Compare commits

..

3 Commits

Author SHA1 Message Date
Kpa-clawbot 6e5516c282 Merge branch 'master' into fix/update-actions-node24 2026-03-29 07:15:27 -07:00
KpaBap cedf79ff83 Merge branch 'master' into fix/update-actions-node24 2026-03-28 17:09:28 -07:00
Kpa-clawbot 9944d50e76 ci: bump GitHub Actions to Node 24 compatible versions
checkout v4→v5, setup-go v5→v6, setup-node v4→v5,
upload-artifact v4→v5, download-artifact v4→v5

Fixes the Node.js 20 deprecation warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 16:45:25 -07:00
19 changed files with 4823 additions and 5257 deletions
+5 -23
View File
@@ -20,10 +20,6 @@ concurrency:
group: deploy-${{ github.event.pull_request.number || github.ref }} group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
defaults:
run:
shell: bash
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -126,13 +122,6 @@ jobs:
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Go coverage badges - name: Upload Go coverage badges
if: always() if: always()
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
@@ -147,7 +136,7 @@ jobs:
# ─────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────
node-test: node-test:
name: "🧪 Node.js Tests" name: "🧪 Node.js Tests"
runs-on: [self-hosted, Linux] runs-on: self-hosted
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -274,13 +263,6 @@ jobs:
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true kill $SERVER_PID 2>/dev/null || true
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Node.js test badges - name: Upload Node.js test badges
if: always() if: always()
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v5
@@ -296,8 +278,8 @@ jobs:
build: build:
name: "🏗️ Build Docker Image" name: "🏗️ Build Docker Image"
if: github.event_name == 'push' if: github.event_name == 'push'
needs: [go-test, node-test] needs: [go-test]
runs-on: [self-hosted, Linux] runs-on: self-hosted
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -322,7 +304,7 @@ jobs:
name: "🚀 Deploy Staging" name: "🚀 Deploy Staging"
if: github.event_name == 'push' if: github.event_name == 'push'
needs: [build] needs: [build]
runs-on: [self-hosted, Linux] runs-on: self-hosted
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -367,7 +349,7 @@ jobs:
name: "📝 Publish Badges & Summary" name: "📝 Publish Badges & Summary"
if: github.event_name == 'push' if: github.event_name == 'push'
needs: [deploy] needs: [deploy]
runs-on: [self-hosted, Linux] runs-on: self-hosted
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
+22 -61
View File
@@ -72,8 +72,8 @@ type Header struct {
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes. // TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct { type TransportCodes struct {
Code1 string `json:"code1"` NextHop string `json:"nextHop"`
Code2 string `json:"code2"` LastHop string `json:"lastHop"`
} }
// Path holds decoded path/hop information. // Path holds decoded path/hop information.
@@ -92,8 +92,6 @@ type AdvertFlags struct {
Room bool `json:"room"` Room bool `json:"room"`
Sensor bool `json:"sensor"` Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"` HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"` HasName bool `json:"hasName"`
} }
@@ -113,8 +111,6 @@ type Payload struct {
Lat *float64 `json:"lat,omitempty"` Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"` Lon *float64 `json:"lon,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Feat1 *int `json:"feat1,omitempty"`
Feat2 *int `json:"feat2,omitempty"`
BatteryMv *int `json:"battery_mv,omitempty"` BatteryMv *int `json:"battery_mv,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
ChannelHash int `json:"channelHash,omitempty"` ChannelHash int `json:"channelHash,omitempty"`
@@ -127,8 +123,6 @@ type Payload struct {
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"` EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"` PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"` Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"` RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -205,13 +199,14 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
} }
func decodeAck(buf []byte) Payload { func decodeAck(buf []byte) Payload {
if len(buf) < 4 { if len(buf) < 6 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
} }
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{ return Payload{
Type: "ACK", Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum), DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
ExtraHash: hex.EncodeToString(buf[2:6]),
} }
} }
@@ -236,8 +231,6 @@ func decodeAdvert(buf []byte) Payload {
if len(appdata) > 0 { if len(appdata) > 0 {
flags := appdata[0] flags := appdata[0]
advType := int(flags & 0x0F) advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{ p.Flags = &AdvertFlags{
Raw: int(flags), Raw: int(flags),
Type: advType, Type: advType,
@@ -246,8 +239,6 @@ func decodeAdvert(buf []byte) Payload {
Room: advType == 3, Room: advType == 3,
Sensor: advType == 4, Sensor: advType == 4,
HasLocation: flags&0x10 != 0, HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0, HasName: flags&0x80 != 0,
} }
@@ -261,16 +252,6 @@ func decodeAdvert(buf []byte) Payload {
p.Lon = &lon p.Lon = &lon
off += 8 off += 8
} }
if hasFeat1 && len(appdata) >= off+2 {
feat1 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat1 = &feat1
off += 2
}
if hasFeat2 && len(appdata) >= off+2 {
feat2 := int(binary.LittleEndian.Uint16(appdata[off : off+2]))
p.Feat2 = &feat2
off += 2
}
if p.Flags.HasName { if p.Flags.HasName {
// Find null terminator to separate name from trailing telemetry bytes // Find null terminator to separate name from trailing telemetry bytes
nameEnd := len(appdata) nameEnd := len(appdata)
@@ -488,22 +469,15 @@ func decodePathPayload(buf []byte) Payload {
} }
func decodeTrace(buf []byte) Payload { func decodeTrace(buf []byte) Payload {
if len(buf) < 9 { if len(buf) < 12 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)} return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
} }
tag := binary.LittleEndian.Uint32(buf[0:4]) return Payload{
authCode := binary.LittleEndian.Uint32(buf[4:8]) Type: "TRACE",
flags := int(buf[8]) DestHash: hex.EncodeToString(buf[5:11]),
p := Payload{ SrcHash: hex.EncodeToString(buf[11:12]),
Type: "TRACE", Tag: binary.LittleEndian.Uint32(buf[1:5]),
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
} }
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
} }
func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload { func decodePayload(payloadType int, buf []byte, channelKeys map[string]string) Payload {
@@ -546,7 +520,8 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
} }
header := decodeHeader(buf[0]) header := decodeHeader(buf[0])
offset := 1 pathByte := buf[1]
offset := 2
var tc *TransportCodes var tc *TransportCodes
if isTransportRoute(header.RouteType) { if isTransportRoute(header.RouteType) {
@@ -554,18 +529,12 @@ func DecodePacket(hexString string, channelKeys map[string]string) (*DecodedPack
return nil, fmt.Errorf("packet too short for transport codes") return nil, fmt.Errorf("packet too short for transport codes")
} }
tc = &TransportCodes{ tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])), NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])), LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
} }
offset += 4 offset += 4
} }
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset) path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed offset += bytesConsumed
@@ -593,24 +562,16 @@ func ComputeContentHash(rawHex string) string {
return rawHex return rawHex
} }
headerByte := buf[0] pathByte := buf[1]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1 hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F) hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes headerByte := buf[0]
payloadStart := 2 + pathBytes
if isTransportRoute(int(headerByte & 0x03)) {
payloadStart += 4
}
if payloadStart > len(buf) { if payloadStart > len(buf) {
if len(rawHex) >= 16 { if len(rawHex) >= 16 {
return rawHex[:16] return rawHex[:16]
+25 -36
View File
@@ -129,8 +129,7 @@ func TestDecodePath3ByteHashes(t *testing.T) {
func TestTransportCodes(t *testing.T) { func TestTransportCodes(t *testing.T) {
// Route type 0 (TRANSPORT_FLOOD) should have transport codes // Route type 0 (TRANSPORT_FLOOD) should have transport codes
// Firmware order: header + transport_codes(4) + path_len + path + payload hex := "1400" + "AABB" + "CCDD" + "1A" + strings.Repeat("00", 10)
hex := "14" + "AABB" + "CCDD" + "00" + strings.Repeat("00", 10)
pkt, err := DecodePacket(hex, nil) pkt, err := DecodePacket(hex, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -141,11 +140,11 @@ func TestTransportCodes(t *testing.T) {
if pkt.TransportCodes == nil { if pkt.TransportCodes == nil {
t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD") t.Fatal("transportCodes should not be nil for TRANSPORT_FLOOD")
} }
if pkt.TransportCodes.Code1 != "AABB" { if pkt.TransportCodes.NextHop != "AABB" {
t.Errorf("code1=%s, want AABB", pkt.TransportCodes.Code1) t.Errorf("nextHop=%s, want AABB", pkt.TransportCodes.NextHop)
} }
if pkt.TransportCodes.Code2 != "CCDD" { if pkt.TransportCodes.LastHop != "CCDD" {
t.Errorf("code2=%s, want CCDD", pkt.TransportCodes.Code2) t.Errorf("lastHop=%s, want CCDD", pkt.TransportCodes.LastHop)
} }
// Route type 1 (FLOOD) should NOT have transport codes // Route type 1 (FLOOD) should NOT have transport codes
@@ -538,11 +537,10 @@ func TestDecodeTraceShort(t *testing.T) {
func TestDecodeTraceValid(t *testing.T) { func TestDecodeTraceValid(t *testing.T) {
buf := make([]byte, 16) buf := make([]byte, 16)
// tag(4) + authCode(4) + flags(1) + pathData buf[0] = 0x00
binary.LittleEndian.PutUint32(buf[0:4], 1) // tag = 1 buf[1] = 0x01 // tag LE uint32 = 1
binary.LittleEndian.PutUint32(buf[4:8], 0xDEADBEEF) // authCode buf[5] = 0xAA // destHash start
buf[8] = 0x02 // flags buf[11] = 0xBB
buf[9] = 0xAA // path data
p := decodeTrace(buf) p := decodeTrace(buf)
if p.Error != "" { if p.Error != "" {
t.Errorf("unexpected error: %s", p.Error) t.Errorf("unexpected error: %s", p.Error)
@@ -550,18 +548,9 @@ func TestDecodeTraceValid(t *testing.T) {
if p.Tag != 1 { if p.Tag != 1 {
t.Errorf("tag=%d, want 1", p.Tag) t.Errorf("tag=%d, want 1", p.Tag)
} }
if p.AuthCode != 0xDEADBEEF {
t.Errorf("authCode=%d, want 0xDEADBEEF", p.AuthCode)
}
if p.TraceFlags == nil || *p.TraceFlags != 2 {
t.Errorf("traceFlags=%v, want 2", p.TraceFlags)
}
if p.Type != "TRACE" { if p.Type != "TRACE" {
t.Errorf("type=%s, want TRACE", p.Type) t.Errorf("type=%s, want TRACE", p.Type)
} }
if p.PathData == "" {
t.Error("pathData should not be empty")
}
} }
func TestDecodeAdvertShort(t *testing.T) { func TestDecodeAdvertShort(t *testing.T) {
@@ -844,9 +833,10 @@ func TestComputeContentHashShortHex(t *testing.T) {
} }
func TestComputeContentHashTransportRoute(t *testing.T) { func TestComputeContentHashTransportRoute(t *testing.T) {
// Route type 0 (TRANSPORT_FLOOD) with transport codes then path=0x00 (0 hops) // Route type 0 (TRANSPORT_FLOOD) with no path hops + 4 transport code bytes
// header=0x14 (TRANSPORT_FLOOD, ADVERT), transport(4), path=0x00 // header=0x14 (TRANSPORT_FLOOD, ADVERT), path=0x00 (0 hops)
hex := "14" + "AABBCCDD" + "00" + strings.Repeat("EE", 10) // transport codes = 4 bytes, then payload
hex := "1400" + "AABBCCDD" + strings.Repeat("EE", 10)
hash := ComputeContentHash(hex) hash := ComputeContentHash(hex)
if len(hash) != 16 { if len(hash) != 16 {
t.Errorf("hash length=%d, want 16", len(hash)) t.Errorf("hash length=%d, want 16", len(hash))
@@ -880,10 +870,12 @@ func TestComputeContentHashPayloadBeyondBufferLongHex(t *testing.T) {
func TestComputeContentHashTransportBeyondBuffer(t *testing.T) { func TestComputeContentHashTransportBeyondBuffer(t *testing.T) {
// Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes // Transport route (0x00 = TRANSPORT_FLOOD) with path claiming some bytes
// header=0x00, transport(4), pathByte=0x02 (2 hops, 1-byte hash) // total buffer too short for transport codes + path
// offset=1+4+1+2=8, buffer needs to be >= 8 // header=0x00, pathByte=0x02 (2 hops, 1-byte hash), then only 2 more bytes
hex := "00" + "AABB" + "CCDD" + "02" + strings.Repeat("CC", 6) // 20 chars = 10 bytes // payloadStart = 2 + 2 + 4(transport) = 8, but buffer only 6 bytes
hex := "0002" + "AABB" + strings.Repeat("CC", 6) // 20 chars = 10 bytes
hash := ComputeContentHash(hex) hash := ComputeContentHash(hex)
// payloadStart = 2 + 2 + 4 = 8, buffer is 10 bytes → should work
if len(hash) != 16 { if len(hash) != 16 {
t.Errorf("hash length=%d, want 16", len(hash)) t.Errorf("hash length=%d, want 16", len(hash))
} }
@@ -921,8 +913,8 @@ func TestDecodePacketWithNewlines(t *testing.T) {
} }
func TestDecodePacketTransportRouteTooShort(t *testing.T) { func TestDecodePacketTransportRouteTooShort(t *testing.T) {
// TRANSPORT_FLOOD (route=0) but only 2 bytes total → too short for transport codes // TRANSPORT_FLOOD (route=0) but only 3 bytes total → too short for transport codes
_, err := DecodePacket("1400", nil) _, err := DecodePacket("140011", nil)
if err == nil { if err == nil {
t.Error("expected error for transport route with too-short buffer") t.Error("expected error for transport route with too-short buffer")
} }
@@ -939,19 +931,16 @@ func TestDecodeAckShort(t *testing.T) {
} }
func TestDecodeAckValid(t *testing.T) { func TestDecodeAckValid(t *testing.T) {
buf := []byte{0xAA, 0xBB, 0xCC, 0xDD} buf := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
p := decodeAck(buf) p := decodeAck(buf)
if p.Error != "" { if p.Error != "" {
t.Errorf("unexpected error: %s", p.Error) t.Errorf("unexpected error: %s", p.Error)
} }
if p.ExtraHash != "ddccbbaa" { if p.DestHash != "aa" {
t.Errorf("extraHash=%s, want ddccbbaa", p.ExtraHash) t.Errorf("destHash=%s, want aa", p.DestHash)
} }
if p.DestHash != "" { if p.ExtraHash != "ccddeeff" {
t.Errorf("destHash should be empty, got %s", p.DestHash) t.Errorf("extraHash=%s, want ccddeeff", p.ExtraHash)
}
if p.SrcHash != "" {
t.Errorf("srcHash should be empty, got %s", p.SrcHash)
} }
} }
+3714 -3717
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -17,8 +17,6 @@ func setupTestDB(t *testing.T) *DB {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Force single connection so all goroutines share the same in-memory DB
conn.SetMaxOpenConns(1)
// Create schema matching MeshCore Analyzer v3 // Create schema matching MeshCore Analyzer v3
schema := ` schema := `
+22 -55
View File
@@ -54,8 +54,8 @@ type Header struct {
// TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes. // TransportCodes are present on TRANSPORT_FLOOD and TRANSPORT_DIRECT routes.
type TransportCodes struct { type TransportCodes struct {
Code1 string `json:"code1"` NextHop string `json:"nextHop"`
Code2 string `json:"code2"` LastHop string `json:"lastHop"`
} }
// Path holds decoded path/hop information. // Path holds decoded path/hop information.
@@ -74,8 +74,6 @@ type AdvertFlags struct {
Room bool `json:"room"` Room bool `json:"room"`
Sensor bool `json:"sensor"` Sensor bool `json:"sensor"`
HasLocation bool `json:"hasLocation"` HasLocation bool `json:"hasLocation"`
HasFeat1 bool `json:"hasFeat1"`
HasFeat2 bool `json:"hasFeat2"`
HasName bool `json:"hasName"` HasName bool `json:"hasName"`
} }
@@ -99,8 +97,6 @@ type Payload struct {
EphemeralPubKey string `json:"ephemeralPubKey,omitempty"` EphemeralPubKey string `json:"ephemeralPubKey,omitempty"`
PathData string `json:"pathData,omitempty"` PathData string `json:"pathData,omitempty"`
Tag uint32 `json:"tag,omitempty"` Tag uint32 `json:"tag,omitempty"`
AuthCode uint32 `json:"authCode,omitempty"`
TraceFlags *int `json:"traceFlags,omitempty"`
RawHex string `json:"raw,omitempty"` RawHex string `json:"raw,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
@@ -177,13 +173,14 @@ func decodeEncryptedPayload(typeName string, buf []byte) Payload {
} }
func decodeAck(buf []byte) Payload { func decodeAck(buf []byte) Payload {
if len(buf) < 4 { if len(buf) < 6 {
return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)} return Payload{Type: "ACK", Error: "too short", RawHex: hex.EncodeToString(buf)}
} }
checksum := binary.LittleEndian.Uint32(buf[0:4])
return Payload{ return Payload{
Type: "ACK", Type: "ACK",
ExtraHash: fmt.Sprintf("%08x", checksum), DestHash: hex.EncodeToString(buf[0:1]),
SrcHash: hex.EncodeToString(buf[1:2]),
ExtraHash: hex.EncodeToString(buf[2:6]),
} }
} }
@@ -208,8 +205,6 @@ func decodeAdvert(buf []byte) Payload {
if len(appdata) > 0 { if len(appdata) > 0 {
flags := appdata[0] flags := appdata[0]
advType := int(flags & 0x0F) advType := int(flags & 0x0F)
hasFeat1 := flags&0x20 != 0
hasFeat2 := flags&0x40 != 0
p.Flags = &AdvertFlags{ p.Flags = &AdvertFlags{
Raw: int(flags), Raw: int(flags),
Type: advType, Type: advType,
@@ -218,8 +213,6 @@ func decodeAdvert(buf []byte) Payload {
Room: advType == 3, Room: advType == 3,
Sensor: advType == 4, Sensor: advType == 4,
HasLocation: flags&0x10 != 0, HasLocation: flags&0x10 != 0,
HasFeat1: hasFeat1,
HasFeat2: hasFeat2,
HasName: flags&0x80 != 0, HasName: flags&0x80 != 0,
} }
@@ -233,12 +226,6 @@ func decodeAdvert(buf []byte) Payload {
p.Lon = &lon p.Lon = &lon
off += 8 off += 8
} }
if hasFeat1 && len(appdata) >= off+2 {
off += 2 // skip feat1 bytes (reserved for future use)
}
if hasFeat2 && len(appdata) >= off+2 {
off += 2 // skip feat2 bytes (reserved for future use)
}
if p.Flags.HasName { if p.Flags.HasName {
name := string(appdata[off:]) name := string(appdata[off:])
name = strings.TrimRight(name, "\x00") name = strings.TrimRight(name, "\x00")
@@ -289,22 +276,15 @@ func decodePathPayload(buf []byte) Payload {
} }
func decodeTrace(buf []byte) Payload { func decodeTrace(buf []byte) Payload {
if len(buf) < 9 { if len(buf) < 12 {
return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)} return Payload{Type: "TRACE", Error: "too short", RawHex: hex.EncodeToString(buf)}
} }
tag := binary.LittleEndian.Uint32(buf[0:4]) return Payload{
authCode := binary.LittleEndian.Uint32(buf[4:8]) Type: "TRACE",
flags := int(buf[8]) DestHash: hex.EncodeToString(buf[5:11]),
p := Payload{ SrcHash: hex.EncodeToString(buf[11:12]),
Type: "TRACE", Tag: binary.LittleEndian.Uint32(buf[1:5]),
Tag: tag,
AuthCode: authCode,
TraceFlags: &flags,
} }
if len(buf) > 9 {
p.PathData = hex.EncodeToString(buf[9:])
}
return p
} }
func decodePayload(payloadType int, buf []byte) Payload { func decodePayload(payloadType int, buf []byte) Payload {
@@ -347,7 +327,8 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
} }
header := decodeHeader(buf[0]) header := decodeHeader(buf[0])
offset := 1 pathByte := buf[1]
offset := 2
var tc *TransportCodes var tc *TransportCodes
if isTransportRoute(header.RouteType) { if isTransportRoute(header.RouteType) {
@@ -355,18 +336,12 @@ func DecodePacket(hexString string) (*DecodedPacket, error) {
return nil, fmt.Errorf("packet too short for transport codes") return nil, fmt.Errorf("packet too short for transport codes")
} }
tc = &TransportCodes{ tc = &TransportCodes{
Code1: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])), NextHop: strings.ToUpper(hex.EncodeToString(buf[offset : offset+2])),
Code2: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])), LastHop: strings.ToUpper(hex.EncodeToString(buf[offset+2 : offset+4])),
} }
offset += 4 offset += 4
} }
if offset >= len(buf) {
return nil, fmt.Errorf("packet too short (no path byte)")
}
pathByte := buf[offset]
offset++
path, bytesConsumed := decodePath(pathByte, buf, offset) path, bytesConsumed := decodePath(pathByte, buf, offset)
offset += bytesConsumed offset += bytesConsumed
@@ -392,24 +367,16 @@ func ComputeContentHash(rawHex string) string {
return rawHex return rawHex
} }
headerByte := buf[0] pathByte := buf[1]
offset := 1
if isTransportRoute(int(headerByte & 0x03)) {
offset += 4
}
if offset >= len(buf) {
if len(rawHex) >= 16 {
return rawHex[:16]
}
return rawHex
}
pathByte := buf[offset]
offset++
hashSize := int((pathByte>>6)&0x3) + 1 hashSize := int((pathByte>>6)&0x3) + 1
hashCount := int(pathByte & 0x3F) hashCount := int(pathByte & 0x3F)
pathBytes := hashSize * hashCount pathBytes := hashSize * hashCount
payloadStart := offset + pathBytes headerByte := buf[0]
payloadStart := 2 + pathBytes
if isTransportRoute(int(headerByte & 0x03)) {
payloadStart += 4
}
if payloadStart > len(buf) { if payloadStart > len(buf) {
if len(rawHex) >= 16 { if len(rawHex) >= 16 {
return rawHex[:16] return rawHex[:16]
+403 -506
View File
@@ -1,506 +1,403 @@
package main package main
// parity_test.go — Golden fixture shape tests. // parity_test.go — Golden fixture shape tests.
// Validates that Go API responses match the shape of Node.js API responses. // Validates that Go API responses match the shape of Node.js API responses.
// Shapes were captured from the production Node.js server and stored in // Shapes were captured from the production Node.js server and stored in
// testdata/golden/shapes.json. // testdata/golden/shapes.json.
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort" "strings"
"strings" "testing"
"testing" )
"time"
) // shapeSpec describes the expected JSON structure from the Node.js server.
type shapeSpec struct {
// shapeSpec describes the expected JSON structure from the Node.js server. Type string `json:"type"`
type shapeSpec struct { Keys map[string]shapeSpec `json:"keys,omitempty"`
Type string `json:"type"` ElementShape *shapeSpec `json:"elementShape,omitempty"`
Keys map[string]shapeSpec `json:"keys,omitempty"` DynamicKeys bool `json:"dynamicKeys,omitempty"`
ElementShape *shapeSpec `json:"elementShape,omitempty"` ValueShape *shapeSpec `json:"valueShape,omitempty"`
DynamicKeys bool `json:"dynamicKeys,omitempty"` RequiredKeys map[string]shapeSpec `json:"requiredKeys,omitempty"`
ValueShape *shapeSpec `json:"valueShape,omitempty"` }
RequiredKeys map[string]shapeSpec `json:"requiredKeys,omitempty"`
} // loadShapes reads testdata/golden/shapes.json relative to this source file.
func loadShapes(t *testing.T) map[string]shapeSpec {
// loadShapes reads testdata/golden/shapes.json relative to this source file. t.Helper()
func loadShapes(t *testing.T) map[string]shapeSpec { _, thisFile, _, _ := runtime.Caller(0)
t.Helper() dir := filepath.Dir(thisFile)
_, thisFile, _, _ := runtime.Caller(0) data, err := os.ReadFile(filepath.Join(dir, "testdata", "golden", "shapes.json"))
dir := filepath.Dir(thisFile) if err != nil {
data, err := os.ReadFile(filepath.Join(dir, "testdata", "golden", "shapes.json")) t.Fatalf("cannot load shapes.json: %v", err)
if err != nil { }
t.Fatalf("cannot load shapes.json: %v", err) var shapes map[string]shapeSpec
} if err := json.Unmarshal(data, &shapes); err != nil {
var shapes map[string]shapeSpec t.Fatalf("cannot parse shapes.json: %v", err)
if err := json.Unmarshal(data, &shapes); err != nil { }
t.Fatalf("cannot parse shapes.json: %v", err) return shapes
} }
return shapes
} // validateShape recursively checks that `actual` matches the expected `spec`.
// `path` tracks the JSON path for error messages.
// validateShape recursively checks that `actual` matches the expected `spec`. // Returns a list of mismatch descriptions.
// `path` tracks the JSON path for error messages. func validateShape(actual interface{}, spec shapeSpec, path string) []string {
// Returns a list of mismatch descriptions. var errs []string
func validateShape(actual interface{}, spec shapeSpec, path string) []string {
var errs []string switch spec.Type {
case "null", "nullable":
switch spec.Type { // nullable means: value can be null OR matching type. Accept anything.
case "null", "nullable": return nil
// nullable means: value can be null OR matching type. Accept anything. case "nullable_number":
return nil // Can be null or number
case "nullable_number": if actual != nil {
// Can be null or number if _, ok := actual.(float64); !ok {
if actual != nil { errs = append(errs, fmt.Sprintf("%s: expected number or null, got %T", path, actual))
if _, ok := actual.(float64); !ok { }
errs = append(errs, fmt.Sprintf("%s: expected number or null, got %T", path, actual)) }
} return errs
} case "string":
return errs if actual == nil {
case "string": errs = append(errs, fmt.Sprintf("%s: expected string, got null", path))
if actual == nil { } else if _, ok := actual.(string); !ok {
errs = append(errs, fmt.Sprintf("%s: expected string, got null", path)) errs = append(errs, fmt.Sprintf("%s: expected string, got %T", path, actual))
} else if _, ok := actual.(string); !ok { }
errs = append(errs, fmt.Sprintf("%s: expected string, got %T", path, actual)) case "number":
} if actual == nil {
case "number": errs = append(errs, fmt.Sprintf("%s: expected number, got null", path))
if actual == nil { } else if _, ok := actual.(float64); !ok {
errs = append(errs, fmt.Sprintf("%s: expected number, got null", path)) errs = append(errs, fmt.Sprintf("%s: expected number, got %T (%v)", path, actual, actual))
} else if _, ok := actual.(float64); !ok { }
errs = append(errs, fmt.Sprintf("%s: expected number, got %T (%v)", path, actual, actual)) case "boolean":
} if actual == nil {
case "boolean": errs = append(errs, fmt.Sprintf("%s: expected boolean, got null", path))
if actual == nil { } else if _, ok := actual.(bool); !ok {
errs = append(errs, fmt.Sprintf("%s: expected boolean, got null", path)) errs = append(errs, fmt.Sprintf("%s: expected boolean, got %T", path, actual))
} else if _, ok := actual.(bool); !ok { }
errs = append(errs, fmt.Sprintf("%s: expected boolean, got %T", path, actual)) case "array":
} if actual == nil {
case "array": errs = append(errs, fmt.Sprintf("%s: expected array, got null (arrays must be [] not null)", path))
if actual == nil { return errs
errs = append(errs, fmt.Sprintf("%s: expected array, got null (arrays must be [] not null)", path)) }
return errs arr, ok := actual.([]interface{})
} if !ok {
arr, ok := actual.([]interface{}) errs = append(errs, fmt.Sprintf("%s: expected array, got %T", path, actual))
if !ok { return errs
errs = append(errs, fmt.Sprintf("%s: expected array, got %T", path, actual)) }
return errs if spec.ElementShape != nil && len(arr) > 0 {
} errs = append(errs, validateShape(arr[0], *spec.ElementShape, path+"[0]")...)
if spec.ElementShape != nil && len(arr) > 0 { }
errs = append(errs, validateShape(arr[0], *spec.ElementShape, path+"[0]")...) case "object":
} if actual == nil {
case "object": errs = append(errs, fmt.Sprintf("%s: expected object, got null", path))
if actual == nil { return errs
errs = append(errs, fmt.Sprintf("%s: expected object, got null", path)) }
return errs obj, ok := actual.(map[string]interface{})
} if !ok {
obj, ok := actual.(map[string]interface{}) errs = append(errs, fmt.Sprintf("%s: expected object, got %T", path, actual))
if !ok { return errs
errs = append(errs, fmt.Sprintf("%s: expected object, got %T", path, actual)) }
return errs
} if spec.DynamicKeys {
// Object with dynamic keys — validate value shapes
if spec.DynamicKeys { if spec.ValueShape != nil && len(obj) > 0 {
// Object with dynamic keys — validate value shapes for k, v := range obj {
if spec.ValueShape != nil && len(obj) > 0 { errs = append(errs, validateShape(v, *spec.ValueShape, path+"."+k)...)
for k, v := range obj { break // check just one sample
errs = append(errs, validateShape(v, *spec.ValueShape, path+"."+k)...) }
break // check just one sample }
} if spec.RequiredKeys != nil {
} for rk, rs := range spec.RequiredKeys {
if spec.RequiredKeys != nil { v, exists := obj[rk]
for rk, rs := range spec.RequiredKeys { if !exists {
v, exists := obj[rk] errs = append(errs, fmt.Sprintf("%s: missing required key %q in dynamic-key object", path, rk))
if !exists { } else {
errs = append(errs, fmt.Sprintf("%s: missing required key %q in dynamic-key object", path, rk)) errs = append(errs, validateShape(v, rs, path+"."+rk)...)
} else { }
errs = append(errs, validateShape(v, rs, path+"."+rk)...) }
} }
} } else if spec.Keys != nil {
} // Object with known keys — check each expected key exists and has correct type
} else if spec.Keys != nil { for key, keySpec := range spec.Keys {
// Object with known keys — check each expected key exists and has correct type val, exists := obj[key]
for key, keySpec := range spec.Keys { if !exists {
val, exists := obj[key] errs = append(errs, fmt.Sprintf("%s: missing field %q (expected %s)", path, key, keySpec.Type))
if !exists { } else {
errs = append(errs, fmt.Sprintf("%s: missing field %q (expected %s)", path, key, keySpec.Type)) errs = append(errs, validateShape(val, keySpec, path+"."+key)...)
} else { }
errs = append(errs, validateShape(val, keySpec, path+"."+key)...) }
} }
} }
}
} return errs
}
return errs
} // parityEndpoint defines one endpoint to test for parity.
type parityEndpoint struct {
// parityEndpoint defines one endpoint to test for parity. name string // key in shapes.json
type parityEndpoint struct { path string // HTTP path to request
name string // key in shapes.json }
path string // HTTP path to request
} func TestParityShapes(t *testing.T) {
shapes := loadShapes(t)
func TestParityShapes(t *testing.T) { _, router := setupTestServer(t)
shapes := loadShapes(t)
_, router := setupTestServer(t) endpoints := []parityEndpoint{
{"stats", "/api/stats"},
endpoints := []parityEndpoint{ {"nodes", "/api/nodes?limit=5"},
{"stats", "/api/stats"}, {"packets", "/api/packets?limit=5"},
{"nodes", "/api/nodes?limit=5"}, {"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
{"packets", "/api/packets?limit=5"}, {"observers", "/api/observers"},
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"}, {"channels", "/api/channels"},
{"observers", "/api/observers"}, {"channel_messages", "/api/channels/0000000000000000/messages?limit=5"},
{"channels", "/api/channels"}, {"analytics_rf", "/api/analytics/rf?days=7"},
{"channel_messages", "/api/channels/0000000000000000/messages?limit=5"}, {"analytics_topology", "/api/analytics/topology?days=7"},
{"analytics_rf", "/api/analytics/rf?days=7"}, {"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
{"analytics_topology", "/api/analytics/topology?days=7"}, {"analytics_distance", "/api/analytics/distance?days=7"},
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"}, {"analytics_subpaths", "/api/analytics/subpaths?days=7"},
{"analytics_distance", "/api/analytics/distance?days=7"}, {"bulk_health", "/api/nodes/bulk-health"},
{"analytics_subpaths", "/api/analytics/subpaths?days=7"}, {"health", "/api/health"},
{"bulk_health", "/api/nodes/bulk-health"}, {"perf", "/api/perf"},
{"health", "/api/health"}, }
{"perf", "/api/perf"},
} for _, ep := range endpoints {
t.Run("Parity_"+ep.name, func(t *testing.T) {
for _, ep := range endpoints { spec, ok := shapes[ep.name]
t.Run("Parity_"+ep.name, func(t *testing.T) { if !ok {
spec, ok := shapes[ep.name] t.Fatalf("no shape spec found for %q in shapes.json", ep.name)
if !ok { }
t.Fatalf("no shape spec found for %q in shapes.json", ep.name)
} req := httptest.NewRequest("GET", ep.path, nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", ep.path, nil) router.ServeHTTP(w, req)
w := httptest.NewRecorder()
router.ServeHTTP(w, req) if w.Code != 200 {
t.Fatalf("GET %s returned %d, expected 200. Body: %s",
if w.Code != 200 { ep.path, w.Code, w.Body.String())
t.Fatalf("GET %s returned %d, expected 200. Body: %s", }
ep.path, w.Code, w.Body.String())
} var body interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
var body interface{} t.Fatalf("GET %s returned invalid JSON: %v\nBody: %s",
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { ep.path, err, w.Body.String())
t.Fatalf("GET %s returned invalid JSON: %v\nBody: %s", }
ep.path, err, w.Body.String())
} mismatches := validateShape(body, spec, ep.path)
if len(mismatches) > 0 {
mismatches := validateShape(body, spec, ep.path) t.Errorf("Go %s has %d shape mismatches vs Node.js golden:\n %s",
if len(mismatches) > 0 { ep.path, len(mismatches), strings.Join(mismatches, "\n "))
t.Errorf("Go %s has %d shape mismatches vs Node.js golden:\n %s", }
ep.path, len(mismatches), strings.Join(mismatches, "\n ")) })
} }
}) }
}
} // TestParityNodeDetail tests node detail endpoint shape.
// Uses a known test node public key from seeded data.
// TestParityNodeDetail tests node detail endpoint shape. func TestParityNodeDetail(t *testing.T) {
// Uses a known test node public key from seeded data. shapes := loadShapes(t)
func TestParityNodeDetail(t *testing.T) { _, router := setupTestServer(t)
shapes := loadShapes(t)
_, router := setupTestServer(t) spec, ok := shapes["node_detail"]
if !ok {
spec, ok := shapes["node_detail"] t.Fatal("no shape spec for node_detail in shapes.json")
if !ok { }
t.Fatal("no shape spec for node_detail in shapes.json")
} req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/nodes/aabbccdd11223344", nil) router.ServeHTTP(w, req)
w := httptest.NewRecorder()
router.ServeHTTP(w, req) if w.Code != 200 {
t.Fatalf("node detail returned %d: %s", w.Code, w.Body.String())
if w.Code != 200 { }
t.Fatalf("node detail returned %d: %s", w.Code, w.Body.String())
} var body interface{}
json.Unmarshal(w.Body.Bytes(), &body)
var body interface{}
json.Unmarshal(w.Body.Bytes(), &body) mismatches := validateShape(body, spec, "/api/nodes/{pubkey}")
if len(mismatches) > 0 {
mismatches := validateShape(body, spec, "/api/nodes/{pubkey}") t.Errorf("Go node detail has %d shape mismatches vs Node.js golden:\n %s",
if len(mismatches) > 0 { len(mismatches), strings.Join(mismatches, "\n "))
t.Errorf("Go node detail has %d shape mismatches vs Node.js golden:\n %s", }
len(mismatches), strings.Join(mismatches, "\n ")) }
}
} // TestParityArraysNotNull verifies that array-typed fields in Go responses are
// [] (empty array) rather than null. This is a common Go/JSON pitfall where
// TestParityArraysNotNull verifies that array-typed fields in Go responses are // nil slices marshal as null instead of [].
// [] (empty array) rather than null. This is a common Go/JSON pitfall where // Uses shapes.json to know which fields SHOULD be arrays.
// nil slices marshal as null instead of []. func TestParityArraysNotNull(t *testing.T) {
// Uses shapes.json to know which fields SHOULD be arrays. shapes := loadShapes(t)
func TestParityArraysNotNull(t *testing.T) { _, router := setupTestServer(t)
shapes := loadShapes(t)
_, router := setupTestServer(t) endpoints := []struct {
name string
endpoints := []struct { path string
name string }{
path string {"stats", "/api/stats"},
}{ {"nodes", "/api/nodes?limit=5"},
{"stats", "/api/stats"}, {"packets", "/api/packets?limit=5"},
{"nodes", "/api/nodes?limit=5"}, {"packets_grouped", "/api/packets?limit=5&groupByHash=true"},
{"packets", "/api/packets?limit=5"}, {"observers", "/api/observers"},
{"packets_grouped", "/api/packets?limit=5&groupByHash=true"}, {"channels", "/api/channels"},
{"observers", "/api/observers"}, {"bulk_health", "/api/nodes/bulk-health"},
{"channels", "/api/channels"}, {"analytics_rf", "/api/analytics/rf?days=7"},
{"bulk_health", "/api/nodes/bulk-health"}, {"analytics_topology", "/api/analytics/topology?days=7"},
{"analytics_rf", "/api/analytics/rf?days=7"}, {"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"},
{"analytics_topology", "/api/analytics/topology?days=7"}, {"analytics_distance", "/api/analytics/distance?days=7"},
{"analytics_hash_sizes", "/api/analytics/hash-sizes?days=7"}, {"analytics_subpaths", "/api/analytics/subpaths?days=7"},
{"analytics_distance", "/api/analytics/distance?days=7"}, }
{"analytics_subpaths", "/api/analytics/subpaths?days=7"},
} for _, ep := range endpoints {
t.Run("NullArrayCheck_"+ep.name, func(t *testing.T) {
for _, ep := range endpoints { spec, ok := shapes[ep.name]
t.Run("NullArrayCheck_"+ep.name, func(t *testing.T) { if !ok {
spec, ok := shapes[ep.name] t.Skipf("no shape spec for %s", ep.name)
if !ok { }
t.Skipf("no shape spec for %s", ep.name)
} req := httptest.NewRequest("GET", ep.path, nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", ep.path, nil) router.ServeHTTP(w, req)
w := httptest.NewRecorder()
router.ServeHTTP(w, req) if w.Code != 200 {
t.Skipf("GET %s returned %d, skipping null-array check", ep.path, w.Code)
if w.Code != 200 { }
t.Skipf("GET %s returned %d, skipping null-array check", ep.path, w.Code)
} var body interface{}
json.Unmarshal(w.Body.Bytes(), &body)
var body interface{}
json.Unmarshal(w.Body.Bytes(), &body) nullArrays := findNullArrays(body, spec, ep.path)
if len(nullArrays) > 0 {
nullArrays := findNullArrays(body, spec, ep.path) t.Errorf("Go %s has null where [] expected:\n %s\n"+
if len(nullArrays) > 0 { "Go nil slices marshal as null — initialize with make() or literal",
t.Errorf("Go %s has null where [] expected:\n %s\n"+ ep.path, strings.Join(nullArrays, "\n "))
"Go nil slices marshal as null — initialize with make() or literal", }
ep.path, strings.Join(nullArrays, "\n ")) })
} }
}) }
}
} // findNullArrays walks JSON data alongside a shape spec and returns paths
// where the spec says the field should be an array but Go returned null.
// findNullArrays walks JSON data alongside a shape spec and returns paths func findNullArrays(actual interface{}, spec shapeSpec, path string) []string {
// where the spec says the field should be an array but Go returned null. var nulls []string
func findNullArrays(actual interface{}, spec shapeSpec, path string) []string {
var nulls []string switch spec.Type {
case "array":
switch spec.Type { if actual == nil {
case "array": nulls = append(nulls, fmt.Sprintf("%s: null (should be [])", path))
if actual == nil { } else if arr, ok := actual.([]interface{}); ok && spec.ElementShape != nil {
nulls = append(nulls, fmt.Sprintf("%s: null (should be [])", path)) for i, elem := range arr {
} else if arr, ok := actual.([]interface{}); ok && spec.ElementShape != nil { nulls = append(nulls, findNullArrays(elem, *spec.ElementShape, fmt.Sprintf("%s[%d]", path, i))...)
for i, elem := range arr { }
nulls = append(nulls, findNullArrays(elem, *spec.ElementShape, fmt.Sprintf("%s[%d]", path, i))...) }
} case "object":
} obj, ok := actual.(map[string]interface{})
case "object": if !ok || obj == nil {
obj, ok := actual.(map[string]interface{}) return nulls
if !ok || obj == nil { }
return nulls if spec.Keys != nil {
} for key, keySpec := range spec.Keys {
if spec.Keys != nil { if val, exists := obj[key]; exists {
for key, keySpec := range spec.Keys { nulls = append(nulls, findNullArrays(val, keySpec, path+"."+key)...)
if val, exists := obj[key]; exists { } else if keySpec.Type == "array" {
nulls = append(nulls, findNullArrays(val, keySpec, path+"."+key)...) // Key missing entirely — also a null-array problem
} else if keySpec.Type == "array" { nulls = append(nulls, fmt.Sprintf("%s.%s: missing (should be [])", path, key))
// Key missing entirely — also a null-array problem }
nulls = append(nulls, fmt.Sprintf("%s.%s: missing (should be [])", path, key)) }
} }
} if spec.DynamicKeys && spec.ValueShape != nil {
} for k, v := range obj {
if spec.DynamicKeys && spec.ValueShape != nil { nulls = append(nulls, findNullArrays(v, *spec.ValueShape, path+"."+k)...)
for k, v := range obj { break // sample one
nulls = append(nulls, findNullArrays(v, *spec.ValueShape, path+"."+k)...) }
break // sample one }
} }
}
} return nulls
}
return nulls
} // TestParityHealthEngine verifies Go health endpoint declares engine=go
// while Node declares engine=node (or omits it). The Go server must always
// TestParityHealthEngine verifies Go health endpoint declares engine=go // identify itself.
// while Node declares engine=node (or omits it). The Go server must always func TestParityHealthEngine(t *testing.T) {
// identify itself. _, router := setupTestServer(t)
func TestParityHealthEngine(t *testing.T) {
_, router := setupTestServer(t) req := httptest.NewRequest("GET", "/api/health", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/health", nil) router.ServeHTTP(w, req)
w := httptest.NewRecorder()
router.ServeHTTP(w, req) var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body) engine, ok := body["engine"]
if !ok {
engine, ok := body["engine"] t.Error("health response missing 'engine' field (Go server must include engine=go)")
if !ok { } else if engine != "go" {
t.Error("health response missing 'engine' field (Go server must include engine=go)") t.Errorf("health engine=%v, expected 'go'", engine)
} else if engine != "go" { }
t.Errorf("health engine=%v, expected 'go'", engine) }
}
} // TestValidateShapeFunction directly tests the shape validator itself.
func TestValidateShapeFunction(t *testing.T) {
// TestValidateShapeFunction directly tests the shape validator itself. t.Run("string match", func(t *testing.T) {
func TestValidateShapeFunction(t *testing.T) { errs := validateShape("hello", shapeSpec{Type: "string"}, "$.x")
t.Run("string match", func(t *testing.T) { if len(errs) != 0 {
errs := validateShape("hello", shapeSpec{Type: "string"}, "$.x") t.Errorf("unexpected errors: %v", errs)
if len(errs) != 0 { }
t.Errorf("unexpected errors: %v", errs) })
}
}) t.Run("string mismatch", func(t *testing.T) {
errs := validateShape(42.0, shapeSpec{Type: "string"}, "$.x")
t.Run("string mismatch", func(t *testing.T) { if len(errs) != 1 {
errs := validateShape(42.0, shapeSpec{Type: "string"}, "$.x") t.Errorf("expected 1 error, got %d: %v", len(errs), errs)
if len(errs) != 1 { }
t.Errorf("expected 1 error, got %d: %v", len(errs), errs) })
}
}) t.Run("null array rejected", func(t *testing.T) {
errs := validateShape(nil, shapeSpec{Type: "array"}, "$.arr")
t.Run("null array rejected", func(t *testing.T) { if len(errs) != 1 || !strings.Contains(errs[0], "null") {
errs := validateShape(nil, shapeSpec{Type: "array"}, "$.arr") t.Errorf("expected null-array error, got: %v", errs)
if len(errs) != 1 || !strings.Contains(errs[0], "null") { }
t.Errorf("expected null-array error, got: %v", errs) })
}
}) t.Run("empty array OK", func(t *testing.T) {
errs := validateShape([]interface{}{}, shapeSpec{Type: "array"}, "$.arr")
t.Run("empty array OK", func(t *testing.T) { if len(errs) != 0 {
errs := validateShape([]interface{}{}, shapeSpec{Type: "array"}, "$.arr") t.Errorf("unexpected errors for empty array: %v", errs)
if len(errs) != 0 { }
t.Errorf("unexpected errors for empty array: %v", errs) })
}
}) t.Run("missing object key", func(t *testing.T) {
spec := shapeSpec{Type: "object", Keys: map[string]shapeSpec{
t.Run("missing object key", func(t *testing.T) { "name": {Type: "string"},
spec := shapeSpec{Type: "object", Keys: map[string]shapeSpec{ "age": {Type: "number"},
"name": {Type: "string"}, }}
"age": {Type: "number"}, obj := map[string]interface{}{"name": "test"}
}} errs := validateShape(obj, spec, "$.user")
obj := map[string]interface{}{"name": "test"} if len(errs) != 1 || !strings.Contains(errs[0], "age") {
errs := validateShape(obj, spec, "$.user") t.Errorf("expected missing age error, got: %v", errs)
if len(errs) != 1 || !strings.Contains(errs[0], "age") { }
t.Errorf("expected missing age error, got: %v", errs) })
}
}) t.Run("nullable allows null", func(t *testing.T) {
errs := validateShape(nil, shapeSpec{Type: "nullable"}, "$.x")
t.Run("nullable allows null", func(t *testing.T) { if len(errs) != 0 {
errs := validateShape(nil, shapeSpec{Type: "nullable"}, "$.x") t.Errorf("nullable should accept null: %v", errs)
if len(errs) != 0 { }
t.Errorf("nullable should accept null: %v", errs) })
}
}) t.Run("dynamic keys validates value shape", func(t *testing.T) {
spec := shapeSpec{
t.Run("dynamic keys validates value shape", func(t *testing.T) { Type: "object",
spec := shapeSpec{ DynamicKeys: true,
Type: "object", ValueShape: &shapeSpec{Type: "number"},
DynamicKeys: true, }
ValueShape: &shapeSpec{Type: "number"}, obj := map[string]interface{}{"a": 1.0, "b": 2.0}
} errs := validateShape(obj, spec, "$.dyn")
obj := map[string]interface{}{"a": 1.0, "b": 2.0} if len(errs) != 0 {
errs := validateShape(obj, spec, "$.dyn") t.Errorf("unexpected errors: %v", errs)
if len(errs) != 0 { }
t.Errorf("unexpected errors: %v", errs) })
} }
})
}
func TestParityWSMultiObserverGolden(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
hub := NewHub()
store := NewPacketStore(db)
if err := store.Load(); err != nil {
t.Fatalf("store load failed: %v", err)
}
poller := NewPoller(db, hub, 50*time.Millisecond)
poller.store = store
client := &Client{send: make(chan []byte, 256)}
hub.Register(client)
defer hub.Unregister(client)
go poller.Start()
defer poller.Stop()
// Wait for poller to initialize its lastID/lastObsID cursors before
// inserting new data; otherwise the poller may snapshot a lastID that
// already includes the test data and never broadcast it.
time.Sleep(100 * time.Millisecond)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('BEEF', 'goldenstarburst237', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
t.Fatalf("insert tx failed: %v", err)
}
var txID int
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='goldenstarburst237'`).Scan(&txID); err != nil {
t.Fatalf("query tx id failed: %v", err)
}
ts := time.Now().Unix()
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 11.0, -88, '["p1"]', ?),
(?, 2, 9.0, -92, '["p1","p2"]', ?),
(?, 1, 7.0, -96, '["p1","p2","p3"]', ?)`,
txID, ts, txID, ts+1, txID, ts+2); err != nil {
t.Fatalf("insert obs failed: %v", err)
}
type golden struct {
Hash string
Count int
Paths []string
ObserverIDs []string
}
expected := golden{
Hash: "goldenstarburst237",
Count: 3,
Paths: []string{`["p1"]`, `["p1","p2"]`, `["p1","p2","p3"]`},
ObserverIDs: []string{"obs1", "obs2"},
}
gotPaths := make([]string, 0, expected.Count)
gotObservers := make(map[string]bool)
deadline := time.After(2 * time.Second)
for len(gotPaths) < expected.Count {
select {
case raw := <-client.send:
var msg map[string]interface{}
if err := json.Unmarshal(raw, &msg); err != nil {
t.Fatalf("unmarshal ws message failed: %v", err)
}
if msg["type"] != "packet" {
continue
}
data, _ := msg["data"].(map[string]interface{})
if data == nil || data["hash"] != expected.Hash {
continue
}
if path, ok := data["path_json"].(string); ok {
gotPaths = append(gotPaths, path)
}
if oid, ok := data["observer_id"].(string); ok && oid != "" {
gotObservers[oid] = true
}
case <-deadline:
t.Fatalf("timed out waiting for %d ws messages, got %d", expected.Count, len(gotPaths))
}
}
sort.Strings(gotPaths)
sort.Strings(expected.Paths)
if len(gotPaths) != len(expected.Paths) {
t.Fatalf("path count mismatch: got %d want %d", len(gotPaths), len(expected.Paths))
}
for i := range expected.Paths {
if gotPaths[i] != expected.Paths[i] {
t.Fatalf("path mismatch at %d: got %q want %q", i, gotPaths[i], expected.Paths[i])
}
}
for _, oid := range expected.ObserverIDs {
if !gotObservers[oid] {
t.Fatalf("missing expected observer %q in ws messages", oid)
}
}
}
+35 -70
View File
@@ -1039,7 +1039,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
} }
} }
// Build broadcast maps (same shape as Node.js WS broadcast), one per observation. // Build broadcast maps (same shape as Node.js WS broadcast)
result := make([]map[string]interface{}, 0, len(broadcastOrder)) result := make([]map[string]interface{}, 0, len(broadcastOrder))
for _, txID := range broadcastOrder { for _, txID := range broadcastOrder {
tx := broadcastTxs[txID] tx := broadcastTxs[txID]
@@ -1055,34 +1055,32 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
decoded["payload"] = payload decoded["payload"] = payload
} }
} }
for _, obs := range tx.Observations { // Build the nested packet object (packets.js checks m.data.packet)
// Build the nested packet object (packets.js checks m.data.packet) pkt := map[string]interface{}{
pkt := map[string]interface{}{ "id": tx.ID,
"id": tx.ID, "raw_hex": strOrNil(tx.RawHex),
"raw_hex": strOrNil(tx.RawHex), "hash": strOrNil(tx.Hash),
"hash": strOrNil(tx.Hash), "first_seen": strOrNil(tx.FirstSeen),
"first_seen": strOrNil(tx.FirstSeen), "timestamp": strOrNil(tx.FirstSeen),
"timestamp": strOrNil(tx.FirstSeen), "route_type": intPtrOrNil(tx.RouteType),
"route_type": intPtrOrNil(tx.RouteType), "payload_type": intPtrOrNil(tx.PayloadType),
"payload_type": intPtrOrNil(tx.PayloadType), "decoded_json": strOrNil(tx.DecodedJSON),
"decoded_json": strOrNil(tx.DecodedJSON), "observer_id": strOrNil(tx.ObserverID),
"observer_id": strOrNil(obs.ObserverID), "observer_name": strOrNil(tx.ObserverName),
"observer_name": strOrNil(obs.ObserverName), "snr": floatPtrOrNil(tx.SNR),
"snr": floatPtrOrNil(obs.SNR), "rssi": floatPtrOrNil(tx.RSSI),
"rssi": floatPtrOrNil(obs.RSSI), "path_json": strOrNil(tx.PathJSON),
"path_json": strOrNil(obs.PathJSON), "direction": strOrNil(tx.Direction),
"direction": strOrNil(obs.Direction), "observation_count": tx.ObservationCount,
"observation_count": tx.ObservationCount,
}
// Broadcast map: top-level fields for live.js + nested packet for packets.js
broadcastMap := make(map[string]interface{}, len(pkt)+2)
for k, v := range pkt {
broadcastMap[k] = v
}
broadcastMap["decoded"] = decoded
broadcastMap["packet"] = pkt
result = append(result, broadcastMap)
} }
// Broadcast map: top-level fields for live.js + nested packet for packets.js
broadcastMap := make(map[string]interface{}, len(pkt)+2)
for k, v := range pkt {
broadcastMap[k] = v
}
broadcastMap["decoded"] = decoded
broadcastMap["packet"] = pkt
result = append(result, broadcastMap)
} }
// Invalidate analytics caches since new data was ingested // Invalidate analytics caches since new data was ingested
@@ -1103,7 +1101,7 @@ func (s *PacketStore) IngestNewFromDB(sinceID, limit int) ([]map[string]interfac
// IngestNewObservations loads new observations for transmissions already in the // IngestNewObservations loads new observations for transmissions already in the
// store. This catches observations that arrive after IngestNewFromDB has already // store. This catches observations that arrive after IngestNewFromDB has already
// advanced past the transmission's ID (fixes #174). // advanced past the transmission's ID (fixes #174).
func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]interface{} { func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) int {
if limit <= 0 { if limit <= 0 {
limit = 500 limit = 500
} }
@@ -1129,7 +1127,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
rows, err := s.db.conn.Query(querySQL, sinceObsID, limit) rows, err := s.db.conn.Query(querySQL, sinceObsID, limit)
if err != nil { if err != nil {
log.Printf("[store] ingest observations query error: %v", err) log.Printf("[store] ingest observations query error: %v", err)
return nil return sinceObsID
} }
defer rows.Close() defer rows.Close()
@@ -1172,16 +1170,20 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
} }
if len(obsRows) == 0 { if len(obsRows) == 0 {
return nil return sinceObsID
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
newMaxObsID := sinceObsID
updatedTxs := make(map[int]*StoreTx) updatedTxs := make(map[int]*StoreTx)
broadcastMaps := make([]map[string]interface{}, 0, len(obsRows))
for _, r := range obsRows { for _, r := range obsRows {
if r.obsID > newMaxObsID {
newMaxObsID = r.obsID
}
// Already ingested (e.g. by IngestNewFromDB in same cycle) // Already ingested (e.g. by IngestNewFromDB in same cycle)
if _, exists := s.byObsID[r.obsID]; exists { if _, exists := s.byObsID[r.obsID]; exists {
continue continue
@@ -1224,43 +1226,6 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
} }
s.totalObs++ s.totalObs++
updatedTxs[r.txID] = tx updatedTxs[r.txID] = tx
decoded := map[string]interface{}{
"header": map[string]interface{}{
"payloadTypeName": resolvePayloadTypeName(tx.PayloadType),
},
}
if tx.DecodedJSON != "" {
var payload map[string]interface{}
if json.Unmarshal([]byte(tx.DecodedJSON), &payload) == nil {
decoded["payload"] = payload
}
}
pkt := map[string]interface{}{
"id": tx.ID,
"raw_hex": strOrNil(tx.RawHex),
"hash": strOrNil(tx.Hash),
"first_seen": strOrNil(tx.FirstSeen),
"timestamp": strOrNil(tx.FirstSeen),
"route_type": intPtrOrNil(tx.RouteType),
"payload_type": intPtrOrNil(tx.PayloadType),
"decoded_json": strOrNil(tx.DecodedJSON),
"observer_id": strOrNil(obs.ObserverID),
"observer_name": strOrNil(obs.ObserverName),
"snr": floatPtrOrNil(obs.SNR),
"rssi": floatPtrOrNil(obs.RSSI),
"path_json": strOrNil(obs.PathJSON),
"direction": strOrNil(obs.Direction),
"observation_count": tx.ObservationCount,
}
broadcastMap := make(map[string]interface{}, len(pkt)+2)
for k, v := range pkt {
broadcastMap[k] = v
}
broadcastMap["decoded"] = decoded
broadcastMap["packet"] = pkt
broadcastMaps = append(broadcastMaps, broadcastMap)
} }
// Re-pick best observation for updated transmissions and update subpath index // Re-pick best observation for updated transmissions and update subpath index
@@ -1315,7 +1280,7 @@ func (s *PacketStore) IngestNewObservations(sinceObsID, limit int) []map[string]
// analytics caches cleared; no per-cycle log to avoid stdout overhead // analytics caches cleared; no per-cycle log to avoid stdout overhead
} }
return broadcastMaps return newMaxObsID
} }
// MaxTransmissionID returns the highest transmission ID in the store. // MaxTransmissionID returns the highest transmission ID in the store.
+229 -245
View File
@@ -1,245 +1,229 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 4096, WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
// Hub manages WebSocket clients and broadcasts. // Hub manages WebSocket clients and broadcasts.
type Hub struct { type Hub struct {
mu sync.RWMutex mu sync.RWMutex
clients map[*Client]bool clients map[*Client]bool
} }
// Client is a single WebSocket connection. // Client is a single WebSocket connection.
type Client struct { type Client struct {
conn *websocket.Conn conn *websocket.Conn
send chan []byte send chan []byte
} }
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
clients: make(map[*Client]bool), clients: make(map[*Client]bool),
} }
} }
func (h *Hub) ClientCount() int { func (h *Hub) ClientCount() int {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
return len(h.clients) return len(h.clients)
} }
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
h.mu.Lock() h.mu.Lock()
h.clients[c] = true h.clients[c] = true
h.mu.Unlock() h.mu.Unlock()
log.Printf("[ws] client connected (%d total)", h.ClientCount()) log.Printf("[ws] client connected (%d total)", h.ClientCount())
} }
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
h.mu.Lock() h.mu.Lock()
if _, ok := h.clients[c]; ok { if _, ok := h.clients[c]; ok {
delete(h.clients, c) delete(h.clients, c)
close(c.send) close(c.send)
} }
h.mu.Unlock() h.mu.Unlock()
log.Printf("[ws] client disconnected (%d total)", h.ClientCount()) log.Printf("[ws] client disconnected (%d total)", h.ClientCount())
} }
// Broadcast sends a message to all connected clients. // Broadcast sends a message to all connected clients.
func (h *Hub) Broadcast(msg interface{}) { func (h *Hub) Broadcast(msg interface{}) {
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { if err != nil {
log.Printf("[ws] marshal error: %v", err) log.Printf("[ws] marshal error: %v", err)
return return
} }
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
for c := range h.clients { for c := range h.clients {
select { select {
case c.send <- data: case c.send <- data:
default: default:
// Client buffer full — drop // Client buffer full — drop
} }
} }
} }
// ServeWS handles the WebSocket upgrade and runs the client. // ServeWS handles the WebSocket upgrade and runs the client.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("[ws] upgrade error: %v", err) log.Printf("[ws] upgrade error: %v", err)
return return
} }
client := &Client{ client := &Client{
conn: conn, conn: conn,
send: make(chan []byte, 256), send: make(chan []byte, 256),
} }
h.Register(client) h.Register(client)
go client.writePump() go client.writePump()
go client.readPump(h) go client.readPump(h)
} }
// wsOrStatic upgrades WebSocket requests at any path, serves static files otherwise. // wsOrStatic upgrades WebSocket requests at any path, serves static files otherwise.
func wsOrStatic(hub *Hub, static http.Handler) http.Handler { func wsOrStatic(hub *Hub, static http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
hub.ServeWS(w, r) hub.ServeWS(w, r)
return return
} }
static.ServeHTTP(w, r) static.ServeHTTP(w, r)
}) })
} }
func (c *Client) readPump(hub *Hub) { func (c *Client) readPump(hub *Hub) {
defer func() { defer func() {
hub.Unregister(c) hub.Unregister(c)
c.conn.Close() c.conn.Close()
}() }()
c.conn.SetReadLimit(512) c.conn.SetReadLimit(512)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error { c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil return nil
}) })
for { for {
_, _, err := c.conn.ReadMessage() _, _, err := c.conn.ReadMessage()
if err != nil { if err != nil {
break break
} }
} }
} }
func (c *Client) writePump() { func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer func() { defer func() {
ticker.Stop() ticker.Stop()
c.conn.Close() c.conn.Close()
}() }()
for { for {
select { select {
case message, ok := <-c.send: case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok { if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{}) c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return return
} }
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return return
} }
case <-ticker.C: case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return return
} }
} }
} }
} }
// Poller watches for new transmissions in SQLite and broadcasts them. // Poller watches for new transmissions in SQLite and broadcasts them.
type Poller struct { type Poller struct {
db *DB db *DB
hub *Hub hub *Hub
store *PacketStore // optional: if set, new transmissions are ingested into memory store *PacketStore // optional: if set, new transmissions are ingested into memory
interval time.Duration interval time.Duration
stop chan struct{} stop chan struct{}
} }
func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller { func NewPoller(db *DB, hub *Hub, interval time.Duration) *Poller {
return &Poller{db: db, hub: hub, interval: interval, stop: make(chan struct{})} return &Poller{db: db, hub: hub, interval: interval, stop: make(chan struct{})}
} }
func (p *Poller) Start() { func (p *Poller) Start() {
lastID := p.db.GetMaxTransmissionID() lastID := p.db.GetMaxTransmissionID()
lastObsID := p.db.GetMaxObservationID() lastObsID := p.db.GetMaxObservationID()
log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval) log.Printf("[poller] starting from transmission ID %d, obs ID %d, interval %v", lastID, lastObsID, p.interval)
ticker := time.NewTicker(p.interval) ticker := time.NewTicker(p.interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if p.store != nil { if p.store != nil {
// Ingest new transmissions into in-memory store and broadcast // Ingest new transmissions into in-memory store and broadcast
newTxs, newMax := p.store.IngestNewFromDB(lastID, 100) newTxs, newMax := p.store.IngestNewFromDB(lastID, 100)
if newMax > lastID { if newMax > lastID {
lastID = newMax lastID = newMax
} }
// Ingest new observations for existing transmissions (fixes #174) // Ingest new observations for existing transmissions (fixes #174)
nextObsID := lastObsID newObsMax := p.store.IngestNewObservations(lastObsID, 500)
if err := p.db.conn.QueryRow(` if newObsMax > lastObsID {
SELECT COALESCE(MAX(id), ?) FROM ( lastObsID = newObsMax
SELECT id FROM observations }
WHERE id > ? if len(newTxs) > 0 {
ORDER BY id ASC log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID)
LIMIT 500 }
)`, lastObsID, lastObsID).Scan(&nextObsID); err != nil { for _, tx := range newTxs {
nextObsID = lastObsID p.hub.Broadcast(WSMessage{
} Type: "packet",
newObs := p.store.IngestNewObservations(lastObsID, 500) Data: tx,
if nextObsID > lastObsID { })
lastObsID = nextObsID }
} } else {
if len(newTxs) > 0 { // Fallback: direct DB query (used when store is nil, e.g. tests)
log.Printf("[broadcast] sending %d packets to %d clients (lastID now %d)", len(newTxs), p.hub.ClientCount(), lastID) newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100)
} if err != nil {
for _, tx := range newTxs { log.Printf("[poller] error: %v", err)
p.hub.Broadcast(WSMessage{ continue
Type: "packet", }
Data: tx, for _, tx := range newTxs {
}) id, _ := tx["id"].(int)
} if id > lastID {
for _, obs := range newObs { lastID = id
p.hub.Broadcast(WSMessage{ }
Type: "packet", // Copy packet fields for the nested packet (avoids circular ref)
Data: obs, pkt := make(map[string]interface{}, len(tx))
}) for k, v := range tx {
} pkt[k] = v
} else { }
// Fallback: direct DB query (used when store is nil, e.g. tests) tx["packet"] = pkt
newTxs, err := p.db.GetNewTransmissionsSince(lastID, 100) p.hub.Broadcast(WSMessage{
if err != nil { Type: "packet",
log.Printf("[poller] error: %v", err) Data: tx,
continue })
} }
for _, tx := range newTxs { }
id, _ := tx["id"].(int) case <-p.stop:
if id > lastID { return
lastID = id }
} }
// Copy packet fields for the nested packet (avoids circular ref) }
pkt := make(map[string]interface{}, len(tx))
for k, v := range tx { func (p *Poller) Stop() {
pkt[k] = v close(p.stop)
} }
tx["packet"] = pkt
p.hub.Broadcast(WSMessage{
Type: "packet",
Data: tx,
})
}
}
case <-p.stop:
return
}
}
}
func (p *Poller) Stop() {
close(p.stop)
}
+275 -415
View File
@@ -1,415 +1,275 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort" "testing"
"testing" "time"
"time"
"github.com/gorilla/websocket"
"github.com/gorilla/websocket" )
)
func TestHubBroadcast(t *testing.T) {
func TestHubBroadcast(t *testing.T) { hub := NewHub()
hub := NewHub()
if hub.ClientCount() != 0 {
if hub.ClientCount() != 0 { t.Errorf("expected 0 clients, got %d", hub.ClientCount())
t.Errorf("expected 0 clients, got %d", hub.ClientCount()) }
}
// Create a test server with WebSocket endpoint
// Create a test server with WebSocket endpoint srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hub.ServeWS(w, r)
hub.ServeWS(w, r) }))
})) defer srv.Close()
defer srv.Close()
// Connect a WebSocket client
// Connect a WebSocket client wsURL := "ws" + srv.URL[4:] // replace http with ws
wsURL := "ws" + srv.URL[4:] // replace http with ws conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil {
if err != nil { t.Fatalf("dial error: %v", err)
t.Fatalf("dial error: %v", err) }
} defer conn.Close()
defer conn.Close()
// Wait for registration
// Wait for registration time.Sleep(50 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
if hub.ClientCount() != 1 {
if hub.ClientCount() != 1 { t.Errorf("expected 1 client, got %d", hub.ClientCount())
t.Errorf("expected 1 client, got %d", hub.ClientCount()) }
}
// Broadcast a message
// Broadcast a message hub.Broadcast(map[string]interface{}{
hub.Broadcast(map[string]interface{}{ "type": "packet",
"type": "packet", "data": map[string]interface{}{"id": 1, "hash": "test123"},
"data": map[string]interface{}{"id": 1, "hash": "test123"}, })
})
// Read the message
// Read the message conn.SetReadDeadline(time.Now().Add(2 * time.Second))
conn.SetReadDeadline(time.Now().Add(2 * time.Second)) _, msg, err := conn.ReadMessage()
_, msg, err := conn.ReadMessage() if err != nil {
if err != nil { t.Fatalf("read error: %v", err)
t.Fatalf("read error: %v", err) }
} if len(msg) == 0 {
if len(msg) == 0 { t.Error("expected non-empty message")
t.Error("expected non-empty message") }
}
// Disconnect
// Disconnect conn.Close()
conn.Close() time.Sleep(100 * time.Millisecond)
time.Sleep(100 * time.Millisecond) }
}
func TestPollerCreation(t *testing.T) {
func TestPollerCreation(t *testing.T) { db := setupTestDB(t)
db := setupTestDB(t) defer db.Close()
defer db.Close() seedTestData(t, db)
seedTestData(t, db) hub := NewHub()
hub := NewHub()
poller := NewPoller(db, hub, 100*time.Millisecond)
poller := NewPoller(db, hub, 100*time.Millisecond) if poller == nil {
if poller == nil { t.Fatal("expected poller")
t.Fatal("expected poller") }
}
// Start and stop
// Start and stop go poller.Start()
go poller.Start() time.Sleep(200 * time.Millisecond)
time.Sleep(200 * time.Millisecond) poller.Stop()
poller.Stop() }
}
func TestHubMultipleClients(t *testing.T) {
func TestHubMultipleClients(t *testing.T) { hub := NewHub()
hub := NewHub()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hub.ServeWS(w, r)
hub.ServeWS(w, r) }))
})) defer srv.Close()
defer srv.Close()
wsURL := "ws" + srv.URL[4:]
wsURL := "ws" + srv.URL[4:]
// Connect two clients
// Connect two clients conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil {
if err != nil { t.Fatalf("dial error: %v", err)
t.Fatalf("dial error: %v", err) }
} defer conn1.Close()
defer conn1.Close()
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil {
if err != nil { t.Fatalf("dial error: %v", err)
t.Fatalf("dial error: %v", err) }
} defer conn2.Close()
defer conn2.Close()
time.Sleep(100 * time.Millisecond)
time.Sleep(100 * time.Millisecond)
if hub.ClientCount() != 2 {
if hub.ClientCount() != 2 { t.Errorf("expected 2 clients, got %d", hub.ClientCount())
t.Errorf("expected 2 clients, got %d", hub.ClientCount()) }
}
// Broadcast and both should receive
// Broadcast and both should receive hub.Broadcast(map[string]interface{}{"type": "test", "data": "hello"})
hub.Broadcast(map[string]interface{}{"type": "test", "data": "hello"})
conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
conn1.SetReadDeadline(time.Now().Add(2 * time.Second)) _, msg1, err := conn1.ReadMessage()
_, msg1, err := conn1.ReadMessage() if err != nil {
if err != nil { t.Fatalf("conn1 read error: %v", err)
t.Fatalf("conn1 read error: %v", err) }
} if len(msg1) == 0 {
if len(msg1) == 0 { t.Error("expected non-empty message on conn1")
t.Error("expected non-empty message on conn1") }
}
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) _, msg2, err := conn2.ReadMessage()
_, msg2, err := conn2.ReadMessage() if err != nil {
if err != nil { t.Fatalf("conn2 read error: %v", err)
t.Fatalf("conn2 read error: %v", err) }
} if len(msg2) == 0 {
if len(msg2) == 0 { t.Error("expected non-empty message on conn2")
t.Error("expected non-empty message on conn2") }
}
// Disconnect one
// Disconnect one conn1.Close()
conn1.Close() time.Sleep(100 * time.Millisecond)
time.Sleep(100 * time.Millisecond)
// Remaining client should still work
// Remaining client should still work hub.Broadcast(map[string]interface{}{"type": "test2"})
hub.Broadcast(map[string]interface{}{"type": "test2"})
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
conn2.SetReadDeadline(time.Now().Add(2 * time.Second)) _, msg3, err := conn2.ReadMessage()
_, msg3, err := conn2.ReadMessage() if err != nil {
if err != nil { t.Fatalf("conn2 read error after disconnect: %v", err)
t.Fatalf("conn2 read error after disconnect: %v", err) }
} if len(msg3) == 0 {
if len(msg3) == 0 { t.Error("expected non-empty message")
t.Error("expected non-empty message") }
} }
}
func TestBroadcastFullBuffer(t *testing.T) {
func TestBroadcastFullBuffer(t *testing.T) { hub := NewHub()
hub := NewHub()
// Create a client with tiny buffer (1)
// Create a client with tiny buffer (1) client := &Client{
client := &Client{ send: make(chan []byte, 1),
send: make(chan []byte, 1), }
} hub.mu.Lock()
hub.mu.Lock() hub.clients[client] = true
hub.clients[client] = true hub.mu.Unlock()
hub.mu.Unlock()
// Fill the buffer
// Fill the buffer client.send <- []byte("first")
client.send <- []byte("first")
// This broadcast should drop the message (buffer full)
// This broadcast should drop the message (buffer full) hub.Broadcast(map[string]interface{}{"type": "dropped"})
hub.Broadcast(map[string]interface{}{"type": "dropped"})
// Channel should still only have the first message
// Channel should still only have the first message select {
select { case msg := <-client.send:
case msg := <-client.send: if string(msg) != "first" {
if string(msg) != "first" { t.Errorf("expected 'first', got %s", string(msg))
t.Errorf("expected 'first', got %s", string(msg)) }
} default:
default: t.Error("expected message in channel")
t.Error("expected message in channel") }
}
// Clean up
// Clean up hub.mu.Lock()
hub.mu.Lock() delete(hub.clients, client)
delete(hub.clients, client) hub.mu.Unlock()
hub.mu.Unlock() }
}
func TestBroadcastMarshalError(t *testing.T) {
func TestBroadcastMarshalError(t *testing.T) { hub := NewHub()
hub := NewHub()
// Marshal error: functions can't be marshaled to JSON
// Marshal error: functions can't be marshaled to JSON hub.Broadcast(map[string]interface{}{"bad": func() {}})
hub.Broadcast(map[string]interface{}{"bad": func() {}}) // Should not panic — just log and return
// Should not panic — just log and return }
}
func TestPollerBroadcastsNewData(t *testing.T) {
func TestPollerBroadcastsNewData(t *testing.T) { db := setupTestDB(t)
db := setupTestDB(t) defer db.Close()
defer db.Close() seedTestData(t, db)
seedTestData(t, db) hub := NewHub()
hub := NewHub()
// Create a client to receive broadcasts
// Create a client to receive broadcasts client := &Client{
client := &Client{ send: make(chan []byte, 256),
send: make(chan []byte, 256), }
} hub.mu.Lock()
hub.mu.Lock() hub.clients[client] = true
hub.clients[client] = true hub.mu.Unlock()
hub.mu.Unlock()
poller := NewPoller(db, hub, 50*time.Millisecond)
poller := NewPoller(db, hub, 50*time.Millisecond) go poller.Start()
go poller.Start()
// Insert new data to trigger broadcast
// Insert new data to trigger broadcast db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type)
db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type) VALUES ('EEFF', 'newhash123456789', '2026-01-16T10:00:00Z', 1, 4)`)
VALUES ('EEFF', 'newhash123456789', '2026-01-16T10:00:00Z', 1, 4)`)
time.Sleep(200 * time.Millisecond)
time.Sleep(200 * time.Millisecond) poller.Stop()
poller.Stop()
// Check if client received broadcast with packet field (fixes #162)
// Check if client received broadcast with packet field (fixes #162) select {
select { case msg := <-client.send:
case msg := <-client.send: if len(msg) == 0 {
if len(msg) == 0 { t.Error("expected non-empty broadcast message")
t.Error("expected non-empty broadcast message") }
} var parsed map[string]interface{}
var parsed map[string]interface{} if err := json.Unmarshal(msg, &parsed); err != nil {
if err := json.Unmarshal(msg, &parsed); err != nil { t.Fatalf("failed to parse broadcast: %v", err)
t.Fatalf("failed to parse broadcast: %v", err) }
} if parsed["type"] != "packet" {
if parsed["type"] != "packet" { t.Errorf("expected type=packet, got %v", parsed["type"])
t.Errorf("expected type=packet, got %v", parsed["type"]) }
} data, ok := parsed["data"].(map[string]interface{})
data, ok := parsed["data"].(map[string]interface{}) if !ok {
if !ok { t.Fatal("expected data to be an object")
t.Fatal("expected data to be an object") }
} // packets.js filters on m.data.packet — must exist
// packets.js filters on m.data.packet — must exist pkt, ok := data["packet"]
pkt, ok := data["packet"] if !ok || pkt == nil {
if !ok || pkt == nil { t.Error("expected data.packet to exist (required by packets.js WS handler)")
t.Error("expected data.packet to exist (required by packets.js WS handler)") }
} pktMap, ok := pkt.(map[string]interface{})
pktMap, ok := pkt.(map[string]interface{}) if !ok {
if !ok { t.Fatal("expected data.packet to be an object")
t.Fatal("expected data.packet to be an object") }
} // Verify key fields exist in nested packet (timestamp required by packets.js)
// Verify key fields exist in nested packet (timestamp required by packets.js) for _, field := range []string{"id", "hash", "payload_type", "timestamp"} {
for _, field := range []string{"id", "hash", "payload_type", "timestamp"} { if _, exists := pktMap[field]; !exists {
if _, exists := pktMap[field]; !exists { t.Errorf("expected data.packet.%s to exist", field)
t.Errorf("expected data.packet.%s to exist", field) }
} }
} default:
default: // Might not have received due to timing
// Might not have received due to timing }
}
// Clean up
// Clean up hub.mu.Lock()
hub.mu.Lock() delete(hub.clients, client)
delete(hub.clients, client) hub.mu.Unlock()
hub.mu.Unlock() }
}
func TestHubRegisterUnregister(t *testing.T) {
func TestPollerBroadcastsMultipleObservations(t *testing.T) { hub := NewHub()
db := setupTestDB(t)
defer db.Close() client := &Client{
seedTestData(t, db) send: make(chan []byte, 256),
hub := NewHub() }
client := &Client{ hub.Register(client)
send: make(chan []byte, 256), if hub.ClientCount() != 1 {
} t.Errorf("expected 1 client after register, got %d", hub.ClientCount())
hub.mu.Lock() }
hub.clients[client] = true
hub.mu.Unlock() hub.Unregister(client)
defer func() { if hub.ClientCount() != 0 {
hub.mu.Lock() t.Errorf("expected 0 clients after unregister, got %d", hub.ClientCount())
delete(hub.clients, client) }
hub.mu.Unlock()
}() // Unregister again should be safe
hub.Unregister(client)
poller := NewPoller(db, hub, 50*time.Millisecond) if hub.ClientCount() != 0 {
store := NewPacketStore(db) t.Errorf("expected 0 clients, got %d", hub.ClientCount())
if err := store.Load(); err != nil { }
t.Fatalf("store load failed: %v", err) }
}
poller.store = store
go poller.Start()
defer poller.Stop()
// Wait for poller to initialize its lastID/lastObsID cursors before
// inserting new data; otherwise the poller may snapshot a lastID that
// already includes the test data and never broadcast it.
time.Sleep(100 * time.Millisecond)
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.conn.Exec(`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, decoded_json)
VALUES ('FACE', 'starbursthash237a', ?, 1, 4, '{"pubKey":"aabbccdd11223344","type":"ADVERT"}')`, now); err != nil {
t.Fatalf("insert tx failed: %v", err)
}
var txID int
if err := db.conn.QueryRow(`SELECT id FROM transmissions WHERE hash='starbursthash237a'`).Scan(&txID); err != nil {
t.Fatalf("query tx id failed: %v", err)
}
ts := time.Now().Unix()
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (?, 1, 14.0, -82, '["aa"]', ?),
(?, 2, 10.5, -90, '["aa","bb"]', ?),
(?, 1, 7.0, -96, '["aa","bb","cc"]', ?)`,
txID, ts, txID, ts+1, txID, ts+2); err != nil {
t.Fatalf("insert observations failed: %v", err)
}
deadline := time.After(2 * time.Second)
var dataMsgs []map[string]interface{}
for len(dataMsgs) < 3 {
select {
case raw := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal ws msg failed: %v", err)
}
if parsed["type"] != "packet" {
continue
}
data, ok := parsed["data"].(map[string]interface{})
if !ok {
continue
}
if data["hash"] == "starbursthash237a" {
dataMsgs = append(dataMsgs, data)
}
case <-deadline:
t.Fatalf("timed out waiting for 3 observation broadcasts, got %d", len(dataMsgs))
}
}
if len(dataMsgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(dataMsgs))
}
paths := make([]string, 0, 3)
observers := make(map[string]bool)
for _, m := range dataMsgs {
hash, _ := m["hash"].(string)
if hash != "starbursthash237a" {
t.Fatalf("unexpected hash %q", hash)
}
p, _ := m["path_json"].(string)
paths = append(paths, p)
if oid, ok := m["observer_id"].(string); ok && oid != "" {
observers[oid] = true
}
}
sort.Strings(paths)
wantPaths := []string{`["aa","bb","cc"]`, `["aa","bb"]`, `["aa"]`}
sort.Strings(wantPaths)
for i := range wantPaths {
if paths[i] != wantPaths[i] {
t.Fatalf("path mismatch at %d: got %q want %q", i, paths[i], wantPaths[i])
}
}
if len(observers) < 2 {
t.Fatalf("expected observations from >=2 observers, got %d", len(observers))
}
}
func TestIngestNewObservationsBroadcast(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
seedTestData(t, db)
store := NewPacketStore(db)
if err := store.Load(); err != nil {
t.Fatalf("store load failed: %v", err)
}
maxObs := db.GetMaxObservationID()
now := time.Now().Unix()
if _, err := db.conn.Exec(`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp)
VALUES (1, 2, 6.0, -100, '["aa","zz"]', ?),
(1, 1, 5.0, -101, '["aa","yy"]', ?)`, now, now+1); err != nil {
t.Fatalf("insert new observations failed: %v", err)
}
maps := store.IngestNewObservations(maxObs, 500)
if len(maps) != 2 {
t.Fatalf("expected 2 broadcast maps, got %d", len(maps))
}
for _, m := range maps {
if m["hash"] != "abc123def4567890" {
t.Fatalf("unexpected hash in map: %v", m["hash"])
}
path, ok := m["path_json"].(string)
if !ok || path == "" {
t.Fatalf("missing path_json in map: %#v", m)
}
if _, ok := m["observer_id"]; !ok {
t.Fatalf("missing observer_id in map: %#v", m)
}
}
}
func TestHubRegisterUnregister(t *testing.T) {
hub := NewHub()
client := &Client{
send: make(chan []byte, 256),
}
hub.Register(client)
if hub.ClientCount() != 1 {
t.Errorf("expected 1 client after register, got %d", hub.ClientCount())
}
hub.Unregister(client)
if hub.ClientCount() != 0 {
t.Errorf("expected 0 clients after unregister, got %d", hub.ClientCount())
}
// Unregister again should be safe
hub.Unregister(client)
if hub.ClientCount() != 0 {
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
}
}
+23 -33
View File
@@ -2,8 +2,8 @@
* MeshCore Packet Decoder * MeshCore Packet Decoder
* Custom implementation does NOT use meshcore-decoder library (known path_length bug). * Custom implementation does NOT use meshcore-decoder library (known path_length bug).
* *
* Packet layout (per firmware docs/packet_format.md): * Packet layout:
* [header(1)] [transportCodes?(4)] [pathLength(1)] [path hops] [payload...] * [header(1)] [pathLength(1)] [transportCodes?] [path hops] [payload...]
* *
* Header byte (LSB first): * Header byte (LSB first):
* bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT) * bits 1-0: routeType (0=TRANSPORT_FLOOD, 1=FLOOD, 2=DIRECT, 3=TRANSPORT_DIRECT)
@@ -42,7 +42,7 @@ const PAYLOAD_TYPES = {
0x0F: 'RAW_CUSTOM', 0x0F: 'RAW_CUSTOM',
}; };
// Route types that carry transport codes (2x uint16_t, 4 bytes total) // Route types that carry transport codes (nextHop + lastHop, 2 bytes each)
const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT const TRANSPORT_ROUTES = new Set([0, 3]); // TRANSPORT_FLOOD, TRANSPORT_DIRECT
// --- Header parsing --- // --- Header parsing ---
@@ -94,11 +94,13 @@ function decodeEncryptedPayload(buf) {
}; };
} }
/** ACK: checksum(4) — CRC of message timestamp + text + sender pubkey (per Mesh.cpp createAck) */ /** ACK: dest(1) + src(1) + ack_hash(4) (per Mesh.cpp) */
function decodeAck(buf) { function decodeAck(buf) {
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') }; if (buf.length < 6) return { error: 'too short', raw: buf.toString('hex') };
return { return {
ackChecksum: buf.subarray(0, 4).toString('hex'), destHash: buf.subarray(0, 1).toString('hex'),
srcHash: buf.subarray(1, 2).toString('hex'),
extraHash: buf.subarray(2, 6).toString('hex'),
}; };
} }
@@ -123,8 +125,6 @@ function decodeAdvert(buf) {
room: advType === 3, room: advType === 3,
sensor: advType === 4, sensor: advType === 4,
hasLocation: !!(flags & 0x10), hasLocation: !!(flags & 0x10),
hasFeat1: !!(flags & 0x20),
hasFeat2: !!(flags & 0x40),
hasName: !!(flags & 0x80), hasName: !!(flags & 0x80),
}; };
@@ -134,14 +134,6 @@ function decodeAdvert(buf) {
result.lon = appdata.readInt32LE(off + 4) / 1e6; result.lon = appdata.readInt32LE(off + 4) / 1e6;
off += 8; off += 8;
} }
if (result.flags.hasFeat1 && appdata.length >= off + 2) {
result.feat1 = appdata.readUInt16LE(off);
off += 2;
}
if (result.flags.hasFeat2 && appdata.length >= off + 2) {
result.feat2 = appdata.readUInt16LE(off);
off += 2;
}
if (result.flags.hasName) { if (result.flags.hasName) {
// Find null terminator to separate name from trailing telemetry bytes // Find null terminator to separate name from trailing telemetry bytes
let nameEnd = appdata.length; let nameEnd = appdata.length;
@@ -239,7 +231,7 @@ function decodeGrpTxt(buf, channelKeys) {
return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData }; return { type: 'GRP_TXT', channelHash, channelHashHex, decryptionStatus: 'no_key', mac, encryptedData };
} }
/** ANON_REQ: dest(1) + ephemeral_pubkey(32) + MAC(2) + encrypted */ /** ANON_REQ: dest(6) + ephemeral_pubkey(32) + MAC(4) + encrypted */
function decodeAnonReq(buf) { function decodeAnonReq(buf) {
if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') }; if (buf.length < 35) return { error: 'too short', raw: buf.toString('hex') };
return { return {
@@ -250,7 +242,7 @@ function decodeAnonReq(buf) {
}; };
} }
/** PATH: dest(1) + src(1) + MAC(2) + path_data */ /** PATH: dest(6) + src(6) + MAC(4) + path_data */
function decodePath_payload(buf) { function decodePath_payload(buf) {
if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') }; if (buf.length < 4) return { error: 'too short', raw: buf.toString('hex') };
return { return {
@@ -261,14 +253,14 @@ function decodePath_payload(buf) {
}; };
} }
/** TRACE: tag(4) + authCode(4) + flags(1) + pathData (per Mesh.cpp onRecvPacket TRACE) */ /** TRACE: flags(1) + tag(4) + dest(6) + src(1) */
function decodeTrace(buf) { function decodeTrace(buf) {
if (buf.length < 9) return { error: 'too short', raw: buf.toString('hex') }; if (buf.length < 12) return { error: 'too short', raw: buf.toString('hex') };
return { return {
tag: buf.readUInt32LE(0), flags: buf[0],
authCode: buf.subarray(4, 8).toString('hex'), tag: buf.readUInt32LE(1),
flags: buf[8], destHash: buf.subarray(5, 11).toString('hex'),
pathData: buf.subarray(9).toString('hex'), srcHash: buf.subarray(11, 12).toString('hex'),
}; };
} }
@@ -297,22 +289,20 @@ function decodePacket(hexString, channelKeys) {
if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)'); if (buf.length < 2) throw new Error('Packet too short (need at least header + pathLength)');
const header = decodeHeader(buf[0]); const header = decodeHeader(buf[0]);
let offset = 1; const pathByte = buf[1];
let offset = 2;
// Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT — BEFORE path_length per spec // Transport codes for TRANSPORT_FLOOD / TRANSPORT_DIRECT
let transportCodes = null; let transportCodes = null;
if (TRANSPORT_ROUTES.has(header.routeType)) { if (TRANSPORT_ROUTES.has(header.routeType)) {
if (buf.length < offset + 4) throw new Error('Packet too short for transport codes'); if (buf.length < offset + 4) throw new Error('Packet too short for transport codes');
transportCodes = { transportCodes = {
code1: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(), nextHop: buf.subarray(offset, offset + 2).toString('hex').toUpperCase(),
code2: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(), lastHop: buf.subarray(offset + 2, offset + 4).toString('hex').toUpperCase(),
}; };
offset += 4; offset += 4;
} }
// Path length byte — AFTER transport codes per spec
const pathByte = buf[offset++];
// Path // Path
const path = decodePath(pathByte, buf, offset); const path = decodePath(pathByte, buf, offset);
offset += path.bytesConsumed; offset += path.bytesConsumed;
@@ -396,7 +386,7 @@ module.exports = { decodePacket, validateAdvert, hasNonPrintableChars, ROUTE_TYP
// --- Tests --- // --- Tests ---
if (require.main === module) { if (require.main === module) {
console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Kpa Roof Solar" ==='); console.log('=== Test 1: ADVERT, FLOOD, 5 hops (2-byte hashes), "Test Repeater" ===');
const pkt1 = decodePacket( const pkt1 = decodePacket(
'11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172' '11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172'
); );
@@ -412,7 +402,7 @@ if (require.main === module) {
assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000'); assert(pkt1.path.hops[0] === '1000', 'first hop should be 1000');
assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818'); assert(pkt1.path.hops[1] === 'D818', 'second hop should be D818');
assert(pkt1.transportCodes === null, 'FLOOD has no transport codes'); assert(pkt1.transportCodes === null, 'FLOOD has no transport codes');
assert(pkt1.payload.name === 'Kpa Roof Solar', 'name should be "Kpa Roof Solar"'); assert(pkt1.payload.name === 'Test Repeater', 'name should be "Test Repeater"');
console.log('✅ Test 1 passed\n'); console.log('✅ Test 1 passed\n');
console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ==='); console.log('=== Test 2: ADVERT, FLOOD, 0 hops (zero-path) ===');
+27 -27
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope"> <meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis."> <meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png"> <meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774786038"> <link rel="stylesheet" href="style.css?v=1774731523">
<link rel="stylesheet" href="home.css?v=1774786038"> <link rel="stylesheet" href="home.css?v=1774731523">
<link rel="stylesheet" href="live.css?v=1774786038"> <link rel="stylesheet" href="live.css?v=1774731523">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous"> crossorigin="anonymous">
@@ -81,29 +81,29 @@
<main id="app" role="main"></main> <main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script> <script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774786038"></script> <script src="roles.js?v=1774731523"></script>
<script src="customize.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="customize.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774786038"></script> <script src="region-filter.js?v=1774731523"></script>
<script src="hop-resolver.js?v=1774786038"></script> <script src="hop-resolver.js?v=1774731523"></script>
<script src="hop-display.js?v=1774786038"></script> <script src="hop-display.js?v=1774731523"></script>
<script src="app.js?v=1774786038"></script> <script src="app.js?v=1774731523"></script>
<script src="home.js?v=1774786038"></script> <script src="home.js?v=1774731523"></script>
<script src="packet-filter.js?v=1774786038"></script> <script src="packet-filter.js?v=1774731523"></script>
<script src="packets.js?v=1774786038"></script> <script src="packets.js?v=1774731523"></script>
<script src="map.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="map.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="channels.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="nodes.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="traces.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="audio.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="audio-v1-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="audio-v2-constellation.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="audio-lab.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="live.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="observers.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="observer-detail.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="compare.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="node-analytics.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774786038" onerror="console.error('Failed to load:', this.src)"></script> <script src="perf.js?v=1774731523" onerror="console.error('Failed to load:', this.src)"></script>
</body> </body>
</html> </html>
+7 -5
View File
@@ -1512,12 +1512,14 @@
rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', ''); rows += fieldRow(off + 1, 'Sender', decoded.sender || '—', '');
if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, ''); if (decoded.sender_timestamp) rows += fieldRow(off + 2, 'Sender Time', decoded.sender_timestamp, '');
} else if (decoded.type === 'ACK') { } else if (decoded.type === 'ACK') {
rows += fieldRow(off, 'Checksum (4B)', decoded.ackChecksum || '', ''); rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
rows += fieldRow(off + 12, 'Extra (6B)', decoded.extraHash || '', '');
} else if (decoded.destHash !== undefined) { } else if (decoded.destHash !== undefined) {
rows += fieldRow(off, 'Dest Hash (1B)', decoded.destHash || '', ''); rows += fieldRow(off, 'Dest Hash (6B)', decoded.destHash || '', '');
rows += fieldRow(off + 1, 'Src Hash (1B)', decoded.srcHash || '', ''); rows += fieldRow(off + 6, 'Src Hash (6B)', decoded.srcHash || '', '');
rows += fieldRow(off + 2, 'MAC (2B)', decoded.mac || '', ''); rows += fieldRow(off + 12, 'MAC (4B)', decoded.mac || '', '');
rows += fieldRow(off + 4, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), ''); rows += fieldRow(off + 16, 'Encrypted Data', truncate(decoded.encryptedData || '', 30), '');
} else { } else {
rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), ''); rows += fieldRow(off, 'Raw', truncate(buf.slice(off * 2), 40), '');
} }
+6 -6
View File
@@ -40,12 +40,12 @@
html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;"> html += `<h3>🔧 Go Runtime</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div> <div class="perf-card"><div class="perf-num">${gr.goroutines}</div><div class="perf-label">Goroutines</div></div>
<div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div> <div class="perf-card"><div class="perf-num">${gr.numGC}</div><div class="perf-label">GC Collections</div></div>
<div class="perf-card"><div class="perf-num" style="color:${gcColor}">${(+gr.pauseTotalMs).toFixed(1)}ms</div><div class="perf-label">GC Pause Total</div></div> <div class="perf-card"><div class="perf-num" style="color:${gcColor}">${gr.pauseTotalMs}ms</div><div class="perf-label">GC Pause Total</div></div>
<div class="perf-card"><div class="perf-num">${(+gr.lastPauseMs).toFixed(1)}ms</div><div class="perf-label">Last GC Pause</div></div> <div class="perf-card"><div class="perf-num">${gr.lastPauseMs}ms</div><div class="perf-label">Last GC Pause</div></div>
<div class="perf-card"><div class="perf-num">${(+gr.heapAllocMB).toFixed(1)}MB</div><div class="perf-label">Heap Alloc</div></div> <div class="perf-card"><div class="perf-num">${gr.heapAllocMB}MB</div><div class="perf-label">Heap Alloc</div></div>
<div class="perf-card"><div class="perf-num">${(+gr.heapSysMB).toFixed(1)}MB</div><div class="perf-label">Heap Sys</div></div> <div class="perf-card"><div class="perf-num">${gr.heapSysMB}MB</div><div class="perf-label">Heap Sys</div></div>
<div class="perf-card"><div class="perf-num">${(+gr.heapInuseMB).toFixed(1)}MB</div><div class="perf-label">Heap Inuse</div></div> <div class="perf-card"><div class="perf-num">${gr.heapInuseMB}MB</div><div class="perf-label">Heap Inuse</div></div>
<div class="perf-card"><div class="perf-num">${(+gr.heapIdleMB).toFixed(1)}MB</div><div class="perf-label">Heap Idle</div></div> <div class="perf-card"><div class="perf-num">${gr.heapIdleMB}MB</div><div class="perf-label">Heap Idle</div></div>
<div class="perf-card"><div class="perf-num">${gr.numCPU}</div><div class="perf-label">CPUs</div></div> <div class="perf-card"><div class="perf-num">${gr.numCPU}</div><div class="perf-label">CPUs</div></div>
<div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div> <div class="perf-card"><div class="perf-num">${health.websocket.clients}</div><div class="perf-label">WS Clients</div></div>
</div>`; </div>`;
+1 -1
View File
@@ -155,7 +155,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
/* === Nav Stats === */ /* === Nav Stats === */
.nav-stats { .nav-stats {
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted); display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
font-family: var(--mono); margin-right: 4px; white-space: nowrap; font-family: var(--mono); margin-right: 4px;
} }
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; } .nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
.nav-stats .stat-val.updated { color: var(--accent); } .nav-stats .stat-val.updated { color: var(--accent); }
+3 -10
View File
@@ -207,13 +207,6 @@ class TTLCache {
if (key.startsWith(prefix)) this.store.delete(key); if (key.startsWith(prefix)) this.store.delete(key);
} }
} }
debouncedInvalidateBulkHealth() {
if (this._bulkHealthTimer) return;
this._bulkHealthTimer = setTimeout(() => {
this._bulkHealthTimer = null;
this.invalidate('bulk-health');
}, 30000);
}
debouncedInvalidateAll() { debouncedInvalidateAll() {
if (this._debounceTimer) return; if (this._debounceTimer) return;
this._debounceTimer = setTimeout(() => { this._debounceTimer = setTimeout(() => {
@@ -417,7 +410,7 @@ app.get('/api/perf', (req, res) => {
avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0, avgMs: perfStats.requests ? Math.round(perfStats.totalMs / perfStats.requests * 10) / 10 : 0,
endpoints: Object.fromEntries(sorted), endpoints: Object.fromEntries(sorted),
slowQueries: perfStats.slowQueries.slice(-20), slowQueries: perfStats.slowQueries.slice(-20),
cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0 }, cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 },
packetStore: pktStore.getStats(), packetStore: pktStore.getStats(),
sqlite: (() => { sqlite: (() => {
try { try {
@@ -526,7 +519,7 @@ app.get('/api/health', (req, res) => {
misses: cache.misses, misses: cache.misses,
staleHits: cache.staleHits, staleHits: cache.staleHits,
recomputes: cache.recomputes, recomputes: cache.recomputes,
hitRate: cache.hits + cache.staleHits + cache.misses > 0 ? Math.round((cache.hits + cache.staleHits) / (cache.hits + cache.staleHits + cache.misses) * 1000) / 10 : 0, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0,
}, },
websocket: { websocket: {
clients: wsClients, clients: wsClients,
@@ -730,7 +723,7 @@ for (const source of mqttSources) {
// Invalidate this node's caches on advert // Invalidate this node's caches on advert
cache.invalidate('node:' + p.pubKey); cache.invalidate('node:' + p.pubKey);
cache.invalidate('health:' + p.pubKey); cache.invalidate('health:' + p.pubKey);
cache.debouncedInvalidateBulkHealth(); cache.invalidate('bulk-health');
// Cross-reference: if this node's pubkey matches an existing observer, backfill observer name // Cross-reference: if this node's pubkey matches an existing observer, backfill observer name
if (p.name && p.pubKey) { if (p.name && p.pubKey) {
+10 -11
View File
@@ -122,14 +122,13 @@ console.log('── Spec Tests: Transport Codes ──');
{ {
// Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes // Route type 0 (TRANSPORT_FLOOD) and 3 (TRANSPORT_DIRECT) should have 4-byte transport codes
// Route type 0: header=0x14 = payloadType 5 (GRP_TXT), routeType 0 (TRANSPORT_FLOOD) // Route type 0: header byte = 0bPPPPPP00, e.g. 0x14 = payloadType 5 (GRP_TXT), routeType 0
// Format: header(1) + transportCodes(4) + pathByte(1) + payload const hex = '1400' + 'AABB' + 'CCDD' + '1A' + '00'.repeat(10); // transport codes + GRP_TXT payload
const hex = '14' + 'AABB' + 'CCDD' + '00' + '1A' + '00'.repeat(10); // transport codes + pathByte + GRP_TXT payload
const p = decodePacket(hex); const p = decodePacket(hex);
assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)'); assertEq(p.header.routeType, 0, 'transport: routeType=0 (TRANSPORT_FLOOD)');
assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD'); assert(p.transportCodes !== null, 'transport: transportCodes present for TRANSPORT_FLOOD');
assertEq(p.transportCodes.code1, 'AABB', 'transport: code1'); assertEq(p.transportCodes.nextHop, 'AABB', 'transport: nextHop');
assertEq(p.transportCodes.code2, 'CCDD', 'transport: code2'); assertEq(p.transportCodes.lastHop, 'CCDD', 'transport: lastHop');
} }
{ {
@@ -258,13 +257,13 @@ console.log('── Spec Tests: Advert Payload ──');
console.log('── Spec Tests: Encrypted Payload Format ──'); console.log('── Spec Tests: Encrypted Payload Format ──');
// Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher — decoder matches this. // NOTE: Spec says v1 encrypted payloads have dest(1) + src(1) + MAC(2) + ciphertext
// But decoder reads dest(6) + src(6) + MAC(4) + ciphertext
// This is a known discrepancy — the decoder matches production behavior, not the spec.
// The spec may describe the firmware's internal addressing while the OTA format differs,
// or the decoder may be parsing the fields differently. Production data validates the decoder.
{ {
const hex = '0100' + 'AA' + 'BB' + 'CCDD' + '00'.repeat(10); note('Spec says v1 encrypted payloads: dest(1)+src(1)+MAC(2)+cipher, but decoder reads dest(6)+src(6)+MAC(4)+cipher — decoder matches prod data');
const p = decodePacket(hex);
assertEq(p.payload.destHash, 'aa', 'encrypted payload: dest is 1 byte');
assertEq(p.payload.srcHash, 'bb', 'encrypted payload: src is 1 byte');
assertEq(p.payload.mac, 'ccdd', 'encrypted payload: MAC is 2 bytes');
} }
console.log('── Spec Tests: validateAdvert ──'); console.log('── Spec Tests: validateAdvert ──');
+16 -16
View File
@@ -28,22 +28,22 @@ test('FLOOD + ADVERT = 0x11', () => {
}); });
test('TRANSPORT_FLOOD = routeType 0', () => { test('TRANSPORT_FLOOD = routeType 0', () => {
// header=0x00 (TRANSPORT_FLOOD + REQ), transportCodes=AABB+CCDD, pathByte=0x00, payload // 0x00 = TRANSPORT_FLOOD + REQ(0), needs transport codes + 16 byte payload
const hex = '00' + 'AABB' + 'CCDD' + '00' + '00'.repeat(16); const hex = '0000' + 'AABB' + 'CCDD' + '00'.repeat(16);
const p = decodePacket(hex); const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 0); assert.strictEqual(p.header.routeType, 0);
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD'); assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_FLOOD');
assert.notStrictEqual(p.transportCodes, null); assert.notStrictEqual(p.transportCodes, null);
assert.strictEqual(p.transportCodes.code1, 'AABB'); assert.strictEqual(p.transportCodes.nextHop, 'AABB');
assert.strictEqual(p.transportCodes.code2, 'CCDD'); assert.strictEqual(p.transportCodes.lastHop, 'CCDD');
}); });
test('TRANSPORT_DIRECT = routeType 3', () => { test('TRANSPORT_DIRECT = routeType 3', () => {
const hex = '03' + '1122' + '3344' + '00' + '00'.repeat(16); const hex = '0300' + '1122' + '3344' + '00'.repeat(16);
const p = decodePacket(hex); const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 3); assert.strictEqual(p.header.routeType, 3);
assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT'); assert.strictEqual(p.header.routeTypeName, 'TRANSPORT_DIRECT');
assert.strictEqual(p.transportCodes.code1, '1122'); assert.strictEqual(p.transportCodes.nextHop, '1122');
}); });
test('DIRECT = routeType 2, no transport codes', () => { test('DIRECT = routeType 2, no transport codes', () => {
@@ -358,7 +358,9 @@ test('ACK decode', () => {
const hex = '0D00' + '00'.repeat(18); const hex = '0D00' + '00'.repeat(18);
const p = decodePacket(hex); const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'ACK'); assert.strictEqual(p.payload.type, 'ACK');
assert(p.payload.ackChecksum); assert(p.payload.destHash);
assert(p.payload.srcHash);
assert(p.payload.extraHash);
}); });
test('ACK too short', () => { test('ACK too short', () => {
@@ -422,9 +424,9 @@ test('TRACE decode', () => {
const hex = '2500' + '00'.repeat(12); const hex = '2500' + '00'.repeat(12);
const p = decodePacket(hex); const p = decodePacket(hex);
assert.strictEqual(p.payload.type, 'TRACE'); assert.strictEqual(p.payload.type, 'TRACE');
assert(p.payload.tag !== undefined);
assert(p.payload.authCode !== undefined);
assert.strictEqual(p.payload.flags, 0); assert.strictEqual(p.payload.flags, 0);
assert(p.payload.tag !== undefined);
assert(p.payload.destHash);
}); });
test('TRACE too short', () => { test('TRACE too short', () => {
@@ -458,18 +460,16 @@ test('Transport route too short throws', () => {
assert.throws(() => decodePacket('0000'), /too short for transport/); assert.throws(() => decodePacket('0000'), /too short for transport/);
}); });
test('Corrupt packet #183 — TRANSPORT_DIRECT with correct field order', () => { test('Corrupt packet #183 — path overflow capped to buffer', () => {
const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D'; const hex = 'BBAD6797EC8751D500BF95A1A776EF580E665BCBF6A0BBE03B5E730707C53489B8C728FD3FB902397197E1263CEC21E52465362243685DBBAD6797EC8751C90A75D9FD8213155D';
const p = decodePacket(hex); const p = decodePacket(hex);
assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT'); assert.strictEqual(p.header.routeType, 3, 'routeType should be TRANSPORT_DIRECT');
assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN'); assert.strictEqual(p.header.payloadTypeName, 'UNKNOWN');
// transport codes are bytes 1-4, pathByte=0x87 at byte 5 // pathByte 0xAD claims 45 hops × 3 bytes = 135, but only 65 bytes available
assert.strictEqual(p.transportCodes.code1, 'AD67');
assert.strictEqual(p.transportCodes.code2, '97EC');
// pathByte 0x87: hashSize=3, hashCount=7
assert.strictEqual(p.path.hashSize, 3); assert.strictEqual(p.path.hashSize, 3);
assert.strictEqual(p.path.hashCount, 7); assert.strictEqual(p.path.hashCount, 21, 'hashCount capped to fit buffer');
assert.strictEqual(p.path.hops.length, 7); assert.strictEqual(p.path.hops.length, 21);
assert.strictEqual(p.path.truncated, true);
// No empty strings in hops // No empty strings in hops
assert(p.path.hops.every(h => h.length > 0), 'no empty hops'); assert(p.path.hops.every(h => h.length > 0), 'no empty hops');
}); });
-18
View File
@@ -1254,24 +1254,6 @@ seedTestData();
lastPathSeenMap.delete(liveNode); lastPathSeenMap.delete(liveNode);
}); });
// ── Cache hit rate includes stale hits ──
await t('Cache hitRate includes staleHits in formula', async () => {
cache.clear();
cache.hits = 0;
cache.misses = 0;
cache.staleHits = 0;
// Simulate: 3 hits, 2 stale hits, 5 misses => rate = (3+2)/(3+2+5) = 50%
cache.hits = 3;
cache.staleHits = 2;
cache.misses = 5;
const r = await request(app).get('/api/health').expect(200);
assert(r.body.cache.hitRate === 50, 'hitRate should be (hits+staleHits)/(hits+staleHits+misses) = 50%, got ' + r.body.cache.hitRate);
// Reset
cache.hits = 0;
cache.misses = 0;
cache.staleHits = 0;
});
// ── Summary ── // ── Summary ──
console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`); console.log(`\n═══ Server Route Tests: ${passed} passed, ${failed} failed ═══`);
if (failed > 0) process.exit(1); if (failed > 0) process.exit(1);