package watch import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/david/knox/internal/db" ) func tmpKnoxDB(t *testing.T) *db.KnoxDB { t.Helper() kdb, err := db.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { kdb.Close() }) return kdb } func seedObs(k *db.KnoxDB, prefix string) { // Insert distinct observations only via RecordObservation so they get this // node's node_id and HCL. for i := 0; i < 3; i++ { _, _, err := k.RecordObservation(db.ObservationRecord{ Fingerprint: prefix + ":" + string(rune('a'+i)), SourceID: "test", SourcePath: prefix, Project: "itest", ContentType: "test", Title: prefix + string(rune('a'+i)), Summary: "summary " + prefix + string(rune('a'+i)), CreatedAt: "2026-08-29T00:00:00Z", LineEnd: 0, Confidence: 0.9, IngesterVersion: "itest/v1", }) if err != nil { panic(err) } } } // TestGossipConvergence starts two nodes, seeds disjoint observations, and runs // a bidirectional handshake. Both must end with the concatenation of both logs. func TestGossipConvergence(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") seedObs(b, "BBB") // Wire a and b as peer HTTP servers. nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() nodeB := &Node{Kdb: b, Name: "B"} sb := httptest.NewServer(nodeB.Handler()) defer sb.Close() // A pulls from B, then B pulls from A (bidirectional sweep). Run(a, nil, []string{sb.URL}) Run(b, nil, []string{sa.URL}) av, err := a.KnowledgeVector() if err != nil { t.Fatalf("a vector: %v", err) } bv, err := b.KnowledgeVector() if err != nil { t.Fatalf("b vector: %v", err) } if len(av) != 2 || len(bv) != 2 { t.Fatalf("expected both nodes to hold 2 source logs, got a=%v b=%v", av, bv) } // Same maximum HCL per origin node on both sides. for nid, hcl := range av { if bv[nid] != hcl { t.Errorf("node %s max hcl mismatch: a=%d b=%d", nid, hcl, bv[nid]) } } } // TestGossipSwarmDiscovery: C only knows A. A knows B. When C sweeps A, it must // learn about B through A's ping, enqueue B, and pull B's observations — with no // direct configuration of B (no relay of data, only membership). func TestGossipSwarmDiscovery(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) c := tmpKnoxDB(t) seedObs(a, "AAA") seedObs(b, "BBB") seedObs(c, "CCC") nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() nodeB := &Node{Kdb: b, Name: "B"} sb := httptest.NewServer(nodeB.Handler()) defer sb.Close() nodeC := &Node{Kdb: c, Name: "C"} sc := httptest.NewServer(nodeC.Handler()) defer sc.Close() // A discovers B (A pings B) so A can advertise B to the swarm. Run(a, nil, []string{sb.URL}) // C only knows A. A single sweep should surface B (membership in ping) // and pull B's observations directly. Run(c, nil, []string{sa.URL}) // C must know B and hold all three origin logs. peers, err := c.ListPeers() if err != nil { t.Fatalf("list peers: %v", err) } foundB := false for _, p := range peers { if p.PeerID == b.NodeID() { foundB = true } } if !foundB { t.Fatalf("C did not discover B via A's membership list; peers=%v", peers) } vec, err := c.KnowledgeVector() if err != nil { t.Fatalf("c vector: %v", err) } if len(vec) != 3 { t.Errorf("C should hold 3 origin logs (A, B, C), got %v", vec) } // C's copy of B's log must match B's own max hcl. bmax := c.MaxHCLForNode(b.NodeID()) bm, err := bNodeMax(b) if err != nil { t.Fatal(err) } if bmax != bm { t.Errorf("C max hcl for B=%d, B reports %d", bmax, bm) } } // bNodeMax is a helper reading the max hcl B holds for its own node. func bNodeMax(b *db.KnoxDB) (int64, error) { rows, err := b.KnowledgeVector() if err != nil { return 0, err } return rows[b.NodeID()], nil } func TestGossipDiff(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") seedObs(b, "BBB") nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() c := &Client{Addr: sa.URL} d, err := c.Diff() if err != nil { t.Fatalf("diff: %v", err) } if d.NodeID != a.NodeID() { t.Errorf("diff node mismatch: %s != %s", d.NodeID, a.NodeID()) } if len(d.Fingerprints) != 3 { t.Errorf("expected 3 fingerprints in diff, got %d", len(d.Fingerprints)) } } // TestGossipDiffTombstones confirms resolved auto-threads surface in diff. func TestGossipDiffTombstones(t *testing.T) { a := tmpKnoxDB(t) id, _, err := a.CreateThreadCluster("Test thread", "motivation", "medium", "[]", `{"trigger":"auto_thread"}`, "cluster:testkey") if err != nil { t.Fatalf("create thread: %v", err) } if err := a.CloseThread(id); err != nil { t.Fatalf("close thread: %v", err) } nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() c := &Client{Addr: sa.URL} d, err := c.Diff() if err != nil { t.Fatalf("diff: %v", err) } if len(d.ThreadStatus) != 1 { t.Errorf("expected 1 thread in diff, got %d", len(d.ThreadStatus)) } for k, st := range d.ThreadStatus { if k != "cluster:testkey" { t.Errorf("unexpected thread key %s", k) } if st != "resolved" { t.Errorf("thread %s should be resolved, got %q", k, st) } } } // TestGossipIdempotent ensures a repeated sweep does not duplicate observations. func TestGossipIdempotent(t *testing.T) { a := tmpKnoxDB(t) b := tmpKnoxDB(t) seedObs(a, "AAA") nodeA := &Node{Kdb: a, Name: "A"} sa := httptest.NewServer(nodeA.Handler()) defer sa.Close() nodeB := &Node{Kdb: b, Name: "B"} sb := httptest.NewServer(nodeB.Handler()) defer sb.Close() Run(b, nil, []string{sa.URL}) entryBefore, err := b.EntryCount() if err != nil { t.Fatal(err) } stats, err := b.Stats() if err != nil { t.Fatal(err) } obsBefore, _ := stats["total_observations"].(int) Run(b, nil, []string{sa.URL}) entryAfter, err := b.EntryCount() if err != nil { t.Fatal(err) } if entryBefore != entryAfter { t.Errorf("second sweep changed entry count: %d -> %d", entryBefore, entryAfter) } stats, err = b.Stats() if err != nil { t.Fatal(err) } obsAfter, _ := stats["total_observations"].(int) if obsBefore != obsAfter { t.Errorf("second sweep duplicated observations: %d -> %d", obsBefore, obsAfter) } } // TestHandleBatchRejectsOversizedBatch: more than maxBatchRows in one POST // must be refused up front, before any insert work. func TestHandleBatchRejectsOversizedBatch(t *testing.T) { b := tmpKnoxDB(t) node := &Node{Kdb: b, Name: "B"} sv := httptest.NewServer(node.Handler()) defer sv.Close() rows := make([]db.GossipObservation, maxBatchRows+1) for i := range rows { rows[i] = db.GossipObservation{ NodeID: "0123456789abcdef0123456789abcdef", HCL: int64(i + 1), Fingerprint: "fp:oversized", SourceID: "test", Title: "t", Summary: "s", CollectedAt: "2026-08-29T00:00:00Z", } } body, _ := json.Marshal(rows) resp, err := http.Post(sv.URL+"/v1/obs/batch", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("oversized batch: want 400, got %d", resp.StatusCode) } } // TestHandleBatchRejectsHugeBody: an oversized body (beyond the 4 MiB cap) // must be refused even when the row count is small. func TestHandleBatchRejectsHugeBody(t *testing.T) { b := tmpKnoxDB(t) node := &Node{Kdb: b, Name: "B"} sv := httptest.NewServer(node.Handler()) defer sv.Close() rows := []db.GossipObservation{{ NodeID: "0123456789abcdef0123456789abcdef", HCL: 1, Fingerprint: "fp:huge", SourceID: "test", Title: "t", Summary: strings.Repeat("x", 5<<20), // 5 MiB summary CollectedAt: "2026-08-29T00:00:00Z", }} body, _ := json.Marshal(rows) resp, err := http.Post(sv.URL+"/v1/obs/batch", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("huge body: want 400, got %d", resp.StatusCode) } } // TestHandleBatchSkipsSelfRows: rows claiming the receiver's own node_id are // dropped at the HTTP layer too (the poisoning vector), reported as conflicts. func TestHandleBatchSkipsSelfRows(t *testing.T) { b := tmpKnoxDB(t) node := &Node{Kdb: b, Name: "B"} sv := httptest.NewServer(node.Handler()) defer sv.Close() rows := []db.GossipObservation{ {NodeID: b.NodeID(), HCL: 1, Fingerprint: "fp:self1", SourceID: "test", Title: "t", Summary: "s", CollectedAt: "2026-08-29T00:00:00Z"}, {NodeID: b.NodeID(), HCL: 2, Fingerprint: "fp:self2", SourceID: "test", Title: "t", Summary: "s", CollectedAt: "2026-08-29T00:00:00Z"}, } body, _ := json.Marshal(rows) resp, err := http.Post(sv.URL+"/v1/obs/batch", "application/json", bytes.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("want 200, got %d", resp.StatusCode) } var out struct { Accepted int `json:"accepted"` Conflict int `json:"conflict"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } if out.Accepted != 0 || out.Conflict != 2 { t.Errorf("self rows: want accepted=0 conflict=2, got accepted=%d conflict=%d", out.Accepted, out.Conflict) } }