feat(watchdog): quarantine escalates to ask_owner; reinstate/retire resolve fail-safe (#45) #53
@@ -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 <crow>" lifts the quarantine, the exact "retire <crow>"
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user