Security: tune the Join Gate, raid mode, anti-nuke and heat with commands
- /security settings, joingate check|account-age, words add|remove|list, raid, antinuke set|limit, heat, alert-dms - Same rule as the rest of the configuration: owner or trusted, validated in the engine - schema.sql was missing security_settings.alert_dm, so the bot couldn't read security settings on a database built from it. Fixed, with an ALTER for existing installs - A test checks every command against Discord's limits Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
5657d0789a
commit
d95ebf473e
10 files changed
+1020
-4
No files matched your search
@@ -0,0 +1,147 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"velox-bot/internal/db/repos/securityrepo"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func TestValidateTuning(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
t securityrepo.Tuning
|
||||
ok bool
|
||||
}{
|
||||
{"nothing to change is fine", securityrepo.Tuning{}, true},
|
||||
{"a filter action", securityrepo.Tuning{NoAvatar: ptr("timeout")}, true},
|
||||
{"an unknown filter action", securityrepo.Tuning{NoAvatar: ptr("nuke")}, false},
|
||||
{"bots can't be timed out", securityrepo.Tuning{UnverifiedBot: ptr("timeout")}, false},
|
||||
{"bots can be kicked", securityrepo.Tuning{UnauthorizedBot: ptr("kick")}, true},
|
||||
{"raid alert only", securityrepo.Tuning{RaidAction: ptr("alert")}, true},
|
||||
{"heat can't just alert", securityrepo.Tuning{HeatAction: ptr("alert")}, false},
|
||||
{"strictness", securityrepo.Tuning{HeatStrictness: ptr("extreme")}, false},
|
||||
{"account age low", securityrepo.Tuning{MinAccountAgeDays: ptr(0)}, false},
|
||||
{"account age high", securityrepo.Tuning{MinAccountAgeDays: ptr(366)}, false},
|
||||
{"raid join count edges", securityrepo.Tuning{RaidJoinCount: ptr(3)}, true},
|
||||
{"raid join count too low", securityrepo.Tuning{RaidJoinCount: ptr(2)}, false},
|
||||
{"raid window too long", securityrepo.Tuning{RaidJoinSeconds: ptr(301)}, false},
|
||||
{"quiet minutes", securityrepo.Tuning{RaidQuietMinutes: ptr(121)}, false},
|
||||
{"nuke window", securityrepo.Tuning{AntiNukeWindowSeconds: ptr(4)}, false},
|
||||
{"heat timeout max", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10080)}, true},
|
||||
{"heat timeout over", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10081)}, false},
|
||||
{"panic off", securityrepo.Tuning{HeatPanicMembers: ptr(0)}, true},
|
||||
{"panic too many", securityrepo.Tuning{HeatPanicMembers: ptr(51)}, false},
|
||||
{"mention flood off", securityrepo.Tuning{HeatLockdownMentions: ptr(0)}, true},
|
||||
{"mention flood too small", securityrepo.Tuning{HeatLockdownMentions: ptr(4)}, false},
|
||||
{"mention flood max", securityrepo.Tuning{HeatLockdownMentions: ptr(1000)}, true},
|
||||
{"a known limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 0}}, true},
|
||||
{"an unknown limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban_everyone": 1}}, false},
|
||||
{"a negative limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": -1}}, false},
|
||||
{"a huge limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 101}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateTuning(&tc.t)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Errorf("ValidateTuning = %v, want ok=%v", err, tc.ok)
|
||||
}
|
||||
var ue UserError
|
||||
if err != nil && !errors.As(err, &ue) {
|
||||
t.Errorf("a refusal must be a UserError the command can show: %T", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanWords(t *testing.T) {
|
||||
got, err := CleanWords([]string{" Spam ", "spam", "", "ADS", "ads "})
|
||||
if err != nil || len(got) != 2 || got[0] != "ads" || got[1] != "spam" {
|
||||
t.Errorf("trimmed, lowercased, de-duplicated and sorted: %v, %v", got, err)
|
||||
}
|
||||
if _, err := CleanWords([]string{strings.Repeat("x", 51)}); err == nil {
|
||||
t.Error("an oversized word is refused")
|
||||
}
|
||||
if _, err := CleanWords([]string{"bad\x00word"}); err == nil {
|
||||
t.Error("control characters are refused")
|
||||
}
|
||||
many := make([]string, 51)
|
||||
for i := range many {
|
||||
many[i] = strings.Repeat("a", i+1)
|
||||
}
|
||||
if _, err := CleanWords(many); err == nil {
|
||||
t.Error("more than 50 words is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneNeedsTheOwnerOrTrusted(t *testing.T) {
|
||||
e, d, st, _ := testEngine(t)
|
||||
ctx := context.Background()
|
||||
owner := d.members["1"]
|
||||
admin := d.addMember("50")
|
||||
trustedUser := d.addMember("60")
|
||||
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
|
||||
refresh(e)
|
||||
|
||||
tun := securityrepo.Tuning{RaidJoinCount: ptr(20)}
|
||||
if err := e.Tune(ctx, guild, admin, tun, "x"); !errors.Is(err, ErrNotTrusted) {
|
||||
t.Errorf("an admin who isn't trusted must not tune: %v", err)
|
||||
}
|
||||
if err := e.Tune(ctx, guild, nil, tun, "x"); err == nil {
|
||||
t.Error("no actor, no change")
|
||||
}
|
||||
if st.set(100).RaidJoinCount == 20 {
|
||||
t.Fatal("nothing may be saved for a refused change")
|
||||
}
|
||||
if err := e.Tune(ctx, guild, owner, tun, "raid join count 20"); err != nil {
|
||||
t.Fatalf("the owner may: %v", err)
|
||||
}
|
||||
if st.set(100).RaidJoinCount != 20 {
|
||||
t.Error("the owner's change is saved")
|
||||
}
|
||||
if err := e.Tune(ctx, guild, trustedUser, securityrepo.Tuning{RaidJoinCount: ptr(2)}, "x"); err == nil {
|
||||
t.Error("an invalid value is refused even for the owner or trusted")
|
||||
}
|
||||
if st.set(100).RaidJoinCount != 20 {
|
||||
t.Error("an invalid change must leave the old value")
|
||||
}
|
||||
if len(st.events) == 0 || st.events[len(st.events)-1].Kind != "settings" {
|
||||
t.Errorf("the change is written to the history: %v", st.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditWords(t *testing.T) {
|
||||
e, d, st, _ := testEngine(t)
|
||||
ctx := context.Background()
|
||||
owner := d.members["1"]
|
||||
admin := d.addMember("50")
|
||||
|
||||
if _, err := e.EditWords(ctx, guild, admin, UsernameWords, true, "scam"); !errors.Is(err, ErrNotTrusted) {
|
||||
t.Errorf("untrusted: %v", err)
|
||||
}
|
||||
words, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " SCAM ")
|
||||
if err != nil || len(words) != 1 || words[0] != "scam" {
|
||||
t.Fatalf("add: %v, %v", words, err)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, ChatWords, true, "free nitro"); err != nil {
|
||||
t.Fatalf("the chat list is separate: %v", err)
|
||||
}
|
||||
s := st.set(100)
|
||||
if len(s.UsernamePatterns) != 1 || len(s.HeatBlockedWords) != 1 {
|
||||
t.Errorf("each list keeps its own words: %v %v", s.UsernamePatterns, s.HeatBlockedWords)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, false, "nothing"); err == nil {
|
||||
t.Error("removing a word that isn't there says so")
|
||||
}
|
||||
words, err = e.EditWords(ctx, guild, owner, UsernameWords, false, "scam")
|
||||
if err != nil || len(words) != 0 {
|
||||
t.Errorf("remove: %v, %v", words, err)
|
||||
}
|
||||
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " "); err == nil {
|
||||
t.Error("a blank word is refused")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user