// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package dataplane import ( "encoding/binary" "net" "net/netip" "testing" "time" "echo-lot.app/server/internal/session" ) // parseReportPart decodes one TRAIN_REPORT payload back into rows, checking the header. func parseReportPart(t *testing.T, b []byte) (id uint32, received int, part, parts int, truncated bool, rows []session.TrainEntry) { t.Helper() if len(b) < trainReportHeader { t.Fatalf("report part shorter than its header: %d", len(b)) } id = binary.BigEndian.Uint32(b[0:4]) received = int(binary.BigEndian.Uint32(b[4:8])) part = int(binary.BigEndian.Uint16(b[8:10])) parts = int(binary.BigEndian.Uint16(b[10:12])) truncated = b[12]&1 != 0 n := int(binary.BigEndian.Uint16(b[14:16])) if want := trainReportHeader + n*trainReportRow; len(b) != want { t.Fatalf("part length %d, want %d for %d rows", len(b), want, n) } off := trainReportHeader rows = make([]session.TrainEntry, n) for i := range rows { rows[i].Seq = binary.BigEndian.Uint32(b[off+i*4:]) } off += n * 4 for i := range rows { rows[i].TRxNs = int64(binary.BigEndian.Uint64(b[off+i*8:])) } off += n * 8 for i := range rows { rows[i].Size = binary.BigEndian.Uint16(b[off+i*2:]) } off += n * 2 for i := range rows { rows[i].TTL = b[off+i] } off += n for i := range rows { rows[i].DSCP = b[off+i] } off += n for i := range rows { rows[i].ECN = b[off+i] } return } func TestBuildTrainReportSplitsAndRoundTrips(t *testing.T) { const count = 250 // enough to need several parts train := session.Train{ID: 42, Received: count, Truncated: true} for i := 0; i < count; i++ { train.Entries = append(train.Entries, session.TrainEntry{ Seq: uint32(i), TRxNs: int64(i) * 1_000_000, Size: uint16(100 + i), TTL: 64, DSCP: 46, ECN: 1, }) } parts := buildTrainReport(train) if len(parts) < 2 { t.Fatalf("250 rows should not fit one ≤%d-byte datagram", trainReportMaxDatagram) } var got []session.TrainEntry for i, p := range parts { if HeaderSize+len(p) > trainReportMaxDatagram { t.Fatalf("part %d would be a %d-byte datagram, cap is %d", i, HeaderSize+len(p), trainReportMaxDatagram) } id, received, part, total, truncated, rows := parseReportPart(t, p) if id != 42 || received != count || part != i || total != len(parts) || !truncated { t.Fatalf("part %d header: id=%d received=%d part=%d/%d truncated=%v", i, id, received, part, total, truncated) } got = append(got, rows...) } if len(got) != count { t.Fatalf("round-tripped %d rows, want %d", len(got), count) } for i, r := range got { want := train.Entries[i] if r != want { t.Fatalf("row %d = %+v, want %+v", i, r, want) } } } func TestBuildTrainReportEmptyTrainStillAnswers(t *testing.T) { parts := buildTrainReport(session.Train{ID: 9}) if len(parts) != 1 { t.Fatalf("empty train: %d parts, want 1 — 'nothing arrived' is the answer, not silence", len(parts)) } id, received, _, total, _, rows := parseReportPart(t, parts[0]) if id != 9 || received != 0 || total != 1 || len(rows) != 0 { t.Fatalf("empty report: id=%d received=%d parts=%d rows=%d", id, received, total, len(rows)) } } func TestTrainDataThenReportOverTheWire(t *testing.T) { mgr, addr := startServer(t) sess, _, err := mgr.New("dev1", "credential-ikm", netip.MustParseAddr("127.0.0.1")) if err != nil { t.Fatal(err) } client, err := net.DialUDP("udp", nil, net.UDPAddrFromAddrPort(addr)) if err != nil { t.Fatal(err) } defer client.Close() client.SetDeadline(time.Now().Add(3 * time.Second)) // A short train: id 5 in payload[0:4], plus padding. const trainID, count = 5, 4 for i := 0; i < count; i++ { payload := make([]byte, 60) binary.BigEndian.PutUint32(payload[0:4], trainID) if _, err := client.Write(craft(t, sess, TypeTrainData, uint32(i+1), payload)); err != nil { t.Fatal(err) } } // TRAIN_DATA must be silent (spec §3.2). client.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) if _, err := client.Read(make([]byte, 1500)); err == nil { t.Fatal("TRAIN_DATA got a response, want none") } // Ask for the report. reqPayload := make([]byte, 4) binary.BigEndian.PutUint32(reqPayload, trainID) client.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 100, reqPayload)); err != nil { t.Fatal(err) } buf := make([]byte, 2000) n, err := client.Read(buf) if err != nil { t.Fatalf("no TRAIN_REPORT: %v", err) } if buf[4] != TypeTrainReport { t.Fatalf("type = %#x, want TRAIN_REPORT", buf[4]) } id, received, part, parts, truncated, rows := parseReportPart(t, buf[HeaderSize:n]) if id != trainID || received != count || part != 0 || parts != 1 || truncated { t.Fatalf("report header: id=%d received=%d part=%d/%d truncated=%v", id, received, part, parts, truncated) } if len(rows) != count { t.Fatalf("%d rows, want %d", len(rows), count) } for i, r := range rows { if r.Seq != uint32(i+1) { t.Fatalf("row %d seq = %d, want %d", i, r.Seq, i+1) } if r.Size != HeaderSize+60 { t.Fatalf("row %d size = %d, want %d", i, r.Size, HeaderSize+60) } } // Unknown train id: one zero-row report, not silence. binary.BigEndian.PutUint32(reqPayload, 999) if _, err := client.Write(craft(t, sess, TypeTrainReportReq, 101, reqPayload)); err != nil { t.Fatal(err) } n, err = client.Read(buf) if err != nil { t.Fatalf("no report for unknown train: %v", err) } id, received, _, _, _, rows = parseReportPart(t, buf[HeaderSize:n]) if id != 999 || received != 0 || len(rows) != 0 { t.Fatalf("unknown-train report: id=%d received=%d rows=%d", id, received, len(rows)) } }