- /music skip, pause, resume, stop, repeat, remove, move (DJ role) - /config view, clear, toggletwitch, streamers, beta-features (server owner) - /slvl status, remove-reward; /moderation bans, timeouts; /security history - /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status - betasettings service and repo methods, with integration tests Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
256 lines
9.2 KiB
Go
256 lines
9.2 KiB
Go
//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)
|
|
}
|
|
}
|