diff --git a/README.md b/README.md index 59b505b..b1a1bcc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ graph TD - **Image-to-ASCII** — because images in a terminal is objectively cool - **Presence tracking** — online/away/DND/invisible with custom status messages - **Rate limiting** — in case someone gets too excited +- **Spam protection** — per-user flood/duplicate limits with auto-mute escalation, covering TUI and IRC alike; configurable under `Spam`, Mods exempt - **Auto-restart** on crash with exponential backoff — it picks itself back up - **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy - **Zero config first run** — generates its own JWT secret and config on launch diff --git a/docs/changelog/v0.2.15.md b/docs/changelog/v0.2.15.md index 5dd8e0b..196325d 100644 --- a/docs/changelog/v0.2.15.md +++ b/docs/changelog/v0.2.15.md @@ -42,6 +42,13 @@ you send from the TUI now reach your own connected IRC client instantly. - **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both. - **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing. - **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before. +- **Spam protection** — per-user message flood and duplicate-message limits, join and + channel-creation throttles, and automatic escalation: enough rejected sends in a few minutes + earns a timed auto-mute through the normal mute system (moderators see it, and it expires on + its own). One guard covers every ingress — TUI and IRC clients hit the same limits. Mods and + above are exempt, everything is configurable under the new `Spam` section + (`Spam:Enabled` master switch, lenient defaults a fast typist won't trip), and the guard only + ever sees stored content — encrypted-room messages stay ciphertext. ## Improvements @@ -62,6 +69,10 @@ you send from the TUI now reach your own connected IRC client instantly. ## Notes for server operators - New config key: `Server:Registration` — `"open"` (default), `"invite"`, or `"closed"`. +- New config section: `Spam` — flood/duplicate/join/create thresholds and auto-mute duration; + see `appsettings.example.json`. On by default with lenient limits; `Spam:Enabled: false` + turns it all off. Note `Spam:MaxJoinsPerWindow` counts only *first-time* channel joins — + keep it above your public channel count so a new member's first connect isn't throttled. - New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`, `DELETE /api/users/me`. - One new database migration (`AddInvitesAndReplies`) applies automatically on startup. diff --git a/src/EchoHub.Server/Config/SpamOptions.cs b/src/EchoHub.Server/Config/SpamOptions.cs new file mode 100644 index 0000000..71d85bc --- /dev/null +++ b/src/EchoHub.Server/Config/SpamOptions.cs @@ -0,0 +1,50 @@ +namespace EchoHub.Server.Config; + +/// +/// Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough +/// that a fast typist never trips them; Mods and above are always exempt. +/// +public sealed class SpamOptions +{ + /// Master switch for all spam protection. + public bool Enabled { get; set; } = true; + + /// Max messages a user may send per window. + public int MaxMessagesPerWindow { get; set; } = 8; + + public int WindowSeconds { get; set; } = 5; + + /// + /// How many identical messages in a row are tolerated; the next one is rejected. + /// (End-to-end encrypted rooms are naturally exempt — identical plaintext produces + /// different ciphertext per message, so repeats never look identical to the server.) + /// + public int MaxDuplicateMessages { get; set; } = 3; + + /// + /// Auto-mute duration once a user accumulates rejected + /// sends within . 0 disables auto-mute (rejections + /// still apply). The mute uses the normal timed-mute machinery, so moderators see it + /// and MuteExpirationService lifts it. + /// + public int AutoMuteMinutes { get; set; } = 5; + + public int ViolationThreshold { get; set; } = 5; + + public int ViolationWindowMinutes { get; set; } = 5; + + /// + /// Max *first-time* channel joins per window. Joins of + /// channels the user already belongs to (reconnect/auto-join) never count, but a brand-new + /// user's first connect joins every public channel at once — keep this above your public + /// channel count. + /// + public int MaxJoinsPerWindow { get; set; } = 25; + + public int JoinWindowSeconds { get; set; } = 30; + + /// Max channel creations per window. + public int MaxChannelCreatesPerWindow { get; set; } = 3; + + public int ChannelCreateWindowMinutes { get; set; } = 10; +} diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 9fb5882..2a0fbe8 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -106,6 +106,11 @@ while (true) // ── Upload limits (admin-configurable via the "Uploads" section) ───── var uploadLimits = builder.Configuration.GetSection("Uploads").Get() ?? new UploadLimits(); builder.Services.AddSingleton(uploadLimits); + + // ── Spam protection (admin-configurable via the "Spam" section) ────── + var spamOptions = builder.Configuration.GetSection("Spam").Get() ?? new SpamOptions(); + builder.Services.AddSingleton(spamOptions); + builder.Services.AddSingleton(); // Raise the multipart form ceiling to match the configured limits; per-endpoint // request-body limits are applied at the action from the same values. builder.Services.Configure(o => diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index d8bd00d..3fb48b5 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -13,15 +13,18 @@ public class ChannelService : IChannelService { private readonly IServiceScopeFactory _scopeFactory; private readonly PresenceTracker _presenceTracker; + private readonly SpamGuard _spamGuard; private readonly ILogger _logger; public ChannelService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, + SpamGuard spamGuard, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; + _spamGuard = spamGuard; _logger = logger; } @@ -74,6 +77,16 @@ public class ChannelService : IChannelService using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + // Channel-creation throttle (spam guard; Mods and above are exempt) + if (_spamGuard.Enabled) + { + var creator = await db.Users.FindAsync(creatorUserId); + var verdict = _spamGuard.CheckChannelCreate(creatorUserId, creator?.Role ?? ServerRole.Member); + if (verdict.Kind != SpamVerdictKind.Allowed) + return ChannelOperationResult.Fail(ChannelError.ValidationFailed, + verdict.Reason ?? "You're creating channels too fast."); + } + if (await db.Channels.AnyAsync(c => c.Name == channelName)) return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists."); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 52253fb..8e9985d 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -20,6 +20,7 @@ public class ChatService : IChatService private readonly IMessageEncryptionService _encryption; private readonly IChannelService _channelService; private readonly FileStorageService _fileStorage; + private readonly SpamGuard _spamGuard; private readonly ILogger _logger; public ChatService( @@ -30,6 +31,7 @@ public class ChatService : IChatService IMessageEncryptionService encryption, IChannelService channelService, FileStorageService fileStorage, + SpamGuard spamGuard, ILogger logger) { _scopeFactory = scopeFactory; @@ -39,6 +41,7 @@ public class ChatService : IChatService _encryption = encryption; _channelService = channelService; _fileStorage = fileStorage; + _spamGuard = spamGuard; _logger = logger; } @@ -102,6 +105,27 @@ public class ChatService : IChatService { channelName = channelName.ToLowerInvariant().Trim(); + // Join throttle — stops channel-cycling spam from any protocol. Only *first-time* + // joins (no membership yet) count: the client auto-joins every known channel on + // connect/reconnect, and that burst must never trip the guard. + if (_spamGuard.Enabled) + { + using var guardScope = _scopeFactory.CreateScope(); + var guardDb = guardScope.ServiceProvider.GetRequiredService(); + + var isExistingMember = await guardDb.Channels + .Where(c => c.Name == channelName) + .AnyAsync(c => guardDb.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId)); + + if (!isExistingMember) + { + var joiner = await guardDb.Users.FindAsync(userId); + var joinVerdict = _spamGuard.CheckJoin(userId, joiner?.Role ?? ServerRole.Member); + if (joinVerdict.Kind != SpamVerdictKind.Allowed) + return ([], joinVerdict.Reason, false); + } + } + // Delegate channel validation + membership (incl. password gate) to ChannelService var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password); if (!success) @@ -199,6 +223,27 @@ public class ChatService : IChatService } } + // Spam guard — operates on the stored content (ciphertext for E2E rooms), never decrypts + if (sender is not null) + { + var verdict = _spamGuard.CheckMessage(userId, sender.Role, plaintext); + switch (verdict.Kind) + { + case SpamVerdictKind.AutoMute: + // Escalate through the normal timed-mute machinery: moderation endpoints + // list it and MuteExpirationService lifts it. Issued by the server itself. + sender.IsMuted = true; + sender.MutedUntil = DateTimeOffset.UtcNow.Add(verdict.MuteDuration); + await db.SaveChangesAsync(); + _logger.LogWarning("Spam protection auto-muted {User} for {Minutes} minutes in '{Channel}'", + username, (int)verdict.MuteDuration.TotalMinutes, channelName); + return $"You have been automatically muted for {(int)verdict.MuteDuration.TotalMinutes} minutes (spam protection)."; + + case SpamVerdictKind.Rejected: + return verdict.Reason; + } + } + // Replies must target an existing message in the same channel Message? replyTarget = null; if (replyToMessageId is { } replyId) diff --git a/src/EchoHub.Server/Services/SpamGuard.cs b/src/EchoHub.Server/Services/SpamGuard.cs new file mode 100644 index 0000000..3dc5ff9 --- /dev/null +++ b/src/EchoHub.Server/Services/SpamGuard.cs @@ -0,0 +1,187 @@ +using EchoHub.Core.Models; +using EchoHub.Server.Config; + +namespace EchoHub.Server.Services; + +public enum SpamVerdictKind +{ + Allowed, + Rejected, + + /// The user crossed the violation threshold — the caller should apply a timed mute. + AutoMute, +} + +public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default) +{ + public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed); +} + +/// +/// In-memory, per-user spam protection consulted from (messages, +/// joins) and (channel creation), so every ingress protocol — +/// SignalR and IRC alike — shares the same limits. State is per-process and never persisted; +/// auto-mutes land in the normal mute store. Operates only on content the server already +/// stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted. +/// +public sealed class SpamGuard +{ + private readonly SpamOptions _options; + private readonly Lock _lock = new(); + private readonly Dictionary _users = []; + + // Lazy stale-state pruning so the dictionary can't grow unbounded on busy servers + private const int PruneThreshold = 512; + private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30); + + public SpamGuard(SpamOptions options) + { + _options = options; + } + + public bool Enabled => _options.Enabled; + + private sealed class UserState + { + public readonly Queue MessageTimes = new(); + public readonly Queue JoinTimes = new(); + public readonly Queue CreateTimes = new(); + public readonly Queue Violations = new(); + public string? LastContent; + public int RepeatCount; + public DateTimeOffset LastSeen; + } + + /// + /// Checks a message send. Rejections count as violations; enough violations inside the + /// violation window escalate to (evaluated only on + /// rejected messages, so a clean message never mutes anyone). + /// + public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null) + { + if (!_options.Enabled || role >= ServerRole.Mod) + return SpamVerdict.Allowed; + + var now = nowOverride ?? DateTimeOffset.UtcNow; + + lock (_lock) + { + var state = GetState(userId, now); + + // Flood: attempts count too — a client hammering retries must actually pause + Prune(state.MessageTimes, now - TimeSpan.FromSeconds(_options.WindowSeconds)); + state.MessageTimes.Enqueue(now); + if (state.MessageTimes.Count > _options.MaxMessagesPerWindow) + return Reject(state, now, "You're sending messages too fast — slow down."); + + // Duplicates: identical (trimmed, case-insensitive) content repeated back-to-back + var normalized = content.Trim().ToLowerInvariant(); + if (normalized == state.LastContent) + { + if (state.RepeatCount >= _options.MaxDuplicateMessages) + return Reject(state, now, "Duplicate message — say something new."); + state.RepeatCount++; + } + else + { + state.LastContent = normalized; + state.RepeatCount = 1; + } + + return SpamVerdict.Allowed; + } + } + + /// Checks a channel join. Rejections count as violations but never auto-mute. + public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null) + { + return CheckWindow(userId, role, nowOverride, + s => s.JoinTimes, + TimeSpan.FromSeconds(_options.JoinWindowSeconds), + _options.MaxJoinsPerWindow, + "You're joining channels too fast — slow down."); + } + + /// Checks a channel creation. Rejections count as violations but never auto-mute. + public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null) + { + return CheckWindow(userId, role, nowOverride, + s => s.CreateTimes, + TimeSpan.FromMinutes(_options.ChannelCreateWindowMinutes), + _options.MaxChannelCreatesPerWindow, + "You're creating channels too fast — try again later."); + } + + private SpamVerdict CheckWindow(Guid userId, ServerRole role, DateTimeOffset? nowOverride, + Func> queueSelector, TimeSpan window, int max, string reason) + { + if (!_options.Enabled || role >= ServerRole.Mod) + return SpamVerdict.Allowed; + + var now = nowOverride ?? DateTimeOffset.UtcNow; + + lock (_lock) + { + var state = GetState(userId, now); + var queue = queueSelector(state); + + Prune(queue, now - window); + queue.Enqueue(now); + if (queue.Count > max) + { + RecordViolation(state, now); + return new SpamVerdict(SpamVerdictKind.Rejected, reason); + } + + return SpamVerdict.Allowed; + } + } + + /// + /// Records a violation and decides between plain rejection and auto-mute escalation. + /// On escalation the violation history is cleared so the user starts fresh post-mute. + /// + private SpamVerdict Reject(UserState state, DateTimeOffset now, string reason) + { + RecordViolation(state, now); + + if (_options.AutoMuteMinutes > 0 && state.Violations.Count >= _options.ViolationThreshold) + { + state.Violations.Clear(); + return new SpamVerdict(SpamVerdictKind.AutoMute, reason, + TimeSpan.FromMinutes(_options.AutoMuteMinutes)); + } + + return new SpamVerdict(SpamVerdictKind.Rejected, reason); + } + + private void RecordViolation(UserState state, DateTimeOffset now) + { + Prune(state.Violations, now - TimeSpan.FromMinutes(_options.ViolationWindowMinutes)); + state.Violations.Enqueue(now); + } + + private UserState GetState(Guid userId, DateTimeOffset now) + { + if (_users.Count > PruneThreshold) + { + foreach (var stale in _users.Where(kv => now - kv.Value.LastSeen > StaleAfter).Select(kv => kv.Key).ToList()) + _users.Remove(stale); + } + + if (!_users.TryGetValue(userId, out var state)) + { + state = new UserState(); + _users[userId] = state; + } + + state.LastSeen = now; + return state; + } + + private static void Prune(Queue queue, DateTimeOffset cutoff) + { + while (queue.Count > 0 && queue.Peek() < cutoff) + queue.Dequeue(); + } +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index b3408c7..7c554d1 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -28,6 +28,19 @@ "MaxAvatarSizeMB": 2, "MaxAttachmentsPerMessage": 10 }, + "Spam": { + "Enabled": true, + "MaxMessagesPerWindow": 8, + "WindowSeconds": 5, + "MaxDuplicateMessages": 3, + "AutoMuteMinutes": 5, + "ViolationThreshold": 5, + "ViolationWindowMinutes": 5, + "MaxJoinsPerWindow": 25, + "JoinWindowSeconds": 30, + "MaxChannelCreatesPerWindow": 3, + "ChannelCreateWindowMinutes": 10 + }, "Encryption": { "Key": "", "EncryptDatabase": false diff --git a/src/EchoHub.Tests/SpamGuardTests.cs b/src/EchoHub.Tests/SpamGuardTests.cs new file mode 100644 index 0000000..e5ad687 --- /dev/null +++ b/src/EchoHub.Tests/SpamGuardTests.cs @@ -0,0 +1,231 @@ +using EchoHub.Core.Models; +using EchoHub.Server.Config; +using EchoHub.Server.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class SpamGuardTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly Guid Alice = Guid.NewGuid(); + + private static SpamGuard CreateGuard(Action? configure = null) + { + var options = new SpamOptions(); + configure?.Invoke(options); + return new SpamGuard(options); + } + + // ── Message flood ───────────────────────────────────────────────── + + [Fact] + public void Flood_UnderLimit_Allowed() + { + var guard = CreateGuard(); + + for (var i = 0; i < 8; i++) + { + var verdict = guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100)); + Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind); + } + } + + [Fact] + public void Flood_OverLimit_Rejected() + { + var guard = CreateGuard(); + + for (var i = 0; i < 8; i++) + guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100)); + + var verdict = guard.CheckMessage(Alice, ServerRole.Member, "msg 9", T0.AddSeconds(1)); + + Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind); + Assert.Contains("too fast", verdict.Reason); + } + + [Fact] + public void Flood_RecoversAfterWindow() + { + var guard = CreateGuard(); + + for (var i = 0; i < 9; i++) + guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100)); + + // The whole window has passed with no attempts — allowed again + var verdict = guard.CheckMessage(Alice, ServerRole.Member, "later", T0.AddSeconds(10)); + + Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind); + } + + [Fact] + public void Flood_IsPerUser() + { + var guard = CreateGuard(); + var bob = Guid.NewGuid(); + + for (var i = 0; i < 9; i++) + guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100)); + + var verdict = guard.CheckMessage(bob, ServerRole.Member, "hello", T0.AddSeconds(1)); + + Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind); + } + + // ── Duplicates ──────────────────────────────────────────────────── + + [Fact] + public void Duplicates_UpToLimit_Allowed_ThenRejected() + { + // Slow enough that the flood window never trips (one message per 10 s) + var guard = CreateGuard(); + + for (var i = 0; i < 3; i++) + { + var v = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(i * 10)); + Assert.Equal(SpamVerdictKind.Allowed, v.Kind); + } + + var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(30)); + + Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind); + Assert.Contains("Duplicate", verdict.Reason); + } + + [Fact] + public void Duplicates_CaseAndWhitespaceInsensitive() + { + var guard = CreateGuard(o => o.MaxDuplicateMessages = 1); + + guard.CheckMessage(Alice, ServerRole.Member, "Hello World", T0); + var verdict = guard.CheckMessage(Alice, ServerRole.Member, " hello world ", T0.AddSeconds(10)); + + Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind); + } + + [Fact] + public void Duplicates_ResetByDifferentMessage() + { + var guard = CreateGuard(o => o.MaxDuplicateMessages = 1); + + guard.CheckMessage(Alice, ServerRole.Member, "same", T0); + guard.CheckMessage(Alice, ServerRole.Member, "different", T0.AddSeconds(10)); + var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddSeconds(20)); + + Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind); + } + + // ── Escalation → auto-mute ──────────────────────────────────────── + + [Fact] + public void Escalation_EnoughViolations_ReturnsAutoMute() + { + var guard = CreateGuard(o => o.ViolationThreshold = 3); + SpamVerdict verdict = default; + + // Keep hammering inside the flood window: 8 allowed, then rejections accumulate + for (var i = 0; i < 8 + 3; i++) + verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50)); + + Assert.Equal(SpamVerdictKind.AutoMute, verdict.Kind); + Assert.Equal(TimeSpan.FromMinutes(5), verdict.MuteDuration); + } + + [Fact] + public void Escalation_ClearsViolations_NextRejectionIsPlain() + { + var guard = CreateGuard(o => o.ViolationThreshold = 3); + + for (var i = 0; i < 8 + 3; i++) + guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50)); + + // Still inside the flood window — rejected, but the counter restarted + var verdict = guard.CheckMessage(Alice, ServerRole.Member, "again", T0.AddSeconds(2)); + + Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind); + } + + [Fact] + public void Escalation_AutoMuteDisabled_StaysRejected() + { + var guard = CreateGuard(o => { o.ViolationThreshold = 3; o.AutoMuteMinutes = 0; }); + SpamVerdict verdict = default; + + for (var i = 0; i < 8 + 10; i++) + verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50)); + + Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind); + } + + // ── Exemptions / master switch ──────────────────────────────────── + + [Theory] + [InlineData(ServerRole.Mod)] + [InlineData(ServerRole.Admin)] + [InlineData(ServerRole.Owner)] + public void ModAndAbove_AlwaysAllowed(ServerRole role) + { + var guard = CreateGuard(); + + for (var i = 0; i < 50; i++) + { + var verdict = guard.CheckMessage(Alice, role, "same spam", T0.AddMilliseconds(i * 10)); + Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind); + } + } + + [Fact] + public void Disabled_EverythingAllowed() + { + var guard = CreateGuard(o => o.Enabled = false); + + for (var i = 0; i < 50; i++) + Assert.Equal(SpamVerdictKind.Allowed, + guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddMilliseconds(i * 10)).Kind); + + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0).Kind); + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0).Kind); + Assert.False(guard.Enabled); + } + + // ── Join throttle ───────────────────────────────────────────────── + + [Fact] + public void Joins_OverLimit_Rejected_ThenRecovers() + { + var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 5; o.JoinWindowSeconds = 30; }); + + for (var i = 0; i < 5; i++) + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind); + + Assert.Equal(SpamVerdictKind.Rejected, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(6)).Kind); + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(60)).Kind); + } + + // ── Channel-create throttle ─────────────────────────────────────── + + [Fact] + public void ChannelCreates_OverLimit_Rejected_ThenRecovers() + { + var guard = CreateGuard(); + + for (var i = 0; i < 3; i++) + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind); + + Assert.Equal(SpamVerdictKind.Rejected, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(10)).Kind); + Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddMinutes(15)).Kind); + } + + [Fact] + public void JoinAndCreateRejections_NeverAutoMute() + { + var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 1; o.ViolationThreshold = 2; }); + + for (var i = 0; i < 20; i++) + { + var verdict = guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i)); + Assert.NotEqual(SpamVerdictKind.AutoMute, verdict.Kind); + } + } +}