mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Closes the two deferred code-review follow-ups on the adventure-news seam, plus folds in two pre-committed WIP fixes. A. Privacy — the public GUID no longer leaks a stable per-player id. Replaced userHash(userID)=sha256(userID)[:6] with eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc). The salt is 32 random bytes, auto-generated once and persisted in the durable news_config table (cached via sync.Once). Because each event uses a distinct HMAC message, tokens are a PRF output and are BOTH uncomputable from a Matrix handle (no enumeration of a player's events, incl. ones anonymized after !news optout) AND mutually unlinkable (a named event can't be walked back to a player's other, anonymized events). Updated all emit sites: pete.go zone, dnd_combat death, adventure_duel rival, dnd_setup arrival, achievements milestone, bootstrap x3. B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted the invariant GUID-prefix == event_type, which also fixes latent permalink mislabels (achv->milestone, rival->rival_result rendered a neutral "Dispatch" on their permalink pages). Folded-in WIP fixes: create the news_config table b42beec's newsEmissionOn reads but never created; reap sent pete_emit_queue rows in RunMaintenance; don't burn a retry attempt when shutdown cancels an in-flight send. Tests: TestEventToken (salted/stable/per-event/persisted), TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear), updated pete_test.go prefixes. Full internal suite + vet green. Unshipped. Deploy Pete first (it must know zone_clear), then gogobee.
217 lines
6.7 KiB
Go
217 lines
6.7 KiB
Go
package plugin
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gogobee/internal/db"
|
|
"gogobee/internal/peteclient"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// enablePeteSeam turns on the emit seam for a test and restores it to disabled
|
|
// afterwards, so a leaked-enabled singleton can't affect other tests.
|
|
func enablePeteSeam(t *testing.T) {
|
|
t.Helper()
|
|
os.Setenv("FEATURE_PETE_NEWS", "true")
|
|
os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0")
|
|
os.Setenv("PETE_INGEST_TOKEN", "tok")
|
|
peteclient.Init()
|
|
t.Cleanup(func() {
|
|
os.Unsetenv("FEATURE_PETE_NEWS")
|
|
os.Unsetenv("PETE_INGEST_URL")
|
|
os.Unsetenv("PETE_INGEST_TOKEN")
|
|
peteclient.Init()
|
|
})
|
|
}
|
|
|
|
func queuedCount(t *testing.T, likeGUID string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.Get().QueryRow(
|
|
`SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil {
|
|
t.Fatalf("queue count: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them,
|
|
// both idempotently.
|
|
func TestNewsOptoutRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
u := id.UserID("@zapp:example.org")
|
|
if isNewsOptedOut(u) {
|
|
t.Fatal("fresh player should not be opted out")
|
|
}
|
|
|
|
setNewsOptout(u, true)
|
|
if !isNewsOptedOut(u) {
|
|
t.Error("opt-out not recorded")
|
|
}
|
|
setNewsOptout(u, true) // idempotent
|
|
if !isNewsOptedOut(u) {
|
|
t.Error("second opt-out flipped state")
|
|
}
|
|
|
|
setNewsOptout(u, false)
|
|
if isNewsOptedOut(u) {
|
|
t.Error("opt-in did not clear")
|
|
}
|
|
setNewsOptout(u, false) // idempotent
|
|
if isNewsOptedOut(u) {
|
|
t.Error("second opt-in flipped state")
|
|
}
|
|
}
|
|
|
|
// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone
|
|
// realm-firsts through the emit queue, seeds the realm-first ledger, and is
|
|
// idempotent on a second boot.
|
|
func TestPeteNewsBackfill(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
enablePeteSeam(t)
|
|
|
|
// A named, dead adventurer.
|
|
db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location)
|
|
VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge")
|
|
// A single-holder achievement.
|
|
db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at)
|
|
VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000)
|
|
// A completed, boss-defeated zone run → realm-first.
|
|
db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at)
|
|
VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00")
|
|
|
|
p := &AdventurePlugin{}
|
|
p.bootstrapPeteNewsBackfill()
|
|
|
|
if got := queuedCount(t, "death:%"); got != 1 {
|
|
t.Errorf("death dispatches queued = %d, want 1", got)
|
|
}
|
|
if got := queuedCount(t, "milestone:%"); got != 1 {
|
|
t.Errorf("achievement dispatches queued = %d, want 1", got)
|
|
}
|
|
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
|
t.Errorf("zone-first dispatches queued = %d, want 1", got)
|
|
}
|
|
// Realm-first ledger seeded so a later live clear tiers as a repeat.
|
|
if claimRealmFirst("zone", "goblin_warren") {
|
|
t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever")
|
|
}
|
|
|
|
// Second boot: job gate holds, nothing re-queued.
|
|
p.bootstrapPeteNewsBackfill()
|
|
if got := queuedCount(t, "%"); got != 3 {
|
|
t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got)
|
|
}
|
|
}
|
|
|
|
// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker
|
|
// can recompute from the handle), stable per logical event (idempotency), and
|
|
// per-event so a user's tokens don't link to each other.
|
|
func TestEventToken(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
// The salt is cached process-wide via sync.Once; reset it so it derives (and
|
|
// persists) against this test's fresh DB rather than an earlier test's.
|
|
guidSaltOnce = sync.Once{}
|
|
guidSalt = nil
|
|
|
|
u := id.UserID("@alice:example.org")
|
|
|
|
// Not the old unsalted digest: an attacker with the handle can't recompute it.
|
|
bare := sha256.Sum256([]byte(u))
|
|
if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) {
|
|
t.Error("token matches unsalted sha256(handle) — enumeration attack reopened")
|
|
}
|
|
|
|
// Stable for the same (user, discriminator): a re-emit dedups.
|
|
if eventToken(u, "arrival") != eventToken(u, "arrival") {
|
|
t.Error("token not stable for the same event — idempotency broken")
|
|
}
|
|
|
|
// Per-event: two different events for the same user yield unrelated tokens, so
|
|
// a named event can't be walked back to the user's anonymized events.
|
|
if eventToken(u, "arrival") == eventToken(u, "death:123") {
|
|
t.Error("tokens collide across events — linkage attack reopened")
|
|
}
|
|
|
|
// Different users, same discriminator, still differ.
|
|
if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") {
|
|
t.Error("token collides across users")
|
|
}
|
|
|
|
// The salt persisted, so it survives a restart (a re-emit after reboot dedups).
|
|
var salt string
|
|
if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" {
|
|
t.Fatalf("guid salt not persisted: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY
|
|
// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and
|
|
// GUID prefix, so Pete never files a repeat as a first-ever.
|
|
func TestEmitZoneClearTaxonomy(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
enablePeteSeam(t)
|
|
|
|
db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`,
|
|
"@zapp:x", "Zapp")
|
|
exp := &Expedition{ZoneID: "goblin_warren"}
|
|
|
|
emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first
|
|
emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat
|
|
|
|
if got := queuedCount(t, "zone_first:%"); got != 1 {
|
|
t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got)
|
|
}
|
|
if got := queuedCount(t, "zone_clear:%"); got != 1 {
|
|
t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got)
|
|
}
|
|
}
|
|
|
|
// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip.
|
|
func TestNewsEmissionKillSwitch(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db.Close()
|
|
if err := db.Init(dir); err != nil {
|
|
t.Fatalf("db.Init: %v", err)
|
|
}
|
|
t.Cleanup(db.Close)
|
|
|
|
if !newsEmissionOn() {
|
|
t.Error("emission should default ON")
|
|
}
|
|
setNewsEmission(false)
|
|
if newsEmissionOn() {
|
|
t.Error("emission still ON after disable")
|
|
}
|
|
setNewsEmission(true)
|
|
if !newsEmissionOn() {
|
|
t.Error("emission still OFF after enable")
|
|
}
|
|
}
|