48e9c39d7b
Refs #1 - /v1/diff endpoint returns each node's fingerprint set + tombstoned auto-thread status - knox gossip diff <peer-url>: shows peer-only/local-only fingerprints (pull/keep preview) and tombstone divergence (resolved-on-peer vs would-resurrect) - knox gossip sync: one-shot anti-entropy sweep + reconcile - gossip server starts before initial seed so peers can reach a booting node; KNOX_PEER_ADDR alone now serves without KNOX_PEERS - integration tests for diff + tombstone reporting - e2e verified: two live daemons, diff previewed 1655 peer-only fps, sync converged second node to 1655 observations
174 lines
4.2 KiB
Go
174 lines
4.2 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, []string{sb.URL})
|
|
Run(b, []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])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGossipDiff ensures /v1/diff reports per-node observation fingerprints and
|
|
// tombstoned thread divergence.
|
|
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, []string{sa.URL})
|
|
before, err := b.EntryCount()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
Run(b, []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)
|
|
}
|
|
} |