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