329 lines
12 KiB
Go
329 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 = 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)
|
|
}
|
|
}
|