Files

336 lines
12 KiB
Go

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.Store(int64(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)
}
flagID := ask["refers_to"].(string)
flagSeen := false
for _, m := range nestMessages(t, ts, id) {
if m["kind"] == "flag" && m["msg_id"] == flagID {
flagSeen = true
}
}
if !flagSeen {
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)
}
}