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.
200 lines
6.7 KiB
Go
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")
|
|
}
|
|
}
|