Files
Pete/internal/web/who_test.go
prosolis 16711e13e6 adventure: a click-through page for every adventurer on the board
Each roster name becomes /adventure/who/{token}. Anyone sees the public sheet —
stats and equipped gear, decoded from the detail_json gogobee now hangs on each
board entry — with a live JSON re-poll so an open tab tracks HP and room as they
move. The signed-in owner sees the same page enriched with their private
inventory, vault, house, and pets, unlocked by an ownership join in the new
player_self_detail table (localpart owns token) — Pete never reverses the
anonymous token to decide it. buyerLocalpart is extracted so the storefront and
the ownership check lowercase the session name the same way.
2026-07-14 22:22:52 -07:00

200 lines
6.7 KiB
Go

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")
}
}