W5a gave the web two verbs that cost nothing. These are the three that take arguments and spend coins: set out for a zone with a supply loadout, walk back into the run you extracted from, hire the pet sitter for a week or a month. Between them they cover the most common thing anybody does in the game, which until now could only be typed into Matrix. Arguments are the new surface, so they are the thing to be careful with. Nothing in a request is trusted: every zone, loadout and duration is looked up in the offer list gogobee pushed onto that owner's own private row, and the order stores what was found there rather than what was sent. A forged zone resolves to nothing and never becomes an order. gogobee then re-resolves all of it anyway, because an offer is a quote and a quote is not a permission. The money confirm is the equip panel's, lifted: cost, balance, and the balance it leaves. When it does not cover, it says so instead of printing a negative. Verified in a browser rather than only in tests, which is where both real defects came from: the confirm box was appending to the whole panel and so appeared at the bottom of the section instead of under the button that raised it, and button prices printed as EUR45000 above a dialog reading EUR45,000. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
424 lines
17 KiB
Go
424 lines
17 KiB
Go
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, 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 means "already out there", not "nowhere to go" — gogobee
|
|
// omits the offers entirely while the adventurer is on an expedition. Refusing
|
|
// cheaply here beats a verdict thirty seconds later saying the same thing.
|
|
func TestNoZoneOffersMeansAlreadyOut(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)
|
|
}
|
|
}
|