From bf4844c01090844783779e13d7f16e8f9d96777e Mon Sep 17 00:00:00 2001 From: Smokey <150986568+jacisjake@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:13:04 -0600 Subject: [PATCH] =?UTF-8?q?test(watchdog):=20red=20for=20#45=20=E2=80=94?= =?UTF-8?q?=20quarantine=20must=20escalate=20to=20ask=5Fowner;=20reinstate?= =?UTF-8?q?/retire=20resolve=20fail-safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- escalate_test.go | 328 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 escalate_test.go diff --git a/escalate_test.go b/escalate_test.go new file mode 100644 index 0000000..b7afaf8 --- /dev/null +++ b/escalate_test.go @@ -0,0 +1,328 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +// Quarantine escalation (#45): a watchdog flag opens an ask_owner; the owner's +// exact "reinstate " lifts the quarantine, the exact "retire " +// removes the crow from the registry or the nest, anything else — wrong +// phrase, other replies, timeout — keeps it. Either exact phrase bumps +// Sentry|triage/watch once, at most once per ask. + +func findAsk(ts *httptest.Server, id, flagID string) map[string]any { + for _, m := range nestMessagesNoT(ts, id) { + if m["kind"] == "ask_owner" && m["refers_to"] == flagID { + return m + } + } + return nil +} + +func waitForAsk(ts *httptest.Server, id, flagID string) map[string]any { + deadline := time.Now().Add(2 * time.Second) + for { + if a := findAsk(ts, id, flagID); a != nil { + return a + } + if time.Now().After(deadline) { + return nil + } + time.Sleep(10 * time.Millisecond) + } +} + +// breachToAsk drives the standard E3+E4 scenario: an unsourced-fact deliver +// draws a Sentry flag and its escalation ask. Returns nest id, request and ask +// records. +func breachToAsk(t *testing.T, ts *httptest.Server) (string, map[string]any, map[string]any) { + t.Helper() + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, from last spring's ledger") + f := waitForFlag(ts, id, d) + if f == nil { + t.Fatalf("setup: the breach must draw a flag, log: %+v", nestMessagesNoT(ts, id)) + } + ask := waitForAsk(ts, id, f["msg_id"].(string)) + if ask == nil { + t.Fatalf("setup: the flag must open an ask_owner, log: %+v", nestMessagesNoT(ts, id)) + } + return id, req, ask +} + +func registryNames(t *testing.T, ts *httptest.Server) []string { + t.Helper() + resp, err := http.Get(ts.URL + "/api/crows") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Crows []struct { + Name string `json:"name"` + } `json:"crows"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + var names []string + for _, c := range out.Crows { + names = append(names, c.Name) + } + return names +} + +func nestCrowList(t *testing.T, ts *httptest.Server, nestID string) []string { + t.Helper() + resp, err := http.Get(ts.URL + "/api/nests") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Nests []struct { + NestID string `json:"nest_id"` + Crows []string `json:"crows"` + } `json:"nests"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + for _, n := range out.Nests { + if n.NestID == nestID { + return n.Crows + } + } + t.Fatalf("nest %s not listed", nestID) + return nil +} + +// watchRecord reloads the persisted ledger and returns the Sentry's watch +// record: nil when it has never moved. +func watchRecord(t *testing.T, s *Server) *Record { + t.Helper() + l, err := LoadLedger(s.p.Ledger.path) + if err != nil { + t.Fatal(err) + } + return l.Crows["Sentry|triage/watch"] +} + +func TestQuarantineOpensAskOwner(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + d := postDeliver(t, ts, id, "Hugin", req["msg_id"].(string), "twelve eggs, from last spring's ledger") + f := waitForFlag(ts, id, d) + if f == nil { + t.Fatalf("setup: the breach must draw a flag, log: %+v", s.msgs.All(id)) + } + + ask := waitForAsk(ts, id, f["msg_id"].(string)) + if ask == nil { + t.Fatalf("a quarantine must escalate: an ask_owner referring the flag must exist, log: %+v", s.msgs.All(id)) + } + if ask["author"] != "Sentry" || ask["to"] != "owner" { + t.Fatalf("the ask comes from the Sentry to the owner, got %v", ask) + } + if ask["approval_phrase"] != "reinstate Hugin" { + t.Fatalf("the ask must carry the exact reinstate phrase, got %v", ask["approval_phrase"]) + } + if r := ask["resolved"]; r != "" { + t.Fatalf("a fresh ask reads open, got resolved=%v", r) + } + txt, _ := ask["text"].(string) + for _, want := range []string{"reinstate Hugin", "retire Hugin", "Evidence:"} { + if !strings.Contains(txt, want) { + t.Fatalf("the ask text must offer %q, got %q", want, txt) + } + } +} + +func TestReinstateOnlyOnExactYes(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id, req, ask := breachToAsk(t, ts) + askID := ask["msg_id"].(string) + claim := `{"author":"Hugin","kind":"claim","refers_to":"` + req["msg_id"].(string) + `","text":"I will take the next one"}` + + code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"let him back"}`) + if code != http.StatusCreated { + t.Fatalf("owner reply: %d %v", code, out) + } + if code, out = postMsg(t, ts, id, claim); code != http.StatusForbidden { + t.Fatalf("anything but the exact phrase is not a yes: claim must stay 403, got %d %v", code, out) + } + if !s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("a wrong-phrase reply must not lift the quarantine") + } + + code, out = postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`) + if code != http.StatusCreated { + t.Fatalf("owner reinstate: %d %v", code, out) + } + if s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("the exact reinstate phrase must lift the quarantine") + } + if r := askResolved(t, ts, id, askID); r != "approved" { + t.Fatalf("the exact phrase must resolve the ask approved, got %v", r) + } + if code, out = postMsg(t, ts, id, claim); code != http.StatusCreated { + t.Fatalf("a reinstated crow's claim must get 201, got %d %v", code, out) + } +} + +func TestAskOwnerTimeoutKeepsQuarantine(t *testing.T) { + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id, req, ask := breachToAsk(t, ts) + askID := ask["msg_id"].(string) + + s.askTimeout = time.Millisecond + time.Sleep(25 * time.Millisecond) + + if r := askResolved(t, ts, id, askID); r != "rejected" { + t.Fatalf("an ask unanswered past the window reads rejected, got %v", r) + } + if !s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("a timeout must not lift the quarantine") + } + code, out := postMsg(t, ts, id, + `{"author":"Hugin","kind":"claim","refers_to":"`+req["msg_id"].(string)+`","text":"I will take the next one"}`) + if code != http.StatusForbidden { + t.Fatalf("a timed-out ask keeps the crow silenced: claim must get 403, got %d %v", code, out) + } + if f := findFlag(ts, id, ask["refers_to"].(string)); f == nil { + t.Fatal("the flag must still be in the log") + } + if rec := watchRecord(t, s); rec != nil { + t.Fatalf("a timeout moves no trust (spec section 10), got %+v", rec) + } +} + +func TestRetireRemovesCrow(t *testing.T) { + // (a) a spun crow in the registry is deleted from it. + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + cs, err := LoadCrowStore(filepath.Join(t.TempDir(), "crows.json")) + if err != nil { + t.Fatal(err) + } + s.WithCrows(cs) + if err := cs.Add(SpunCrow{Name: "Ash", Persona: "Hugin", Trust: "stranger"}); err != nil { + t.Fatal(err) + } + id := newNest(t, ts, "Lab", `"Hugin","Munin"`) + _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + // Ash is not a nest member, so its breach is logged harness-style and + // watched directly (same shape as the one-flag-per-expectation case). + ash, err := s.msgs.Append(Message{NestID: id, Kind: "deliver", Author: "Ash", To: "owner", + Text: "five eggs, sourced nowhere", RefersTo: req["msg_id"].(string), Timestamp: time.Now().UTC().Format(time.RFC3339)}) + if err != nil { + t.Fatal(err) + } + s.checkWatchdog(nestOf(s, id), ash) + f := findFlag(ts, id, ash.MsgID()) + if f == nil { + t.Fatalf("setup: Ash must be flagged, log: %+v", s.msgs.All(id)) + } + ask := waitForAsk(ts, id, f["msg_id"].(string)) + if ask == nil || ask["approval_phrase"] != "reinstate Ash" { + t.Fatalf("setup: the flag must ask about Ash, got %v", ask) + } + if names := registryNames(t, ts); !slices.Contains(names, "Ash") { + t.Fatalf("setup: Ash must be in the registry, got %v", names) + } + code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+ask["msg_id"].(string)+`","text":"retire Ash"}`) + if code != http.StatusCreated { + t.Fatalf("owner retire: %d %v", code, out) + } + if names := registryNames(t, ts); slices.Contains(names, "Ash") { + t.Fatalf("the exact retire phrase must delete the spun crow, registry: %v", names) + } + if r := askResolved(t, ts, id, ask["msg_id"].(string)); r != "approved" { + t.Fatalf("retire reads as the owner's explicit yes, got resolved=%v", r) + } + + // (b) a persona crow is removed from the nest's membership, never killed. + id2 := newNest(t, ts, "Coop", `"Hugin","Munin"`) + _, req2 := post(t, ts.URL+"/api/nests/"+id2+"/messages", + `{"author":"owner","kind":"request","text":"count the eggs"}`) + d2 := postDeliver(t, ts, id2, "Hugin", req2["msg_id"].(string), "twelve eggs, from last spring's ledger") + f2 := waitForFlag(ts, id2, d2) + if f2 == nil { + t.Fatalf("setup: Hugin must be flagged in Coop, log: %+v", s.msgs.All(id2)) + } + ask2 := waitForAsk(ts, id2, f2["msg_id"].(string)) + if ask2 == nil { + t.Fatalf("setup: the Coop flag must open an ask, log: %+v", s.msgs.All(id2)) + } + if crows := nestCrowList(t, ts, id2); !slices.Contains(crows, "Hugin") { + t.Fatalf("setup: Hugin must be in Coop, got %v", crows) + } + code, out = postMsg(t, ts, id2, `{"author":"owner","kind":"say","refers_to":"`+ask2["msg_id"].(string)+`","text":"retire Hugin"}`) + if code != http.StatusCreated { + t.Fatalf("owner retire: %d %v", code, out) + } + if crows := nestCrowList(t, ts, id2); slices.Contains(crows, "Hugin") { + t.Fatalf("the exact retire phrase must remove the persona crow from the nest, got %v", crows) + } + if !s.p.Ledger.IsQuarantined("Hugin") { + t.Fatal("retire is not a lift: the quarantine mark stays on the books") + } + code, out = postMsg(t, ts, id2, + `{"author":"Hugin","kind":"claim","refers_to":"`+req2["msg_id"].(string)+`","text":"still here"}`) + if code != http.StatusForbidden { + t.Fatalf("a retired crow must read 403, not 400, got %d %v", code, out) + } + if names := registryNames(t, ts); slices.Contains(names, "Hugin") { + t.Fatalf("a persona crow is never deleted from the registry, got %v", names) + } +} + +func TestWatchTrustMoves(t *testing.T) { + // a reinstate approval bumps Sentry|triage/watch once, idempotently. + ts, s := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id, _, ask := breachToAsk(t, ts) + askID := ask["msg_id"].(string) + + if rec := watchRecord(t, s); rec != nil { + t.Fatalf("the flag alone must not move trust, got %+v", rec) + } + code, out := postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`) + if code != http.StatusCreated { + t.Fatalf("owner reinstate: %d %v", code, out) + } + rec := watchRecord(t, s) + if rec == nil || rec.Approved != 1 || rec.Streak != 1 { + t.Fatalf("the owner's explicit yes is an approval for the Sentry's watch record, got %+v", rec) + } + + // the same reply again must not bump twice (one decision per ask). + code, out = postMsg(t, ts, id, `{"author":"owner","kind":"say","refers_to":"`+askID+`","text":"reinstate Hugin"}`) + if code != http.StatusCreated { + t.Fatalf("second reinstate: %d %v", code, out) + } + if rec := watchRecord(t, s); rec.Approved != 1 || rec.Streak != 1 { + t.Fatalf("the bump must fire at most once per ask, got %+v", rec) + } + + // a wrong-phrase reply leaves it untouched. + ts2, s2 := newWatchServer(t, watchScores(0.9, 0.05, 0.05), nil) + id2, _, ask2 := breachToAsk(t, ts2) + code, out = postMsg(t, ts2, id2, `{"author":"owner","kind":"say","refers_to":"`+ask2["msg_id"].(string)+`","text":"sounds good"}`) + if code != http.StatusCreated { + t.Fatalf("owner reply: %d %v", code, out) + } + if rec := watchRecord(t, s2); rec != nil { + t.Fatalf("anything but the exact phrase moves nothing, got %+v", rec) + } +}