Files
knox/internal/watch/gossip_test.go
T
2026-09-17 01:49:12 -07:00

244 lines
6.0 KiB
Go

package watch
import (
"net/http/httptest"
"path/filepath"
"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})
before, err := b.EntryCount()
if err != nil {
t.Fatal(err)
}
Run(b, nil, []string{sa.URL})
after, err := b.EntryCount()
if err != nil {
t.Fatal(err)
}
if before != after {
t.Errorf("second sweep changed entry count: %d -> %d", before, after)
}
}