Files
Pete/internal/storage/detail_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

127 lines
4.7 KiB
Go

package storage
import "testing"
// The private self-detail set is the one place Pete holds a localpart↔token
// association. These tests pin the security contract of that store: the
// ownership join only ever unlocks a page for the localpart that owns it, and a
// replace wipes a departed player's stale self-view rather than leaving it to
// linger.
func seedSelfDetail(t *testing.T, localpart, token string) PlayerDetail {
t.Helper()
return PlayerDetail{
Localpart: localpart,
Token: token,
Inventory: []ItemView{{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10}},
Vault: []ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
House: HouseView{Tier: 2, LoanBalance: 1500},
Pets: []PetView{{Type: "cat", Name: "Mittens", Level: 3}},
}
}
// TestPlayerDetailByOwnerMatrix is the ownership matrix: the self-view unlocks
// only when the signed-in localpart owns the exact page token. A different
// localpart, or the owner viewing someone else's page, gets nothing.
func TestPlayerDetailByOwnerMatrix(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
seedSelfDetail(t, "quack", "tok-quack"),
}, 1000); err != nil {
t.Fatalf("ReplacePlayerDetail: %v", err)
}
// The owner, on their own page: unlocked, and the private goods come through.
pd, ok, err := PlayerDetailByOwner("josie", "tok-josie")
if err != nil || !ok {
t.Fatalf("owner on own page: ok=%v err=%v, want unlocked", ok, err)
}
if len(pd.Inventory) != 1 || pd.House.Tier != 2 || len(pd.Pets) != 1 {
t.Errorf("owner detail = %+v, want inventory+house+pet carried", pd)
}
// Josie signed in, looking at Quack's page: her localpart doesn't own that
// token, so the join must refuse — no peeking at another player's private set.
if _, ok, _ := PlayerDetailByOwner("josie", "tok-quack"); ok {
t.Error("owner unlocked ANOTHER player's page — the token guard failed")
}
// A signed-in stranger with no self-detail row at all.
if _, ok, _ := PlayerDetailByOwner("nobody", "tok-josie"); ok {
t.Error("a stranger unlocked a page they have no row for")
}
// Empty inputs never unlock.
if _, ok, _ := PlayerDetailByOwner("", "tok-josie"); ok {
t.Error("empty localpart unlocked a page")
}
if _, ok, _ := PlayerDetailByOwner("josie", ""); ok {
t.Error("empty token unlocked a page")
}
}
// TestReplacePlayerDetailReplaces: the set is swapped whole, never merged — a
// player gogobee stops pushing (deleted character, dropped out) must lose their
// stale self-view, the same complete-snapshot contract as the board.
func TestReplacePlayerDetailReplaces(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
seedSelfDetail(t, "quack", "tok-quack"),
}, 1000); err != nil {
t.Fatal(err)
}
// Next push carries only Josie; Quack has left.
if err := ReplacePlayerDetail([]PlayerDetail{
seedSelfDetail(t, "josie", "tok-josie"),
}, 1060); err != nil {
t.Fatal(err)
}
if _, ok, _ := PlayerDetailByOwner("quack", "tok-quack"); ok {
t.Error("a dropped player's self-view survived the replace")
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-josie"); !ok {
t.Error("the surviving player lost their self-view")
}
}
// TestReplacePlayerDetailTokenFollows: when a player's board token rotates
// (re-derived each push), the ownership check must follow it. The stale token no
// longer unlocks; the current one does.
func TestReplacePlayerDetailTokenFollows(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-old")}, 1000); err != nil {
t.Fatal(err)
}
if err := ReplacePlayerDetail([]PlayerDetail{seedSelfDetail(t, "josie", "tok-new")}, 1060); err != nil {
t.Fatal(err)
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-old"); ok {
t.Error("the stale token still unlocked the page after a rotation")
}
if _, ok, _ := PlayerDetailByOwner("josie", "tok-new"); !ok {
t.Error("the current token failed to unlock the page")
}
}
// TestReplacePlayerDetailSkipsUnusable: a row with no owner or no page to hang
// on is dropped at write time — it could never be served and would only be dead
// weight.
func TestReplacePlayerDetailSkipsUnusable(t *testing.T) {
setupTestDB(t)
if err := ReplacePlayerDetail([]PlayerDetail{
{Localpart: "", Token: "tok-orphan"},
{Localpart: "ghost", Token: ""},
seedSelfDetail(t, "josie", "tok-josie"),
}, 1000); err != nil {
t.Fatal(err)
}
if tok, ok := SelfToken("josie"); !ok || tok != "tok-josie" {
t.Errorf("SelfToken(josie) = %q,%v, want tok-josie", tok, ok)
}
if _, ok := SelfToken("ghost"); ok {
t.Error("a tokenless row was stored")
}
}