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()) } } // TestOnlyTownWideFactsArePreChecked. Pete's copy of the board is up to two // minutes behind the game box, so what it may refuse locally turns on whether // being two minutes late could make the answer wrong. A personal status can: // somebody who set out over Matrix still reads as idle here, and refusing their // extract would deny a run gogobee would have ended. A boss camped outside town // cannot: that is town-wide and runs on a day-or-longer clock. func TestOnlyTownWideFactsArePreChecked(t *testing.T) { // Idle mark: extract goes through anyway, and rejected_not_running is the // answer if the mark really was standing in town. s := seedActions(t, "holymachina", "idle") if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 { t.Fatalf("extract while idle = %d, want it queued (%s)", w.Code, w.Body.String()) } // 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, nil); 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 } // ── W5b: the three verbs that take arguments ───────────────────────────────── // seedOffers is seedActions with an offer list on the private detail row — which // is what gogobee pushes, and what every W5b param is resolved against. func seedOffers(t *testing.T, owner, status string, pd storage.PlayerDetail) *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, owner) if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 { t.Fatalf("seed roster = %d", w.Code) } pd.Localpart = owner pd.Token = "tok-josie" if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{pd}}); w.Code != 200 { t.Fatalf("seed detail = %d", w.Code) } return s } func offeredZones() []storage.ZoneOffer { return []storage.ZoneOffer{{ ID: "goblin_warrens", Display: "Goblin Warrens", Tier: 1, Loadouts: []storage.LoadoutOffer{ {Key: "lean", Name: "lean", Cost: 40, Days: 3}, {Key: "balanced", Name: "balanced", Cost: 80, Days: 5}, }, }} } func placeParams(t *testing.T, s *Server, username string, req advOrderReq) *httptest.ResponseRecorder { t.Helper() r := as(t, s, username, "POST", "/api/adventure/order", req) w := httptest.NewRecorder() s.handleAdvOrder(w, r) return w } // The whole point of resolving params against the owner's own offer list: a // forged zone, or a loadout that zone does not sell, must never reach an order // row. gogobee would refuse them anyway — this is the cheap answer, thirty // seconds earlier, and it keeps the queue clean. func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) { s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()}) if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionExpedition, Zone: "dragons_lair", Loadout: "lean", }); w.Code != 409 { t.Fatalf("forged zone = %d, want 409 (%s)", w.Code, w.Body.String()) } if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "enormous", }); w.Code != 409 { t.Fatalf("forged loadout = %d, want 409 (%s)", w.Code, w.Body.String()) } w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "balanced", }) if w.Code != 200 { t.Fatalf("offered zone = %d, want 200 (%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.Params == nil || got.Params.Zone != "goblin_warrens" || got.Params.Loadout != "balanced" { t.Fatalf("params = %+v, want the resolved zone and loadout", got.Params) } // And they survive the round trip to gogobee's poll, which is the only reason // they are stored at all. pending, err := storage.PendingAdvOrders(10) if err != nil { t.Fatalf("pending: %v", err) } if len(pending) != 1 || pending[0].Params == nil || pending[0].Params.Zone != "goblin_warrens" { t.Fatalf("pending params lost in the round trip: %+v", pending) } } // An empty zone list is a refusal, and the message says only what Pete saw. It // usually means the adventurer is already out — gogobee omits the offers // entirely while they are down there — but a game box too old to push offers // sends the same empty list, so the copy claims nothing about which. Refusing // cheaply here beats a verdict thirty seconds later saying the same thing. func TestNoZoneOffersMeansNothingOnOffer(t *testing.T) { s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{}) w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean", }) if w.Code != 409 { t.Fatalf("departure with no offers = %d, want 409 (%s)", w.Code, w.Body.String()) } } // The sitter sells two durations and nothing else, and is not sold twice. func TestBabysitParamsAreTheTwoDurationsOnly(t *testing.T) { s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{ Babysit: &storage.BabysitOffer{WeekCost: 700, MonthCost: 3000}, }) for _, days := range []int{0, 3, 365} { if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionBabysit, Days: days, }); w.Code != 409 { t.Fatalf("%d-day sitter = %d, want 409", days, w.Code) } } if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionBabysit, Days: 30, }); w.Code != 200 { t.Fatalf("month = %d, want 200 (%s)", w.Code, w.Body.String()) } // Already engaged: the page should not be offering this at all, but a stale // tab can still post it. s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{ Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 3000}, }) if w := placeParams(t, s2, "holymachina", advOrderReq{ Action: storage.AdvActionBabysit, Days: 7, }); w.Code != 409 { t.Fatalf("second sitter = %d, want 409", w.Code) } } // Resume is refused when the snapshot positively says there is nothing waiting, // and accepted with a loadout the offer actually lists. func TestResumeParamsNeedAnOfferedLoadout(t *testing.T) { s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{}) if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionResume, Loadout: "lean", }); w.Code != 409 { t.Fatalf("resume with nothing waiting = %d, want 409", w.Code) } s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{ Resume: &storage.ResumeOffer{ZoneID: "goblin_warrens", Display: "Goblin Warrens", Day: 3, Loadouts: []storage.LoadoutOffer{{Key: "lean", Name: "lean", Cost: 40, Days: 3}}}, }) if w := placeParams(t, s2, "holymachina", advOrderReq{ Action: storage.AdvActionResume, Loadout: "heavy", }); w.Code != 409 { t.Fatalf("unoffered loadout = %d, want 409", w.Code) } if w := placeParams(t, s2, "holymachina", advOrderReq{ Action: storage.AdvActionResume, Loadout: "lean", }); w.Code != 200 { t.Fatalf("offered loadout = %d, want 200 (%s)", w.Code, w.Body.String()) } } // The offer list is the whole gate, so it is worth pinning that an empty one is // a refusal rather than a pass-through: gogobee omits the zones while the // adventurer is out, and a pass-through there would queue a departure that is // certain to come back "you're already on expedition". // // There is deliberately no "Pete has never heard of this player" case to test: // the detail row this resolves against is the same row the ownership check // already found, so it always exists by then. A gogobee too old to push offers // yields an empty list and the page renders no picker at all. func TestParamsResolveOnlyAgainstAPushedOffer(t *testing.T) { s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()}) if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean", }); w.Code != 200 { t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String()) } // Resume is not on offer for this player at all, so it is refused even though // the loadout key is a real one from the zone list above. if w := placeParams(t, s, "holymachina", advOrderReq{ Action: storage.AdvActionResume, Loadout: "lean", }); w.Code != 409 { t.Fatalf("resume with no offer = %d, want 409", w.Code) } }