Files
velox-bot/internal/security/security_test.go
FernandoJVideiraandClaude Opus 5.5 3f87e68a2c Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot
checks), raid detection with invite pausing, anti-nuke quarantine of
admins who mass delete, manual and automatic lockdowns, a decaying heat
score against spam, role and channel snapshots with restore, and alert
DMs to the owner and trusted users. Everything that weakens protection
is limited to the owner and the trusted list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:59:52 +01:00

595 lines
22 KiB
Go

package security
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
var ctx = context.Background()
// testEngine is a guild (id 100) owned by user 1, with Velox as user 999,
// alerts going to channel 500 and pinging role 201.
func testEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore, *time.Time) {
t.Helper()
d, st := newDiscord(), newStore()
e := New(d, st, "999")
e.syncDMs = true
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
e.now = func() time.Time { return now }
s := st.set(100)
s.AlertChannelID, s.AlertRoleID = 500, 201
return e, d, st, &now
}
func refresh(e *Engine) { e.Invalidate(guild) }
// snowflake for an account created at t.
func snowflakeAt(t time.Time, n int) string {
ms := t.UnixMilli() - 1420070400000
return fmt.Sprint(ms<<22 | int64(n))
}
func entry(action discordgo.AuditLogAction, user, target string, changes ...*discordgo.AuditLogChange) *discordgo.AuditLogEntry {
return &discordgo.AuditLogEntry{ActionType: &action, UserID: user, TargetID: target, Changes: changes}
}
func change(key discordgo.AuditLogChangeKey, old, new any) *discordgo.AuditLogChange {
return &discordgo.AuditLogChange{Key: &key, OldValue: old, NewValue: new}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// ---- Join Gate ----
func TestCheckJoin(t *testing.T) {
now := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
s := securityrepo.Defaults(1)
s.JoinGateEnabled = true
s.NoAvatar, s.NewAccount, s.AdvertisingName = securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan
s.UsernameFilter, s.UsernamePatterns = securityrepo.ActionKick, []string{" FreeNitro "}
s.UnverifiedBot = securityrepo.ActionKick
old := snowflakeAt(now.AddDate(-1, 0, 0), 1)
young := snowflakeAt(now.Add(-48*time.Hour), 2)
for _, c := range []struct {
name string
u *discordgo.User
want string
}{
{"clean", &discordgo.User{ID: old, Username: "ana", Avatar: "x"}, "off"},
{"no avatar", &discordgo.User{ID: old, Username: "ana"}, "timeout"},
{"new account beats no avatar", &discordgo.User{ID: young, Username: "ana"}, "kick"},
{"invite in display name", &discordgo.User{ID: old, Username: "ana", GlobalName: "join DISCORD.GG/abc", Avatar: "x"}, "ban"},
{"blocked word, case-insensitive", &discordgo.User{ID: old, Username: "freenitro_giveaway", Avatar: "x"}, "kick"},
{"verified bot passes", &discordgo.User{ID: young, Bot: true, PublicFlags: discordgo.UserFlagVerifiedBot}, "off"},
{"unverified bot", &discordgo.User{ID: young, Bot: true}, "kick"},
} {
if got := CheckJoin(s, c.u, now).Action; got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
s.JoinGateEnabled = false
if CheckJoin(s, &discordgo.User{ID: young}, now).Action != "off" {
t.Error("a disabled Join Gate must not act")
}
}
func TestJoinGateAndJoinLock(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.JoinGateEnabled, s.NewAccount = true, securityrepo.ActionKick
refresh(e)
young := d.addMember(snowflakeAt(now.Add(-time.Hour), 5))
if removed := e.OnMemberJoin(ctx, guild, young); !removed || !contains(d.kicked, young.User.ID) {
t.Errorf("new account should be kicked: removed=%v kicked=%v", removed, d.kicked)
}
if d.sentCount() != 1 || !contains(st.eventKinds(), securityrepo.EventJoinGate) {
t.Error("the action should be alerted and recorded")
}
st.set(100).JoinLock = securityrepo.ActionBan
refresh(e)
m := d.addMember(snowflakeAt(now.AddDate(-2, 0, 0), 6))
if !e.OnMemberJoin(ctx, guild, m) || !contains(d.banned, m.User.ID) {
t.Error("a join lock bans everyone new")
}
}
func TestOwnerAndBotAreNeverPunished(t *testing.T) {
e, d, st, _ := testEngine(t)
st.set(100).JoinLock = securityrepo.ActionBan
refresh(e)
if e.OnMemberJoin(ctx, guild, d.members["1"]) || len(d.banned) != 0 {
t.Error("the owner must never be banned")
}
if err := e.Quarantine(ctx, guild, "1", "x"); err == nil {
t.Error("the owner must never be quarantined")
}
if err := e.Quarantine(ctx, guild, "999", "x"); err == nil {
t.Error("the bot must never quarantine itself")
}
}
// ---- Raids ----
func TestRaidModeStartsActsAndEnds(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidJoinSeconds, s.RaidAction, s.RaidQuietMinutes = true, 3, 10, securityrepo.ActionKick, 5
refresh(e)
old := now.AddDate(-1, 0, 0)
join := func(n int) *discordgo.Member {
m := d.addMember(snowflakeAt(old, n))
e.OnMemberJoin(ctx, guild, m)
return m
}
a := join(1)
*now = now.Add(2 * time.Second)
join(2)
if len(d.kicked) != 0 || e.RaidActive(guild) {
t.Fatal("two joins are not a raid")
}
*now = now.Add(2 * time.Second)
join(3)
if !e.RaidActive(guild) || len(d.kicked) != 3 || !contains(d.kicked, a.User.ID) {
t.Fatalf("the third join starts a raid and the whole burst is kicked: %v", d.kicked)
}
if len(d.requests) != 1 || !strings.Contains(d.requests[0], "/incident-actions") || !strings.Contains(d.requests[0], "2026-10-21") {
t.Errorf("invites should be paused for 24h: %v", d.requests)
}
alert := d.channelSends()[0]
if alert.Content != "<@&201>" || strings.Join(alert.AllowedMentions.Roles, ",") != "201" || len(alert.AllowedMentions.Users) != 0 {
t.Errorf("a raid pings only the alert role: %+v", alert)
}
// During the raid every join is acted on.
*now = now.Add(time.Minute)
join(4)
if len(d.kicked) != 4 {
t.Errorf("joins during a raid are kicked: %v", d.kicked)
}
// Quiet for 5 minutes: it ends, invites resume, the list is posted.
*now = now.Add(6 * time.Minute)
e.checkRaids(ctx)
if e.RaidActive(guild) {
t.Fatal("the raid should end after the quiet period")
}
if len(d.requests) != 2 || !strings.Contains(d.requests[1], "invites_disabled_until:<nil>") {
t.Errorf("invites should be resumed: %v", d.requests)
}
var list string
for name, content := range d.files {
if strings.HasPrefix(name, "raid-") {
list = content
}
}
if strings.Count(list, "\tkick\t") != 4 {
t.Errorf("the summary should list the 4 kicked accounts:\n%s", list)
}
if err := e.EndRaid(ctx, guild, "1"); err == nil {
t.Error("ending twice should say there's no raid")
}
}
func TestNoAlertFloodDuringARaid(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.JoinGateEnabled, s.NoAvatar = true, securityrepo.ActionKick
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionKick, false
refresh(e)
for i := 0; i < 50; i++ {
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i)}}) // no avatar
}
if n := d.sentCount(); n > 4 {
t.Errorf("a raid of 50 should produce a handful of alerts, got %d", n)
}
}
func TestRaidAlertOnlyTouchesNobody(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction, s.RaidPauseInvites = true, 3, securityrepo.ActionAlert, false
refresh(e)
for i := 0; i < 5; i++ {
e.OnMemberJoin(ctx, guild, d.addMember(snowflakeAt(now.AddDate(-1, 0, 0), i)))
}
if !e.RaidActive(guild) || len(d.kicked)+len(d.banned) != 0 || len(d.requests) != 0 {
t.Error("alert-only raid mode must not remove anyone or pause invites")
}
}
func TestRaidMemoryIsBounded(t *testing.T) {
e, d, st, now := testEngine(t)
s := st.set(100)
s.AntiRaidEnabled, s.RaidJoinCount, s.RaidAction = true, 3, securityrepo.ActionAlert
refresh(e)
for i := 0; i < maxRecentJoins+300; i++ {
e.OnMemberJoin(ctx, guild, &discordgo.Member{User: &discordgo.User{ID: snowflakeAt(now.AddDate(-1, 0, 0), i), Avatar: "a"}})
}
r := e.raidFor(guild)
if len(r.joins) > maxRecentJoins {
t.Errorf("recent joins not capped: %d", len(r.joins))
}
_ = d
}
// ---- Anti-nuke ----
func nukeEngine(t *testing.T) (*Engine, *fakeDiscord, *fakeStore) {
e, d, st, _ := testEngine(t)
st.set(100).AntiNukeEnabled = true
refresh(e)
d.addMember("50", "200", "202", "204") // a rogue admin with a booster role
return e, d, st
}
func TestNukeOverTheLimitIsQuarantined(t *testing.T) {
e, d, st := nukeEngine(t)
// Limit is 2 channel deletes per 10s.
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c1"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c2"))
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Fatal("two deletes are within the limit")
}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c3"))
q, ok := st.quarantines[key(100, 50)]
if !ok || fmt.Sprint(q.PreviousRoleIDs) != "[200 202]" {
t.Fatalf("roles should be saved (not the managed one): %+v", q)
}
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
t.Errorf("only the unremovable booster role should remain, got %s", got)
}
if d.timeouts["50"] == nil {
t.Error("the nuker should be timed out")
}
sends := d.channelSends()
last := sends[len(sends)-1]
if !strings.Contains(last.Embeds[0].Title, "Nuke attempt stopped") || !contains(last.AllowedMentions.Users, "1") {
t.Errorf("the owner should be pinged: %+v", last)
}
// The next delete within a minute doesn't trigger a second response.
before := len(d.sent)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelDelete, "50", "c4"))
if len(d.sent) != before {
t.Error("one attempt should be answered once")
}
}
func TestTrustedOwnerAndBotAreExempt(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("60", "201")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 50, Kind: "user"}, {TargetID: 201, Kind: "role"}}
refresh(e)
for _, who := range []string{"50", "60", "1", "999"} {
for i := 0; i < 5; i++ {
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberBanAdd, who, "x"))
}
}
if len(st.quarantines) != 0 {
t.Errorf("trusted users, trusted roles, the owner and the bot are exempt: %v", st.quarantines)
}
}
func TestInstantTriggers(t *testing.T) {
for name, ent := range map[string]*discordgo.AuditLogEntry{
"prune": entry(discordgo.AuditLogActionMemberPrune, "50", ""),
"vanity": entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("vanity_url_code", "old", "scam")),
"perms": entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", "0", fmt.Sprint(discordgo.PermissionAdministrator))),
"grant": entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "200"}})),
"newrole": entry(discordgo.AuditLogActionRoleCreate, "50", "x", change("permissions", nil, fmt.Sprint(discordgo.PermissionManageRoles))),
"botrole": entry(discordgo.AuditLogActionRoleUpdate, "50", "299", change("name", "Velox", "x")),
} {
t.Run(name, func(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("70")
e.OnAuditLogEntry(ctx, guild, ent)
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Errorf("%s should quarantine at once", name)
}
})
}
}
func TestHarmlessChangesAreIgnored(t *testing.T) {
e, d, st := nukeEngine(t)
d.addMember("70")
// Renaming a role, removing permissions, giving a plain role.
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionRoleUpdate, "50", "202", change("permissions", fmt.Sprint(discordgo.PermissionAdministrator), "0")))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$add", nil, []any{map[string]any{"id": "202"}})))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionChannelUpdate, "50", "c"))
if len(st.quarantines) != 0 {
t.Error("harmless changes must not trigger anything")
}
}
func TestHarmlessEntriesCostNoLookups(t *testing.T) {
e, d, _ := nukeEngine(t)
d.lookups = 0
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMessageDelete, "50", "x"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberUpdate, "50", "x"))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "70", change("$remove", nil, []any{map[string]any{"id": "202"}})))
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionGuildUpdate, "50", guild, change("name", "a", "b")))
if d.lookups != 0 {
t.Errorf("harmless entries made %d Discord lookups", d.lookups)
}
}
func TestSelfGrantedTrustedRoleIsNotABypass(t *testing.T) {
e, d, st := nukeEngine(t)
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
refresh(e)
// The rogue admin gives themselves the trusted role...
d.members["50"].Roles = append(d.members["50"].Roles, "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "50", change("$add", nil, []any{map[string]any{"id": "201"}})))
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Fatal("granting yourself a trusted role must be stopped, not count as trust")
}
if contains(d.members["50"].Roles, "201") {
t.Error("the trusted role should be gone")
}
}
func TestTrustedRoleGrantedToAnAltIsTakenBack(t *testing.T) {
e, d, st := nukeEngine(t)
st.trusted[100] = []securityrepo.Trusted{{TargetID: 201, Kind: "role"}}
refresh(e)
d.addMember("71", "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberRoleUpdate, "50", "71", change("$add", nil, []any{map[string]any{"id": "201"}})))
if _, ok := st.quarantines[key(100, 50)]; !ok {
t.Error("the granter should be quarantined")
}
if contains(d.members["71"].Roles, "201") {
t.Error("the alt should lose the trusted role")
}
}
func TestNukingBotIsKicked(t *testing.T) {
e, d, _ := nukeEngine(t)
d.members["80"] = &discordgo.Member{User: &discordgo.User{ID: "80", Bot: true}}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "80", ""))
if !contains(d.kicked, "80") {
t.Error("a nuking bot should be kicked")
}
}
func TestAntiNukeOffDoesNothing(t *testing.T) {
e, _, st := nukeEngine(t)
st.set(100).AntiNukeEnabled = false
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if len(st.quarantines) != 0 {
t.Error("anti-nuke off must not act")
}
}
func TestUnauthorizedBotAdd(t *testing.T) {
e, d, st, _ := testEngine(t)
st.set(100).JoinGateEnabled = true
refresh(e)
d.addMember("50", "200")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "50", "81"))
if !contains(d.kicked, "81") {
t.Error("a bot added by someone untrusted should be kicked")
}
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionBotAdd, "1", "82"))
if contains(d.kicked, "82") {
t.Error("the owner may add bots")
}
}
func TestDangerousQuarantineRoleIsNotAssigned(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 200 // misconfigured: the Admin role
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if contains(d.members["50"].Roles, "200") {
t.Error("a quarantine role with dangerous permissions must never be assigned")
}
st.set(100).QuarantineRoleID = 203
refresh(e)
d.addMember("51", "201")
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "51", ""))
if !contains(d.members["51"].Roles, "203") {
t.Error("a safe quarantine role should be assigned")
}
}
func TestQuarantineRoleVeloxCantAssignDoesNotBlockQuarantine(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 203
refresh(e)
d.rejectRole = "203" // e.g. moved above Velox's role after it was configured
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
if got := strings.Join(d.members["50"].Roles, ","); got != "204" {
t.Errorf("roles must still be removed without the quarantine role, got %s", got)
}
if d.timeouts["50"] == nil {
t.Error("and the member still timed out")
}
}
func TestReleaseGivesRolesBack(t *testing.T) {
e, d, st := nukeEngine(t)
st.set(100).QuarantineRoleID = 203
refresh(e)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
// The Mod role was deleted meanwhile... no wait, Member (202) was.
d.roles = append(d.roles[:3], d.roles[4:]...)
if err := e.Release(ctx, guild, "50", "1"); err != nil {
t.Fatal(err)
}
got := strings.Join(d.members["50"].Roles, ",")
if got != "204,200" {
t.Errorf("roles after release = %s (want the booster role kept, Admin back, deleted Member skipped, quarantine role gone)", got)
}
if d.timeouts["50"] != nil {
t.Error("the timeout should be removed")
}
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Error("the quarantine record should be gone")
}
if err := e.Release(ctx, guild, "50", "1"); err == nil {
t.Error("releasing twice should fail")
}
}
// ---- Lockdown ----
func TestLockAndUnlockRestoreExactly(t *testing.T) {
e, d, _, _ := testEngine(t)
d.channels["300"] = &discordgo.Channel{ID: "300", GuildID: guild, Type: discordgo.ChannelTypeGuildText,
PermissionOverwrites: []*discordgo.PermissionOverwrite{{ID: guild, Type: discordgo.PermissionOverwriteTypeRole,
Allow: discordgo.PermissionAddReactions, Deny: discordgo.PermissionAttachFiles}}}
d.channels["301"] = &discordgo.Channel{ID: "301", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
d.channels["302"] = &discordgo.Channel{ID: "302", GuildID: guild, Type: discordgo.ChannelTypeGuildCategory}
d.channels["400"] = &discordgo.Channel{ID: "400", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
if err := e.LockChannel(ctx, guild, "400", "1", false); err == nil {
t.Fatal("a channel of another server must never be touched")
}
locked, failed, err := e.LockServer(ctx, guild, "1")
if err != nil || locked != 2 || failed != 0 {
t.Fatalf("locked=%d failed=%d err=%v (categories are skipped)", locked, failed, err)
}
o := d.channels["300"].PermissionOverwrites[0]
if o.Deny&discordgo.PermissionSendMessages == 0 || o.Allow&discordgo.PermissionAddReactions != 0 || o.Deny&discordgo.PermissionAttachFiles == 0 {
t.Errorf("lock should deny chat and keep other bits: %+v", o)
}
// Someone changes an unrelated bit during the lockdown; it must survive.
o.Allow |= discordgo.PermissionEmbedLinks
// Locking again must not overwrite the saved "before" state.
_ = e.LockChannel(ctx, guild, "300", "1", false)
if _, _, err := e.UnlockServer(ctx, guild, "1"); err != nil {
t.Fatal(err)
}
o = d.channels["300"].PermissionOverwrites[0]
if o.Allow != discordgo.PermissionAddReactions|discordgo.PermissionEmbedLinks || o.Deny != discordgo.PermissionAttachFiles {
t.Errorf("unlock should restore the original lock bits and keep the new one: allow=%d deny=%d", o.Allow, o.Deny)
}
if len(d.channels["301"].PermissionOverwrites) != 0 {
t.Error("a channel that had no overwrite should have none again")
}
if _, _, err := e.UnlockServer(ctx, guild, "1"); err == nil {
t.Error("unlocking an unlocked server should say so")
}
}
// ---- Configuration rules ----
func TestConfigurationPermissions(t *testing.T) {
e, d, st, _ := testEngine(t)
admin := d.addMember("50", "200")
owner := d.members["1"]
trustedUser := d.addMember("60")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
// Turning on: Manage Server is enough. Turning off: owner or trusted only.
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionManageGuild, securityrepo.FeatureAntiNuke, true); err != nil {
t.Errorf("an admin may turn anti-nuke on: %v", err)
}
if err := e.SetFeature(ctx, guild, admin, discordgo.PermissionAdministrator, securityrepo.FeatureAntiNuke, false); !errors.Is(err, ErrNotTrusted) {
t.Errorf("an untrusted admin must not turn anti-nuke off: %v", err)
}
if err := e.SetFeature(ctx, guild, trustedUser, 0, securityrepo.FeatureAntiNuke, false); err != nil {
t.Errorf("a trusted user may: %v", err)
}
if err := e.SetFeature(ctx, guild, d.addMember("61"), 0, securityrepo.FeatureJoinGate, true); err == nil {
t.Error("a regular member must not turn things on")
}
if err := e.SetFeature(ctx, guild, nil, discordgo.PermissionAdministrator, securityrepo.FeatureJoinGate, true); err == nil {
t.Error("no actor, no change")
}
// Alerts: trusted only, never @everyone, only this server's channels.
d.channels["500"] = &discordgo.Channel{ID: "500", GuildID: guild, Type: discordgo.ChannelTypeGuildText}
d.channels["501"] = &discordgo.Channel{ID: "501", GuildID: "other", Type: discordgo.ChannelTypeGuildText}
if err := e.SetAlerts(ctx, guild, admin, "500", "201"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("untrusted alert change: %v", err)
}
if err := e.SetAlerts(ctx, guild, owner, "500", guild); err == nil {
t.Error("alerts must never ping @everyone")
}
if err := e.SetAlerts(ctx, guild, owner, "501", ""); err == nil {
t.Error("another server's channel must be refused")
}
if err := e.SetAlerts(ctx, guild, owner, "500", "201"); err != nil {
t.Error(err)
}
// Quarantine role: safe roles below Velox only.
for role, ok := range map[string]bool{"200": false, guild: false, "204": false, "299": false, "203": true} {
err := e.SetQuarantineRole(ctx, guild, owner, role)
if (err == nil) != ok {
t.Errorf("quarantine role %s: err=%v", role, err)
}
}
// Trust: owner only, never @everyone, bounded.
if err := e.Trust(ctx, guild, trustedUser, "user", "50"); !errors.Is(err, ErrNotOwner) {
t.Errorf("trusted users must not spread trust: %v", err)
}
if err := e.Trust(ctx, guild, owner, "role", guild); err == nil {
t.Error("trusting @everyone must be refused")
}
if err := e.Trust(ctx, guild, owner, "user", "404"); err == nil {
t.Error("trusting someone not in the server must be refused")
}
if err := e.Trust(ctx, guild, owner, "role", "201"); err != nil {
t.Error(err)
}
if err := e.Untrust(ctx, guild, admin, "201"); !errors.Is(err, ErrNotOwner) {
t.Errorf("only the owner untrusts: %v", err)
}
}
func TestDashboardCommands(t *testing.T) {
e, d, st := nukeEngine(t)
e.OnAuditLogEntry(ctx, guild, entry(discordgo.AuditLogActionMemberPrune, "50", ""))
st.cmds = []securityrepo.Command{
{ID: 1, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50; DROP"}`), RequestedBy: 1},
{ID: 2, GuildID: 100, CommandType: "release_quarantine", Payload: []byte(`{"user_id":"50"}`), RequestedBy: 1},
{ID: 3, GuildID: 100, CommandType: "nope", RequestedBy: 1},
}
e.ProcessCommands(ctx)
if _, ok := st.quarantines[key(100, 50)]; ok {
t.Error("the valid release should have run")
}
if len(st.done) != 3 {
t.Errorf("every command must be marked done: %v", st.done)
}
_ = d
}
func TestSafeNameDefusesMentionsAndMarkdown(t *testing.T) {
got := safeName(&discordgo.User{Username: "x", GlobalName: "@everyone **hi** <@&1> [link](http://a)"})
if strings.Contains(got, "@everyone") || strings.Contains(got, "<@&") || strings.Contains(got, "**") || strings.Contains(got, "](") {
t.Errorf("unsafe name rendering: %q", got)
}
}