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