hardlink/internal/dispenser/transport_test.go

418 lines
14 KiB
Go

package dispenser
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"testing"
"time"
)
// Golden frames reconstructed from K720_Dll.dll's big-endian length and XOR
// algorithm (SendCmd 0x100050C0, Query 0x10005280, SensorQuery 0x10005420).
var vendorFrames = []struct {
name string
command []byte
frame []byte
}{
{"AP", []byte{2, 'A', 'P'}, []byte{2, 0x30, 0x30, 0, 2, 0x41, 0x50, 3, 0x12}},
{"RF", []byte{2, 'R', 'F'}, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x46, 3, 0x17}},
{"FC7", commandFC7, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x37, 3, 0x30}},
{"FC0", commandFC0, []byte{2, 0x30, 0x30, 0, 3, 0x46, 0x43, 0x30, 3, 0x37}},
{"RS", commandRS, []byte{2, 0x30, 0x30, 0, 2, 0x52, 0x53, 3, 2}},
}
func TestACKScannerFragmentation(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
wrong := []byte("15")
if address == "15" {
wrong = []byte("00")
}
for _, prefix := range [][]byte{nil, {0x30}, {0x10}, {address[1]}, {ACK, wrong[0], wrong[1], 0x30}, {NAK, wrong[0], wrong[1]}} {
for _, token := range []byte{ACK, NAK} {
wire := append(append([]byte(nil), prefix...), token, Address[0], Address[1])
for mask := 0; mask < 1<<(len(wire)-1); mask++ {
t.Run(fmt.Sprintf("%s/%X/%d", address, wire, mask), func(t *testing.T) {
p := &scriptedTransport{}
start := 0
for i := 1; i < len(wire); i++ {
if mask&(1<<(i-1)) != 0 {
p.chunks = append(p.chunks, wire[start:i])
start = i
}
}
p.chunks = append(p.chunks, wire[start:])
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if (err != nil) != (token == NAK) {
t.Fatalf("dispatchCommand(% X)=%v", wire, err)
}
want := [][]byte{createPacket(Address, commandFC7)}
if token == ACK {
want = append(want, append([]byte{ENQ}, Address...))
}
if len(p.writes) != len(want) {
t.Fatalf("writes=% X want=% X", p.writes, want)
}
for i := range want {
if !bytes.Equal(p.writes[i], want[i]) {
t.Errorf("write=% X want=% X", p.writes[i], want[i])
}
}
if len(p.chunks) != 0 {
t.Errorf("unread token bytes=% X", p.chunks)
}
})
}
}
}
}
}
func TestACKScannerBoundsAndFollowingTransaction(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
ack := append([]byte{ACK}, Address...)
for _, leading := range []int{61, 62, 64} {
wire := append(bytes.Repeat([]byte{0xff}, leading), ack...)
p := &scriptedTransport{chunks: [][]byte{wire}}
err := dispatchCommand(context.Background(), p, commandRS, 0)
if (err == nil) != (leading == 61) {
t.Errorf("leading=%d error=%v", leading, err)
}
if leading != 61 && (len(p.writes) != 1 || len(bytes.Join(p.chunks, nil)) != len(wire)-64) {
t.Fatalf("scan exceeded bound: writes=% X remaining=% X", p.writes, p.chunks)
}
}
wire := append(append(append([]byte{0x10}, ack...), ack...), 0xfe)
p := &scriptedTransport{chunks: [][]byte{wire}}
for i := 0; i < 2; i++ {
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err != nil {
t.Fatal(err)
}
}
if len(p.writes) != 4 || !bytes.Equal(bytes.Join(p.chunks, nil), []byte{0xfe}) {
t.Fatalf("transaction boundary lost: writes=% X remaining=% X", p.writes, p.chunks)
}
}
}
type ackReadErrorTransport struct {
*scriptedTransport
reads, failAt int
err error
}
func (p *ackReadErrorTransport) Read(b []byte) (int, error) {
p.reads++
n, err := p.scriptedTransport.Read(b)
if p.reads == p.failAt {
return n, p.err
}
return n, err
}
func TestACKScannerReadErrors(t *testing.T) {
transportAddress(t)
for _, address := range []string{"00", "15"} {
Address = []byte(address)
for _, token := range []byte{ACK, NAK} {
for _, readErr := range []error{io.EOF, os.ErrDeadlineExceeded, io.ErrClosedPipe} {
for _, failAt := range []int{1, 2} {
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{token, Address[0], Address[1]}}}, failAt: failAt, err: readErr}
err := dispatchCommand(context.Background(), p, commandFC7, 0)
if failAt == 1 && !errors.Is(err, readErr) {
t.Fatalf("incomplete token error=%v want=%v", err, readErr)
}
if failAt == 2 && ((err == nil) != (token == ACK) || errors.Is(err, readErr)) {
t.Fatalf("complete token %02X with read error returned %v", token, err)
}
wantWrites := 1
if failAt == 2 && token == ACK {
wantWrites = 2
}
if len(p.writes) != wantWrites || p.reads != failAt {
t.Fatalf("writes=%d reads=%d want=%d/%d", len(p.writes), p.reads, wantWrites, failAt)
}
}
}
}
for _, wire := range [][]byte{nil, {0x30, ACK, Address[0]}, {0xff, 0xfe}, {ACK, Address[0]}} {
p := &scriptedTransport{chunks: [][]byte{wire}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil || len(p.writes) != 1 {
t.Fatalf("incomplete/garbage % X: err=%v writes=% X", wire, err, p.writes)
}
}
for _, cancelAt := range []int{1, 2} {
ctx, cancel := context.WithCancel(context.Background())
p := &ackReadErrorTransport{scriptedTransport: &scriptedTransport{chunks: [][]byte{{ACK, Address[0], Address[1]}}}, failAt: 2, err: io.EOF}
p.afterRead = func() {
if p.reads == cancelAt {
cancel()
}
}
err := dispatchCommand(ctx, p, commandFC7, 0)
cancel()
if !errors.Is(err, context.Canceled) || len(p.writes) != 1 || p.reads != cancelAt {
t.Fatalf("cancel read %d: err=%v writes=%d reads=%d", cancelAt, err, len(p.writes), p.reads)
}
}
}
}
func TestVendorOutboundFrames(t *testing.T) {
for _, tc := range vendorFrames {
t.Run(tc.name, func(t *testing.T) {
if got := createPacket([]byte("00"), tc.command); !bytes.Equal(got, tc.frame) {
t.Errorf("createPacket(%s) = % X, want % X", tc.name, got, tc.frame)
}
if got := calculateBCC(tc.frame[:len(tc.frame)-1]); got != tc.frame[len(tc.frame)-1] {
t.Errorf("calculateBCC(%s) = %02X, want %02X", tc.name, got, tc.frame[len(tc.frame)-1])
}
})
}
}
type scriptedTransport struct {
chunks [][]byte
writes [][]byte
readErr error
writeErr error
shortWrite int
afterRead func()
afterWrite func()
}
func (p *scriptedTransport) Read(b []byte) (int, error) {
if len(p.chunks) == 0 {
if p.readErr != nil {
return 0, p.readErr
}
return 0, io.EOF
}
n := copy(b, p.chunks[0])
p.chunks[0] = p.chunks[0][n:]
if len(p.chunks[0]) == 0 {
p.chunks = p.chunks[1:]
}
if p.afterRead != nil {
p.afterRead()
}
return n, nil
}
func (p *scriptedTransport) Write(b []byte) (int, error) {
p.writes = append(p.writes, append([]byte(nil), b...))
if p.afterWrite != nil {
p.afterWrite()
}
if p.writeErr != nil {
return 0, p.writeErr
}
if p.shortWrite == len(p.writes) {
return len(b) - 1, nil
}
return len(b), nil
}
func transportAddress(t *testing.T) {
t.Helper()
old := Address
Address = []byte("00")
t.Cleanup(func() { Address = old })
}
// Independent SF response vectors: status is 30 30 30 [33].
var vendorAP = []byte{2, 0x30, 0x30, 0, 6, 'S', 'F', 0x30, 0x30, 0x30, 0x33, 3, 0x11}
var vendorRF = []byte{2, 0x30, 0x30, 0, 5, 'S', 'F', 0x30, 0x30, 0x30, 3, 0x21}
var vendorACK = []byte{6, 0x30, 0x30}
func TestVendorQueryFragmentation(t *testing.T) {
transportAddress(t)
for i, frame := range [][]byte{vendorAP, vendorRF} {
wire := append(append([]byte(nil), vendorACK...), frame...)
for split := 1; split < len(wire); split++ {
t.Run(fmt.Sprintf("%s/split%d", vendorFrames[i].name, split), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{wire[:split], wire[split:]}}
got, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0)
if err != nil || !bytes.Equal(got, frame[7:len(frame)-2]) {
t.Fatalf("queryStatus(split=%d) = % X, %v, want % X, nil", split, got, err, frame[7:len(frame)-2])
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], vendorFrames[i].frame) || !bytes.Equal(p.writes[1], []byte{5, 0x30, 0x30}) {
t.Errorf("queryStatus writes = % X, want command then ENQ", p.writes)
}
})
}
t.Run(vendorFrames[i].name+"/one-byte", func(t *testing.T) {
p := &scriptedTransport{}
for _, b := range wire {
p.chunks = append(p.chunks, []byte{b})
}
if _, err := queryStatus(context.Background(), p, vendorFrames[i].command, 4-i, 0); err != nil {
t.Errorf("queryStatus(one-byte reads) = %v, want nil", err)
}
})
}
}
func TestVendorQueryRejectsInvalidFrames(t *testing.T) {
transportAddress(t)
for _, tc := range []struct {
name string
offset int
value byte
}{
{"STX", 0, 1}, {"address high", 1, '1'}, {"address low", 2, '1'},
{"zero length", 4, 0}, {"short length", 4, 5}, {"long length", 4, 7}, {"high length", 3, 0xff},
{"type S", 5, 'X'}, {"type F", 6, 'X'}, {"ETX", 11, 4}, {"BCC", 12, 0},
} {
t.Run(tc.name, func(t *testing.T) {
frame := append([]byte(nil), vendorAP...)
frame[tc.offset] = tc.value
// Keep checksum valid when testing type/ETX to isolate those checks.
if tc.offset == 5 || tc.offset == 6 || tc.offset == 11 {
frame[12] = 0
for _, b := range frame[:12] {
frame[12] ^= b
}
}
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if got, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil || got != nil {
t.Errorf("queryStatus(%s) = % X, %v, want nil/error", tc.name, got, err)
}
if len(p.writes) != 2 {
t.Errorf("queryStatus(%s) writes=%d, want 2 without resend", tc.name, len(p.writes))
}
})
}
for n := 0; n < len(vendorAP); n++ {
t.Run(fmt.Sprintf("truncated%d", n), func(t *testing.T) {
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP[:n]}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err == nil {
t.Errorf("queryStatus(%d-byte frame) succeeded, want error", n)
}
})
}
}
func TestTransportACKAndMechanicalDispatch(t *testing.T) {
transportAddress(t)
for _, tc := range vendorFrames[2:] {
p := &scriptedTransport{chunks: [][]byte{{6}, {'0'}, {'0'}}}
if err := dispatchCommand(context.Background(), p, tc.command, 0); err != nil {
t.Fatalf("dispatchCommand(%s)=%v, want nil", tc.name, err)
}
if len(p.writes) != 2 || !bytes.Equal(p.writes[0], tc.frame) || !bytes.Equal(p.writes[1], []byte{5, '0', '0'}) {
t.Errorf("dispatchCommand(%s) writes=% X, want command then ENQ", tc.name, p.writes)
}
}
for _, ack := range [][]byte{{0x15, '0', '0'}, {6, '1', '0'}, {6, '0', '1'}, {6}, {6, '0'}, {}} {
p := &scriptedTransport{chunks: [][]byte{ack}}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Errorf("dispatchCommand(ACK=% X) succeeded, want error", ack)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(ACK=% X) writes=%d, want 1", ack, len(p.writes))
}
}
}
func TestTransportShortWritesAndErrors(t *testing.T) {
transportAddress(t)
failure := errors.New("serial failure")
for _, stage := range []int{1, 2} {
p := &scriptedTransport{chunks: [][]byte{vendorACK}, shortWrite: stage}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); !errors.Is(err, io.ErrShortWrite) {
t.Errorf("dispatchCommand(short write %d)=%v, want ErrShortWrite", stage, err)
}
if len(p.writes) != stage {
t.Errorf("short write %d writes=%d, want %d", stage, len(p.writes), stage)
}
}
for _, p := range []*scriptedTransport{{writeErr: failure}, {readErr: failure}, {chunks: [][]byte{{}}}} {
if err := dispatchCommand(context.Background(), p, commandRS, 0); err == nil {
t.Error("dispatchCommand(I/O failure) succeeded, want error")
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(I/O failure) writes=%d, want 1", len(p.writes))
}
}
}
func TestTransportCancellation(t *testing.T) {
transportAddress(t)
for _, stage := range []string{"before write", "processing wait", "after ACK", "after ENQ", "after header"} {
t.Run(stage, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p := &scriptedTransport{chunks: [][]byte{vendorACK, vendorAP}}
wantWrites := 1
switch stage {
case "before write":
cancel()
wantWrites = 0
case "processing wait":
p.afterWrite = cancel
case "after ACK":
p.afterRead = func() {
if len(p.chunks) == 1 {
cancel()
}
}
case "after ENQ":
wantWrites = 2
p.afterWrite = func() {
if len(p.writes) == 2 {
cancel()
}
}
case "after header":
wantWrites = 2
reads := 0
p.afterRead = func() {
reads++
if reads == 3 {
cancel()
}
}
}
if _, err := queryStatus(ctx, p, vendorFrames[0].command, 4, 0); !errors.Is(err, context.Canceled) {
t.Errorf("queryStatus(cancel %s)=%v, want Canceled", stage, err)
}
if len(p.writes) != wantWrites {
t.Errorf("queryStatus(cancel %s) writes=%d, want %d", stage, len(p.writes), wantWrites)
}
})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
p := &scriptedTransport{}
if err := dispatchCommand(ctx, p, commandRS, time.Second); !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("dispatchCommand(deadline in wait)=%v, want DeadlineExceeded", err)
}
if len(p.writes) != 1 {
t.Errorf("dispatchCommand(deadline) writes=%d, want 1", len(p.writes))
}
}
func TestTransportTrailingDataNotConsumedAsStatus(t *testing.T) {
transportAddress(t)
frame := append(append([]byte(nil), vendorAP...), 0xff, 0xfe, 0xfd)
p := &scriptedTransport{chunks: [][]byte{vendorACK, frame}}
if _, err := queryStatus(context.Background(), p, vendorFrames[0].command, 4, 0); err != nil {
t.Fatalf("queryStatus(frame with trailing bytes)=%v, want nil", err)
}
if len(p.chunks) != 1 || !bytes.Equal(p.chunks[0], []byte{0xff, 0xfe, 0xfd}) {
t.Fatalf("remaining bytes=% X, want FF FE FD", p.chunks)
}
if err := dispatchCommand(context.Background(), p, commandFC7, 0); err == nil {
t.Error("dispatchCommand(trailing garbage) succeeded, want invalid ACK")
}
if len(p.writes) != 3 {
t.Errorf("writes after trailing garbage=%d, want 3 (no ENQ/resend)", len(p.writes))
}
}