The site could only reach somebody who was already looking at it. Push existed and adventure used none of it, so the one communal event in the game -- the Siege -- was invisible to anyone not sitting in Matrix, and a player whose adventurer died found out whenever they next opened a tab. Four opt-in categories, every one of them off until asked for: the Siege (realm-wide, begins and ends), your expedition ending, your adventurer wandering off, and a contract landing on you. Turning on news notifications is not consent to be told about the game, so nothing here enrolls anybody automatically. No new wire. Every trigger is a dispatch already landing in adventure_events, so this is Pete-side only and gogobee is untouched. Two things it needed from storage. push_subscriptions now keeps the Matrix localpart alongside the OIDC subject, because every ownership join in the schema is keyed on the localpart and the sender runs on a ticker with no session to read one from -- without it there is no way to answer "whose adventurer is this". And the alerts carry their own watermark, kept apart from the digest's: the two run on different clocks and one column would let each consume the other's backlog. The ownership join is re-read on every pass rather than trusted from the subscription row, so an opt-out or a removal closes the channel at once. It fails closed in both directions, and an unresolved owner can never fall through to a broadcast -- a game alert naming somebody's adventurer, delivered to the wrong phone, is a privacy leak dressed as a feature. An existing subscription carries watermark 0, which read literally means "has never been told anything" and would page every subscriber for the whole history of the realm on the first tick after deploy. Those rows are stamped to now and start from the next dispatch. Verified against a running Pete with a real push service, real P-256 client keys and real encryption: the right person is notified, the wrong one is not, a second pass is silent, and dropping the player from the board takes the channel with it. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
284 lines
11 KiB
Go
284 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// TestOwnerScopedAlertsNeverBroadcast is the security regression for this whole
|
|
// phase, and it is the one to keep if the rest are ever thinned out.
|
|
//
|
|
// Three of the four categories name somebody's adventurer. The sender resolves
|
|
// "which character belongs to this subscriber" from a join that can legitimately
|
|
// come back empty — a player off the board, an opt-out, a session with no
|
|
// username. If an empty answer ever fell through to "matches anything", every
|
|
// subscriber's phone would light up with a stranger's death, naming them.
|
|
func TestOwnerScopedAlertsNeverBroadcast(t *testing.T) {
|
|
owned := []storage.AdvEvent{
|
|
{EventType: "death", Subject: "Josie", Zone: "The Crypt"},
|
|
{EventType: "zone_clear", Subject: "Josie", Zone: "The Crypt"},
|
|
{EventType: "retreat", Subject: "Josie", Zone: "The Crypt"},
|
|
{EventType: "departure", Subject: "Josie", Zone: "The Crypt"},
|
|
{EventType: "mischief_contract", Subject: "Josie", Stakes: "500 gold"},
|
|
}
|
|
|
|
for _, ev := range owned {
|
|
// No resolvable owner at all.
|
|
if _, ok := advAlertFor(ev, ""); ok {
|
|
t.Errorf("%s matched with no owner resolved; that is a broadcast of a private event", ev.EventType)
|
|
}
|
|
// An owner, but somebody else's dispatch.
|
|
if _, ok := advAlertFor(ev, "Quack"); ok {
|
|
t.Errorf("%s about Josie matched a subscriber who plays Quack", ev.EventType)
|
|
}
|
|
// The actual owner.
|
|
if _, ok := advAlertFor(ev, "Josie"); !ok {
|
|
t.Errorf("%s about Josie did not match Josie", ev.EventType)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSiegeAlertsAreRealmWide is the other half: the Siege is the one communal
|
|
// mechanic, so it must reach a subscriber whose ownership join came back empty —
|
|
// including someone who has never made a character at all.
|
|
func TestSiegeAlertsAreRealmWide(t *testing.T) {
|
|
for _, kind := range []string{"siege_start", "siege_win", "siege_loss"} {
|
|
a, ok := advAlertFor(storage.AdvEvent{EventType: kind, Boss: "The Hollow King"}, "")
|
|
if !ok {
|
|
t.Fatalf("%s did not match a subscriber with no character", kind)
|
|
}
|
|
if a.Category != advCatSiege {
|
|
t.Errorf("%s filed under %q, want %q", kind, a.Category, advCatSiege)
|
|
}
|
|
if a.URL != "/adventure/siege" {
|
|
t.Errorf("%s links to %q, want the war room", kind, a.URL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUntemplatedDispatchIsSilent pins that adding an event type upstream does
|
|
// not silently start paging people. W0 made an unknown event_type render as a
|
|
// neutral card rather than 400 — the right call for a *page*, and the wrong one
|
|
// for a phone. A dispatch nobody has written alert copy for gets no alert.
|
|
func TestUntemplatedDispatchIsSilent(t *testing.T) {
|
|
for _, kind := range []string{"treasure_found", "milestone", "arrival", "companion_hire", "brand_new_thing"} {
|
|
if _, ok := advAlertFor(storage.AdvEvent{EventType: kind, Subject: "Josie"}, "Josie"); ok {
|
|
t.Errorf("%s produced an alert; new event types must opt in, not opt out", kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEndedRunAlertPrefersTheRunReport pins the landing page. A finished
|
|
// expedition has a report worth reading; a dispatch that predates the liveblog
|
|
// has no run id and must still land somewhere real rather than on /adventure/.
|
|
func TestEndedRunAlertPrefersTheRunReport(t *testing.T) {
|
|
withRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g1", RunID: "run-7"}
|
|
a, ok := advAlertFor(withRun, "Josie")
|
|
if !ok || a.URL != "/adventure/run/run-7" {
|
|
t.Errorf("url = %q, want the run report", a.URL)
|
|
}
|
|
|
|
noRun := storage.AdvEvent{EventType: "zone_clear", Subject: "Josie", GUID: "g2"}
|
|
a, ok = advAlertFor(noRun, "Josie")
|
|
if !ok || a.URL != "/adventure/g2" {
|
|
t.Errorf("url = %q, want the story permalink fallback", a.URL)
|
|
}
|
|
}
|
|
|
|
// TestAlertCopySurvivesAnEmptyZone guards the copy against the blank-noun defect
|
|
// that W2b hit twice: a fact is allowed to arrive with fields missing, and the
|
|
// result must still read as a sentence rather than "Josie fell in ".
|
|
func TestAlertCopySurvivesAnEmptyZone(t *testing.T) {
|
|
a, ok := advAlertFor(storage.AdvEvent{EventType: "death", Subject: "Josie"}, "Josie")
|
|
if !ok {
|
|
t.Fatal("death with no zone produced no alert")
|
|
}
|
|
if a.Title != "Josie fell in the dark" {
|
|
t.Errorf("title = %q; a missing zone must still read as a sentence", a.Title)
|
|
}
|
|
// Same for a siege with no boss name on the fact.
|
|
b, _ := advAlertFor(storage.AdvEvent{EventType: "siege_win"}, "")
|
|
if b.Body != "It went down. See how the town did." {
|
|
t.Errorf("body = %q; a nameless boss must still read as a sentence", b.Body)
|
|
}
|
|
}
|
|
|
|
// TestUnsignedContractKeepsItsSecret. The anonymity of an unsigned mischief
|
|
// contract is the mechanic — the buyer's name is the reward for surviving it. A
|
|
// notification that leaked it would hand the payoff to the target for free.
|
|
func TestUnsignedContractKeepsItsSecret(t *testing.T) {
|
|
signed, _ := advAlertFor(storage.AdvEvent{
|
|
EventType: "mischief_contract", Subject: "Josie", Opponent: "Quack",
|
|
}, "Josie")
|
|
if !strings.Contains(signed.Body, "Quack") {
|
|
t.Errorf("a signed contract hid its buyer: %q", signed.Body)
|
|
}
|
|
|
|
anon, _ := advAlertFor(storage.AdvEvent{EventType: "mischief_contract", Subject: "Josie"}, "Josie")
|
|
if strings.Contains(anon.Body, "Quack") || !strings.Contains(anon.Body, "isn't saying who") {
|
|
t.Errorf("an unsigned contract gave something away: %q", anon.Body)
|
|
}
|
|
}
|
|
|
|
// TestCategoriesDefaultToNothing pins the consent model. A user who turned on
|
|
// news notifications has not asked to be told about the game, and every way the
|
|
// preference can be missing or broken must mean "no".
|
|
func TestCategoriesDefaultToNothing(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
_ = s
|
|
|
|
for name, blob := range map[string]string{
|
|
"no prefs row at all": "",
|
|
"prefs but no key": `{"pete.weather.loc.v1":"\"London\""}`,
|
|
"key is not JSON": `{"pete.advPush.v1":"not json at all"}`,
|
|
"key is a JSON null": `{"pete.advPush.v1":null}`,
|
|
"all boxes unchecked": `{"pete.advPush.v1":"{\"siege\":false,\"run\":false}"}`,
|
|
} {
|
|
if blob != "" {
|
|
if err := storage.PutUserPrefs("sub-1", blob, "Josie", "j@example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if got := advCategoriesFor("sub-1"); len(got) != 0 {
|
|
t.Errorf("%s: enabled %v, want nothing", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCategoriesReadTheDoubleEncodedBlob. The client mirrors localStorage, whose
|
|
// values are strings, so the stored map arrives as JSON inside a JSON string.
|
|
// Both that shape and a bare object must parse — the bare form is what a
|
|
// hand-edited or migrated blob looks like.
|
|
func TestCategoriesReadTheDoubleEncodedBlob(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
_ = s
|
|
|
|
nested, _ := json.Marshal(map[string]string{
|
|
advPrefsKey: `{"siege":true,"run":true,"departure":false}`,
|
|
})
|
|
if err := storage.PutUserPrefs("sub-1", string(nested), "Josie", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := advCategoriesFor("sub-1")
|
|
if !got[advCatSiege] || !got[advCatRun] {
|
|
t.Errorf("double-encoded blob parsed to %v, want siege+run", got)
|
|
}
|
|
if got[advCatDeparture] {
|
|
t.Error("an explicitly false category was treated as enabled")
|
|
}
|
|
|
|
bare := `{"pete.advPush.v1":{"contract":true}}`
|
|
if err := storage.PutUserPrefs("sub-2", bare, "Quack", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := advCategoriesFor("sub-2"); !got[advCatContract] {
|
|
t.Errorf("bare-object blob parsed to %v, want contract", got)
|
|
}
|
|
}
|
|
|
|
// TestFirstPassNeverReplaysTheBacklog is the deploy safety valve.
|
|
//
|
|
// Every subscription that exists when this ships carries watermark 0. Read
|
|
// literally that means "has never been told anything", and the first tick would
|
|
// page every subscriber for every dispatch Pete has ever stored — on the same
|
|
// pass, from a feature they never switched on. The seeding branch stamps those
|
|
// rows to now and sends nothing, so alerts begin at the next real dispatch.
|
|
func TestFirstPassNeverReplaysTheBacklog(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
|
|
// A subscriber with everything switched on and a character on the board —
|
|
// i.e. the person most exposed to a replay.
|
|
seedSubscriberWithEverythingOn(t, "sub-1", "josie", "Josie")
|
|
|
|
// A realm with history, all of it well before now.
|
|
old := time.Now().Add(-90 * 24 * time.Hour).Unix()
|
|
for i, kind := range []string{"death", "zone_clear", "siege_start", "departure"} {
|
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
|
GUID: string(rune('a'+i)) + "-guid", EventType: kind, Subject: "Josie",
|
|
Boss: "The Hollow King", Zone: "The Crypt", OccurredAt: old + int64(i),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
before := time.Now().Unix()
|
|
// No push service is reachable from a test, so a send would surface as an
|
|
// error rather than silence. What this asserts is that we never get that far:
|
|
// the watermark is seeded and the pass returns having considered nobody.
|
|
s.sendAdventureAlerts()
|
|
|
|
subs, err := storage.ListPushSubscriptions()
|
|
if err != nil || len(subs) != 1 {
|
|
t.Fatalf("read back %d subscriptions (err %v), want 1", len(subs), err)
|
|
}
|
|
if subs[0].LastAdvNotifiedAt < before {
|
|
t.Fatalf("watermark = %d, want >= %d: a 0 watermark must be stamped to now, not read as 'tell them everything'",
|
|
subs[0].LastAdvNotifiedAt, before)
|
|
}
|
|
|
|
// And the pass after it is quiet, because everything in the realm is now
|
|
// behind the watermark.
|
|
s.sendAdventureAlerts()
|
|
subs, _ = storage.ListPushSubscriptions()
|
|
if subs[0].LastAdvNotifiedAt < before {
|
|
t.Error("second pass moved the watermark backwards")
|
|
}
|
|
}
|
|
|
|
// TestCategoriesOffStillAdvanceTheWatermark. Someone with push on but no
|
|
// adventure categories must not accumulate a backlog: switching a category on
|
|
// should start from that moment, not replay everything it was off for.
|
|
func TestCategoriesOffStillAdvanceTheWatermark(t *testing.T) {
|
|
s, _ := newAdvServer(t, "tok")
|
|
|
|
if err := storage.AddPushSubscription("sub-1", "josie", "https://push.example/ep", "p", "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Move off the seeding branch so the pass actually evaluates this row.
|
|
if err := storage.TouchAdvPushSubscription("https://push.example/ep", 1000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storage.InsertAdventureEvent(&storage.AdvEvent{
|
|
GUID: "g1", EventType: "siege_start", Boss: "The Hollow King", OccurredAt: 5000,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s.sendAdventureAlerts()
|
|
|
|
subs, _ := storage.ListPushSubscriptions()
|
|
if len(subs) != 1 || subs[0].LastAdvNotifiedAt != 5000 {
|
|
t.Fatalf("watermark = %d, want 5000: a subscriber with nothing enabled must still move past what they were not told",
|
|
subs[0].LastAdvNotifiedAt)
|
|
}
|
|
}
|
|
|
|
// seedSubscriberWithEverythingOn wires the full happy path: a push endpoint, a
|
|
// character on the board with an owner, and every alert category enabled.
|
|
func seedSubscriberWithEverythingOn(t *testing.T, sub, localpart, character string) {
|
|
t.Helper()
|
|
if err := storage.AddPushSubscription(sub, localpart, "https://push.example/"+sub, "p", "a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blob, _ := json.Marshal(map[string]string{
|
|
advPrefsKey: `{"siege":true,"run":true,"departure":true,"contract":true}`,
|
|
})
|
|
if err := storage.PutUserPrefs(sub, string(blob), character, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storage.ReplaceRoster([]storage.RosterEntry{{
|
|
Token: "tok-" + localpart, Name: character, Level: 14, Status: "idle",
|
|
}}, time.Now().Unix()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := storage.ReplacePlayerDetail([]storage.PlayerDetail{{
|
|
Localpart: localpart, Token: "tok-" + localpart,
|
|
}}, time.Now().Unix()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|