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") } }