package web import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "pete/internal/storage" ) // The adventurer detail page. Two contracts under test: the public sheet reaches // any visitor (stats + gear, never a handle), and the private self-view unlocks // only for the signed-in owner of that exact page — anon and other users see the // public page and nothing more. func postDetail(t *testing.T, s *Server, token string, push detailPush) *httptest.ResponseRecorder { t.Helper() body, _ := json.Marshal(push) req := httptest.NewRequest("POST", "/api/ingest/detail", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() s.handleDetailIngest(w, req) return w } // publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way // the roster push carries it. func publicDetail(t *testing.T) json.RawMessage { t.Helper() raw, err := json.Marshal(map[string]any{ "hp_current": 30, "hp_max": 42, "temp_hp": 5, "armor_class": 17, "abilities": [6]int{16, 14, 15, 10, 12, 8}, "modifiers": [6]int{3, 2, 2, 0, 1, -1}, "gear": []map[string]any{ {"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100}, {"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100}, }, "supplies": 8, "threat_level": 2, "room": "3 / 7", }) if err != nil { t.Fatal(err) } return raw } // seedWho puts one adventurer on the board with a public detail sheet, and // pushes a private self-detail set owned by `owner` for that same token. func seedWho(t *testing.T, owner string) *Server { s, _ := newAdvServer(t, "tok") s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")} now := time.Now().Unix() e := entry("tok-josie", "Josie", "expedition", "holymachina") e.Level = 14 e.ClassRace = "human cleric" e.Detail = publicDetail(t) 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", Inventory: []storage.ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}}, Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}}, House: storage.HouseView{Tier: 2, LoanBalance: 1500}, Pets: []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}}, }}}); w.Code != 200 { t.Fatalf("seed detail = %d", w.Code) } return s } func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder { t.Helper() var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil) req.SetPathValue("token", token) if asUser != "" { payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()}) req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)}) } w := httptest.NewRecorder() s.handleAdventureWho(w, req) return w } // TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven // through the real template, so a field slip 500s here rather than in prod. func TestWhoPublicSheet(t *testing.T) { s := seedWho(t, "josie") w := getWho(t, s, "tok-josie", "") // anonymous if w.Code != 200 { t.Fatalf("GET who = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} { if !strings.Contains(body, want) { t.Errorf("public page missing %q", want) } } // The private goods must NOT be on an anonymous render. for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} { if strings.Contains(body, leak) { t.Errorf("anonymous page leaked private detail %q", leak) } } } // TestWhoSelfUnlock: the owner, signed in on their own page, sees the private // inventory/vault/house/pets inline. func TestWhoSelfUnlock(t *testing.T) { s := seedWho(t, "josie") w := getWho(t, s, "tok-josie", "josie") if w.Code != 200 { t.Fatalf("owner GET who = %d, want 200", w.Code) } body := w.Body.String() for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} { if !strings.Contains(body, want) { t.Errorf("owner page missing private detail %q", want) } } } // TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case // username; the localpart it maps to is lowercase. The owner must still unlock. func TestWhoSelfUnlockCaseInsensitive(t *testing.T) { s := seedWho(t, "josie") w := getWho(t, s, "tok-josie", "Josie") // capital-J session if !strings.Contains(w.Body.String(), "Iron Ore") { t.Error("a mixed-case owner username failed to unlock their own self-view") } } // TestWhoOtherUserNoUnlock: a different signed-in player sees only the public // sheet on someone else's page — the ownership join must not leak across users. func TestWhoOtherUserNoUnlock(t *testing.T) { s := seedWho(t, "josie") w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner if w.Code != 200 { t.Fatalf("other-user GET who = %d, want 200", w.Code) } body := w.Body.String() if !strings.Contains(body, "Josie") { t.Error("public sheet missing for a signed-in non-owner") } for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} { if strings.Contains(body, leak) { t.Errorf("a non-owner unlocked private detail %q", leak) } } } // TestWhoOffBoardIs404: a token that isn't on the current board resolves to // nothing — the same liveness gate the storefront uses, so a stale or guessed // token never renders a page. func TestWhoOffBoardIs404(t *testing.T) { s := seedWho(t, "josie") if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 { t.Errorf("off-board token = %d, want 404", w.Code) } } // TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the // set whole, so a player dropped from a later push loses their self-view. func TestDetailIngestReplacesAndAuth(t *testing.T) { s := seedWho(t, "josie") if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 { t.Errorf("bad bearer = %d, want 401", w.Code) } // A later push without Josie: her self-view must be gone, but the public // board (a separate keyspace) is untouched, so her page still renders public. if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 { t.Fatalf("empty detail push = %d, want 200", w.Code) } body := getWho(t, s, "tok-josie", "josie").Body.String() if strings.Contains(body, "Iron Ore") { t.Error("owner still unlocked after her detail was dropped from the push") } if !strings.Contains(body, "Josie") { t.Error("public page vanished when only the private detail was dropped") } }