bb852faa27
Implements the top findings from the codebase review, verified with tests and live CLI/MCP checks. **Gossip integrity** - Push validation: 4 MiB body cap, 1000-row batch cap; rows claiming the local node id (vector-poisoning), empty node ids, and negative HCLs rejected (internal/watch/gossip.go, internal/db/gossip.go) - Reconcile-on-pull: Run returns the pulled count, syncGossip rebuilds derived state when > 0 — entry-count comparison could never fire, so synced observations never materialized into searchable entries **Data-layer safety** - HLC resumed from MAX(hcl) at Open (hlc.SeekTo): a restart with a regressed wall clock cannot reissue values the (node_id, hcl) locator and pull cursors depend on - Writer serialization: _txlock=immediate DSN + SetMaxOpenConns(1) + per-KnoxDB mutex around RecordObservation's check-then-insert dedup (closes duplicate-row race) **Watch daemon** - Ticker guard flags now atomic.Bool (was a cross-goroutine data race) - Trailing-edge per-path debounce (timer-based, pruned on fire/delete) - Recursive watches (startup tree walk + watcher.Add on dir Create); Rename re-ingests, Remove cancels pending ingests **MCP + CLI** - Strict arg validation, no silent clamping: thread_id 0 errors instead of renaming thread #1; empty knox_thread_link {} errors instead of false success; thread existence checked before writes; golden-thread tool nil-safe - --page 0 errors instead of panicking; query/recent pagination actually pages (page x limit) **Tests** (new internal/hlc and internal/db packages): SeekTo monotonicity, concurrent dedup race, push validation, reopen HCL monotonicity, batch caps, self-spoof rejection, idempotency on observation counts. Verified: go build, go vet, full suite with -race, live MCP stdio transcripts against a scratch DB. Reviewed-on: #3 Co-authored-by: David Gwilliam <dhgwilliam@gmail.com> Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
348 lines
9.3 KiB
Go
348 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|