package web import ( "bytes" "encoding/json" "net/http/httptest" "strings" "testing" "time" "pete/internal/storage" ) // W5: the action queue's web seam. Two contracts, same shape as the equip queue's // tests — the owner half must be unable to act for anybody but itself, and the // gogobee half is a bearer-authed, idempotent pending/verdict pair. // seedActions stands up a board and a private detail row owned by `owner`, which // together are gogobee's proof that this account has an adventurer. `status` is // the roster status the mark carries ("expedition" or "idle"), because the // extract pre-check reads it. func seedActions(t *testing.T, owner, status string) *Server { t.Helper() s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} now := time.Now().Unix() e := entry("tok-josie", "Josie", status, "holymachina") if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 { t.Fatalf("seed roster = %d", w.Code) } if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{ Localpart: owner, Token: "tok-josie", }}}); w.Code != 200 { t.Fatalf("seed detail = %d", w.Code) } return s } func placeAction(t *testing.T, s *Server, username, action string) *httptest.ResponseRecorder { t.Helper() r := as(t, s, username, "POST", "/api/adventure/order", advOrderReq{Action: action}) w := httptest.NewRecorder() s.handleAdvOrder(w, r) return w } // TestActionOrderNamesNoCharacter is the reason this seam has a smaller attack // surface than the equip queue's: nothing in the request identifies an // adventurer, so there is no id to forge. The order that lands must be attributed // to the session's own localpart and its own token, whatever the body said. func TestActionOrderNamesNoCharacter(t *testing.T) { s := seedActions(t, "holymachina", "expedition") // A body carrying extra fields — a token, a localpart — must change nothing: // the handler reads only Action off it. r := as(t, s, "holymachina", "POST", "/api/adventure/order", map[string]any{ "action": "extract", "token": "tok-somebody-else", "owner_localpart": "someone", }) w := httptest.NewRecorder() s.handleAdvOrder(w, r) if w.Code != 200 { t.Fatalf("order = %d (%s)", w.Code, w.Body.String()) } var got storage.AdvOrder if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if got.OwnerLocalpart != "holymachina" { t.Fatalf("owner = %q, want the session's localpart", got.OwnerLocalpart) } if got.Token != "tok-josie" { t.Fatalf("token = %q, want the token resolved from the session, not the body", got.Token) } if got.Status != storage.AdvOrderPending { t.Fatalf("status = %q, want pending — Pete never claims an action landed", got.Status) } } // TestActionOrderNeedsAnAdventurer: a signed-in visitor with no self-detail row // has no adventurer for gogobee to act on. Queuing the order anyway would file // something gogobee can only answer with a rejection. func TestActionOrderNeedsAnAdventurer(t *testing.T) { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} if w := placeAction(t, s, "stranger", "extract"); w.Code != 403 { t.Fatalf("order without an adventurer = %d, want 403", w.Code) } } // TestOnlyOneOutstandingOrderPerVerb. Two queued extracts apply in sequence and // the second answers "you weren't on an expedition" — a rejection for something // that worked, which is the worst thing the strip could say. The guard is per // verb, so a pending extract must not block a Siege bout. func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) { s := seedActions(t, "holymachina", "expedition") postSiege(t, s, "tok", liveSiege(time.Now().Unix(), 800)) if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 { t.Fatalf("first extract = %d (%s)", w.Code, w.Body.String()) } w := placeAction(t, s, "holymachina", "extract") if w.Code != 409 { t.Fatalf("second extract = %d, want 409", w.Code) } if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 { t.Fatalf("bout blocked by a pending extract = %d (%s); the guard is per verb", w.Code, w.Body.String()) } } // TestActionPreChecksAreCourtesyOnly pins both halves of a deliberate asymmetry. // Pete refuses what its own snapshot says is impossible — but the snapshot is up // to two minutes old, so the refusal must be cheap and local (a 409 the button // shows immediately), never a queued order gogobee has to answer. func TestActionPreChecksAreCourtesyOnly(t *testing.T) { // Idle mark: extract refused up front. s := seedActions(t, "holymachina", "idle") if w := placeAction(t, s, "holymachina", "extract"); w.Code != 409 { t.Fatalf("extract while idle = %d, want 409", w.Code) } // No Siege pushed at all: unknown, not "inactive". Pete has never heard from // gogobee about a boss, and refusing on that would make the button dead on a // fresh deploy. It must go through and let gogobee answer. if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 { t.Fatalf("bout with no siege snapshot at all = %d, want it queued", w.Code) } // A snapshot that positively says no boss is camped: refuse. s2 := seedActions(t, "holymachina", "idle") now := time.Now().Unix() postSiege(t, s2, "tok", siegePush{SnapshotAt: now, Siege: storage.Siege{Active: false}}) if w := placeAction(t, s2, "holymachina", "siege_join"); w.Code != 409 { t.Fatalf("bout with no boss camped = %d, want 409", w.Code) } } func TestActionOrderRejectsAnUnknownVerb(t *testing.T) { s := seedActions(t, "holymachina", "expedition") if w := placeAction(t, s, "holymachina", "sell_house"); w.Code != 400 { t.Fatalf("unknown action = %d, want 400", w.Code) } } // TestActionOrdersAreScopedToTheirOwner: the strip is read back by OIDC subject. // `as` signs every session as sub-1, so this drives the storage layer directly to // prove the scoping rather than pretending two sessions exist. func TestActionOrdersAreScopedToTheirOwner(t *testing.T) { s := seedActions(t, "holymachina", "expedition") if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 { t.Fatalf("place = %d", w.Code) } if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract); err != nil { t.Fatalf("insert other: %v", err) } r := as(t, s, "holymachina", "GET", "/api/adventure/orders", nil) w := httptest.NewRecorder() s.handleAdvOrders(w, r) var got []storage.AdvOrder if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if len(got) != 1 || got[0].OwnerLocalpart != "holymachina" { t.Fatalf("orders = %+v, want only the signed-in owner's", got) } } // TestActionVerdictIsIdempotent: gogobee's poll loop retries, so the same verdict // arrives more than once and only the first may move the order. A second verdict // overwriting the first would let a re-offer's "no expedition to leave" replace // the "done" that was true. func TestActionVerdictIsIdempotent(t *testing.T) { s := seedActions(t, "holymachina", "expedition") w := placeAction(t, s, "holymachina", "extract") var order storage.AdvOrder _ = json.Unmarshal(w.Body.Bytes(), &order) first := postVerdict(t, s, "tok", advOrderVerdict{ GUID: order.GUID, Status: storage.AdvOrderApplied, Detail: "Out on day 3.", }) if first.Code != 200 { t.Fatalf("verdict = %d (%s)", first.Code, first.Body.String()) } second := postVerdict(t, s, "tok", advOrderVerdict{ GUID: order.GUID, Status: storage.AdvRejectedNotRunning, Detail: "no run", }) if second.Code != 200 { t.Fatalf("retried verdict = %d, want a quiet 200", second.Code) } got, err := storage.AdvOrderByGUID(order.GUID) if err != nil { t.Fatalf("read back: %v", err) } if got.Status != storage.AdvOrderApplied || !strings.Contains(got.Detail, "day 3") { t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail) } } // TestActionWireNeedsTheBearerToken: the poll and the verdict are gogobee's, and // the pending list names every player who has asked for something. func TestActionWireNeedsTheBearerToken(t *testing.T) { s := seedActions(t, "holymachina", "expedition") placeAction(t, s, "holymachina", "extract") req := httptest.NewRequest("GET", "/api/adventure/orders/pending", nil) w := httptest.NewRecorder() s.handleAdvOrdersPending(w, req) if w.Code != 401 { t.Fatalf("unauthed poll = %d, want 401", w.Code) } req = httptest.NewRequest("GET", "/api/adventure/orders/pending", nil) req.Header.Set("Authorization", "Bearer tok") w = httptest.NewRecorder() s.handleAdvOrdersPending(w, req) if w.Code != 200 { t.Fatalf("authed poll = %d", w.Code) } var pending []storage.AdvOrder if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil { t.Fatalf("decode: %v", err) } if len(pending) != 1 || pending[0].Action != storage.AdvActionExtract { t.Fatalf("pending = %+v, want the one queued extract", pending) } } // TestVerdictForAnUnknownOrderIs400: under this seam's contract that parks the // row for a human rather than retrying forever against a row that can never // exist. func TestVerdictForAnUnknownOrderIs400(t *testing.T) { s := seedActions(t, "holymachina", "expedition") if w := postVerdict(t, s, "tok", advOrderVerdict{GUID: "nope", Status: storage.AdvOrderApplied}); w.Code != 400 { t.Fatalf("verdict for an unknown guid = %d, want 400", w.Code) } } func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(v) req := httptest.NewRequest("POST", "/api/adventure/orders/verdict", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) w := httptest.NewRecorder() s.handleAdvOrderVerdict(w, req) return w }