//go:build integration // Runs against a real Postgres with the dashboard API's migrations applied: // // SECURITY_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/securityrepo package securityrepo import ( "context" "database/sql" "os" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" ) func testRepo(t *testing.T) (*Repo, int64) { t.Helper() dsn := os.Getenv("SECURITY_TEST_DB") if dsn == "" { t.Skip("SECURITY_TEST_DB not set") } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) const g = 777 for _, tbl := range []string{"security_settings", "security_trusted", "security_quarantines", "security_lockdowns", "security_events", "security_commands"} { if _, err := db.Exec(`DELETE FROM `+tbl+` WHERE guild_id = $1`, g); err != nil { t.Fatal(err) } } return NewRepo(db), g } func TestSettings(t *testing.T) { r, g := testRepo(t) ctx := context.Background() s, err := r.GetSettings(ctx, g) if err != nil || s.Configured || s.JoinGateEnabled || s.AntiNukeEnabled || s.UnauthorizedBot != ActionKick || s.AntiNukeLimits[LimitChannelDelete] != 2 || !s.AlertDM { t.Fatalf("defaults wrong: %+v %v", s, err) } for _, f := range []string{FeatureJoinGate, FeatureAntiRaid, FeatureAntiNuke} { if err := r.SetEnabled(ctx, g, f, true); err != nil { t.Fatal(err) } } if err := r.SetEnabled(ctx, g, "drop table", true); err == nil { t.Error("unknown features must be refused, never put into SQL") } _ = r.SetAlerts(ctx, g, 10, 20) if _, err := r.db.Exec(`UPDATE security_settings SET alert_dm = false WHERE guild_id = $1`, g); err != nil { t.Fatal(err) } _ = r.SetQuarantineRole(ctx, g, 30) _ = r.SetJoinLock(ctx, g, ActionBan) if _, err := r.db.Exec(`UPDATE security_settings SET joingate_username_patterns = '{nitro,free}', antinuke_limits = '{"ban": 0}' WHERE guild_id = $1`, g); err != nil { t.Fatal(err) } s, err = r.GetSettings(ctx, g) if err != nil { t.Fatal(err) } if !s.Configured || s.AlertDM || !s.JoinGateEnabled || !s.AntiRaidEnabled || !s.AntiNukeEnabled || s.AlertChannelID != 10 || s.AlertRoleID != 20 || s.QuarantineRoleID != 30 || s.JoinLock != ActionBan { t.Errorf("saved settings wrong: %+v", s) } if len(s.UsernamePatterns) != 2 || s.AntiNukeLimits[LimitBan] != 0 || s.AntiNukeLimits[LimitKick] != 5 { t.Errorf("patterns or limits wrong (saved limits override defaults key by key): %+v %+v", s.UsernamePatterns, s.AntiNukeLimits) } if err := r.SetJoinLock(ctx, g, "everyone"); err == nil { t.Error("the database must refuse unknown join lock modes") } } func TestTrustedQuarantineLockdown(t *testing.T) { r, g := testRepo(t) ctx := context.Background() _ = r.AddTrusted(ctx, g, Trusted{TargetID: 5, Kind: "user", AddedBy: 1}) _ = r.AddTrusted(ctx, g, Trusted{TargetID: 6, Kind: "role", AddedBy: 1}) if err := r.AddTrusted(ctx, g, Trusted{TargetID: 7, Kind: "everyone", AddedBy: 1}); err == nil { t.Error("unknown kinds must be refused") } list, _ := r.ListTrusted(ctx, g) if len(list) != 2 { t.Fatalf("trusted = %+v", list) } if ok, _ := r.RemoveTrusted(ctx, g, 5); !ok { t.Error("remove should report true") } _ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: []int64{1, 2}, Reason: "first"}) _ = r.SaveQuarantine(ctx, Quarantine{GuildID: g, UserID: 9, PreviousRoleIDs: nil, Reason: "second"}) q, _ := r.GetQuarantine(ctx, g, 9) if q == nil || len(q.PreviousRoleIDs) != 2 || q.Reason != "first" { t.Errorf("the first quarantine record must be kept: %+v", q) } qs, _ := r.ListQuarantines(ctx, g) if len(qs) != 1 { t.Errorf("list = %+v", qs) } _ = r.DeleteQuarantine(ctx, g, 9) if q, _ := r.GetQuarantine(ctx, g, 9); q != nil { t.Error("deleted") } _ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: true, PreviousAllow: 1, PreviousDeny: 2, ServerWide: true, LockedBy: 1}) _ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 50, HadOverwrite: false, PreviousAllow: 9, PreviousDeny: 9, LockedBy: 2}) _ = r.SaveLockdown(ctx, Lockdown{GuildID: g, ChannelID: 51, LockedBy: 1}) l, _ := r.GetLockdown(ctx, g, 50) if l == nil || !l.HadOverwrite || l.PreviousAllow != 1 || !l.ServerWide { t.Errorf("the first lockdown record must be kept: %+v", l) } if all, _ := r.ListLockdowns(ctx, g, false); len(all) != 2 { t.Errorf("all = %+v", all) } if server, _ := r.ListLockdowns(ctx, g, true); len(server) != 1 { t.Errorf("server-wide = %+v", server) } _ = r.DeleteLockdown(ctx, g, 50) if l, _ := r.GetLockdown(ctx, g, 50); l != nil { t.Error("deleted") } if err := r.AddEvent(ctx, Event{GuildID: g, Kind: EventNuke, ActorID: 3, Detail: "x"}); err != nil { t.Fatal(err) } if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, payload, requested_by) VALUES ($1, 'release_quarantine', '{"user_id":"9"}', 1)`, g); err != nil { t.Fatal(err) } if _, err := r.db.Exec(`INSERT INTO security_commands (guild_id, command_type, requested_by) VALUES ($1, 'grant_admin', 1)`, g); err == nil { t.Error("unknown commands must be refused") } cmds, _ := r.ListUnprocessedCommands(ctx) var mine *Command for i := range cmds { if cmds[i].GuildID == g { mine = &cmds[i] } } if mine == nil || string(mine.Payload) != `{"user_id": "9"}` { t.Fatalf("command = %+v", mine) } _ = r.MarkCommandProcessed(ctx, mine.ID) } func TestRaidsSnapshotsAndHeatSettings(t *testing.T) { r, g := testRepo(t) ctx := context.Background() if _, err := r.db.Exec(`DELETE FROM security_raids WHERE guild_id = $1`, g); err != nil { t.Fatal(err) } _, _ = r.db.Exec(`DELETE FROM security_raid_caught WHERE guild_id = $1`, g) _, _ = r.db.Exec(`DELETE FROM security_snapshots WHERE guild_id = $1`, g) s, _ := r.GetSettings(ctx, g) if s.HeatEnabled || s.HeatStrictness != "medium" || s.HeatTimeoutMinutes != 10 || !s.HeatEscalate || s.HeatPanicMembers != 3 { t.Errorf("heat defaults wrong: %+v", s) } _ = r.SetEnabled(ctx, g, FeatureHeat, true) if _, err := r.db.Exec(`UPDATE security_settings SET heat_blocked_words = '{scam}', antinuke_auto_restore = true WHERE guild_id = $1`, g); err != nil { t.Fatal(err) } s, _ = r.GetSettings(ctx, g) if !s.HeatEnabled || !s.AntiNukeAutoRestore || len(s.HeatBlockedWords) != 1 { t.Errorf("heat settings not read back: %+v", s) } if _, err := r.db.Exec(`UPDATE security_settings SET heat_strictness = 'insane' WHERE guild_id = $1`, g); err == nil { t.Error("unknown strictness must be refused by the database") } start := time.Now().Add(-time.Minute).UTC().Truncate(time.Millisecond) _ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: start, LastJoinAt: start, InvitesPaused: true, CaughtTotal: 1}) _ = r.SaveRaid(ctx, Raid{GuildID: g, StartedAt: time.Now(), LastJoinAt: start.Add(time.Second), InvitesPaused: true, CaughtTotal: 2}) _ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "kick", Name: "a"}) _ = r.AddRaidCaught(ctx, g, Caught{UserID: 1, Action: "ban", Name: "a"}) _ = r.AddRaidCaught(ctx, g, Caught{UserID: 2, Action: "kick", Name: "b"}) raids, _ := r.ListRaids(ctx) var mine *Raid for i := range raids { if raids[i].GuildID == g { mine = &raids[i] } } if mine == nil || !mine.StartedAt.Equal(start) || mine.CaughtTotal != 2 { t.Fatalf("raid = %+v (the start time is kept on update)", mine) } caught, _ := r.ListRaidCaught(ctx, g, 10) if len(caught) != 2 || caught[0].Action != "kick" { t.Errorf("caught = %+v (one row per raider)", caught) } _ = r.DeleteRaid(ctx, g) if c, _ := r.ListRaidCaught(ctx, g, 10); len(c) != 0 { t.Error("deleting a raid clears its list") } for i := 0; i < 5; i++ { if err := r.SaveSnapshot(ctx, Snapshot{GuildID: g, Data: []byte(`{"roles":[],"channels":[]}`), Roles: i}); err != nil { t.Fatal(err) } } var n int _ = r.db.QueryRow(`SELECT COUNT(*) FROM security_snapshots WHERE guild_id = $1`, g).Scan(&n) if n != 3 { t.Errorf("only the last three snapshots are kept, have %d", n) } latest, _ := r.LatestSnapshot(ctx, g) if latest == nil || latest.Roles != 4 { t.Errorf("latest = %+v", latest) } _ = r.SetEnabled(ctx, g, FeatureAntiNuke, true) due, _ := r.GuildsDueForSnapshot(ctx, time.Hour) for _, d := range due { if d == g { t.Error("a guild with a fresh snapshot is not due") } } due, _ = r.GuildsDueForSnapshot(ctx, 0) found := false for _, d := range due { found = found || d == g } if !found { t.Error("with a zero max age every anti-nuke guild is due") } } func TestListEvents(t *testing.T) { r, g := testRepo(t) ctx := context.Background() for _, kind := range []string{"settings", "raid_started", "quarantine"} { if err := r.AddEvent(ctx, Event{GuildID: g, Kind: kind, ActorID: 5, Detail: "d"}); err != nil { t.Fatal(err) } } if err := r.AddEvent(ctx, Event{GuildID: g + 1, Kind: "other server"}); err != nil { t.Fatal(err) } t.Cleanup(func() { _, _ = r.db.Exec(`DELETE FROM security_events WHERE guild_id = $1`, g+1) }) got, err := r.ListEvents(ctx, g, 2) if err != nil || len(got) != 2 || got[0].Kind != "quarantine" || got[1].Kind != "raid_started" { t.Fatalf("newest first, limited: %+v %v", got, err) } if got[0].CreatedAt.IsZero() || got[0].ActorID != 5 || got[0].TargetID != 0 { t.Errorf("fields: %+v", got[0]) } if all, _ := r.ListEvents(ctx, g, 0); len(all) != 3 { t.Errorf("only this server's events: %v", all) } }