From 95d965acbdf9d4ebf6eb91a465dfbc63bc00c1c4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:13:07 +0200 Subject: [PATCH 1/9] feat: add DatabaseMaintenanceService (clear all rows, keep schema) --- .../Maintenance/DatabaseMaintenanceService.cs | 51 +++++++++++++++++++ .../IDatabaseMaintenanceService.cs | 10 ++++ .../PersistenceServiceCollectionExtensions.cs | 2 + .../DatabaseMaintenanceServiceTests.cs | 49 ++++++++++++++++++ 4 files changed, 112 insertions(+) create mode 100644 src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs create mode 100644 src/RustPlusBot.Persistence/Maintenance/IDatabaseMaintenanceService.cs create mode 100644 tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs diff --git a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs new file mode 100644 index 0000000..f5f9486 --- /dev/null +++ b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using Microsoft.EntityFrameworkCore; + +namespace RustPlusBot.Persistence.Maintenance; + +/// Clears every table's rows while keeping the schema (a live-safe "factory reset"). +/// The bot database context. +public sealed class DatabaseMaintenanceService(BotDbContext context) : IDatabaseMaintenanceService +{ + /// + public async Task ClearAllAsync(CancellationToken cancellationToken = default) + { + var tables = context.Model.GetEntityTypes() + .Select(t => t.GetTableName()) + .Where(name => !string.IsNullOrEmpty(name)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // Keep one connection open across every statement so the FK pragma persists + // (with per-statement connections the pragma would reset before the DELETEs). + await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + try + { + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", cancellationToken) + .ConfigureAwait(false); + + foreach (var table in tables) + { + // Table names come from the EF model (never user input); the identifier guard keeps + // the raw statement demonstrably injection-safe for the Sonar gate. + if (!IsSafeIdentifier(table!)) + { + continue; + } + + var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\""); + await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false); + } + + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", cancellationToken) + .ConfigureAwait(false); + } + finally + { + await context.Database.CloseConnectionAsync().ConfigureAwait(false); + } + } + + private static bool IsSafeIdentifier(string identifier) => + identifier.All(c => char.IsLetterOrDigit(c) || c == '_'); +} diff --git a/src/RustPlusBot.Persistence/Maintenance/IDatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/IDatabaseMaintenanceService.cs new file mode 100644 index 0000000..8e29770 --- /dev/null +++ b/src/RustPlusBot.Persistence/Maintenance/IDatabaseMaintenanceService.cs @@ -0,0 +1,10 @@ +namespace RustPlusBot.Persistence.Maintenance; + +/// Database-wide maintenance operations. +public interface IDatabaseMaintenanceService +{ + /// Deletes every row in every table across all guilds, preserving the schema. + /// A cancellation token. + /// A task that completes when all rows have been deleted. + Task ClearAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 614a4d6..1f39ee1 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using RustPlusBot.Persistence.Commands; using RustPlusBot.Persistence.Connections; using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Maintenance; using RustPlusBot.Persistence.Map; using RustPlusBot.Persistence.Servers; using RustPlusBot.Persistence.StorageMonitors; @@ -34,6 +35,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi sp.GetRequiredService>().CreateDbContext()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs new file mode 100644 index 0000000..383630c --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Guilds; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Persistence.Maintenance; + +namespace RustPlusBot.Persistence.Tests.Maintenance; + +public sealed class DatabaseMaintenanceServiceTests +{ + [Fact] + public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + context.RustServers.Add(new RustServer + { + GuildId = 1, Name = "A", Ip = "a", Port = 1 + }); + context.RustServers.Add(new RustServer + { + GuildId = 2, Name = "B", Ip = "b", Port = 2 + }); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 1, Culture = "en" + }); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 2, Culture = "fr" + }); + await context.SaveChangesAsync(); + + var service = new DatabaseMaintenanceService(context); + await service.ClearAllAsync(); + + Assert.Empty(await context.RustServers.ToListAsync()); + Assert.Empty(await context.GuildSettings.ToListAsync()); + + // Schema still exists: a fresh insert succeeds. + context.GuildSettings.Add(new GuildSettings + { + GuildId = 3, Culture = "en" + }); + await context.SaveChangesAsync(); + Assert.Single(await context.GuildSettings.ToListAsync()); + } +} From ce0e14af491688e3cc2555cb10d78e501fbfe87b Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:23:53 +0200 Subject: [PATCH 2/9] test: exercise FK-cascade path in DatabaseMaintenanceService wipe test --- .../Maintenance/DatabaseMaintenanceService.cs | 2 ++ .../Maintenance/DatabaseMaintenanceServiceTests.cs | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs index f5f9486..9a93aea 100644 --- a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs +++ b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs @@ -26,6 +26,8 @@ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", cancellat foreach (var table in tables) { + // Deletion order is deliberately irrelevant: PRAGMA foreign_keys = OFF (above) suspends + // FK enforcement for the wipe, so do not "fix" this by adding a topological sort. // Table names come from the EF model (never user input); the identifier guard keeps // the raw statement demonstrably injection-safe for the Sonar gate. if (!IsSafeIdentifier(table!)) diff --git a/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs index 383630c..7567503 100644 --- a/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Maintenance/DatabaseMaintenanceServiceTests.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Switches; using RustPlusBot.Persistence.Maintenance; namespace RustPlusBot.Persistence.Tests.Maintenance; @@ -14,10 +15,11 @@ public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema() await using var _ = context; await using var __ = connection; - context.RustServers.Add(new RustServer + var serverA = new RustServer { GuildId = 1, Name = "A", Ip = "a", Port = 1 - }); + }; + context.RustServers.Add(serverA); context.RustServers.Add(new RustServer { GuildId = 2, Name = "B", Ip = "b", Port = 2 @@ -30,6 +32,10 @@ public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema() { GuildId = 2, Culture = "fr" }); + context.SmartSwitches.Add(new SmartSwitch + { + GuildId = 1, ServerId = serverA.Id, EntityId = 10, Name = "sw" + }); await context.SaveChangesAsync(); var service = new DatabaseMaintenanceService(context); @@ -37,6 +43,7 @@ public async Task ClearAllAsync_EmptiesEveryTable_AndKeepsSchema() Assert.Empty(await context.RustServers.ToListAsync()); Assert.Empty(await context.GuildSettings.ToListAsync()); + Assert.Empty(await context.SmartSwitches.ToListAsync()); // Schema still exists: a fresh insert succeeds. context.GuildSettings.Add(new GuildSettings From 759614a1a57ded1655418a1c7dae0674281e1a79 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:31:58 +0200 Subject: [PATCH 3/9] feat: add GuildPurgeService (per-guild data purge) --- .../Teardown/GuildPurgeService.cs | 38 ++++++++ .../Teardown/IGuildPurgeService.cs | 11 +++ .../WorkspaceServiceCollectionExtensions.cs | 1 + .../Teardown/GuildPurgeServiceTests.cs | 91 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs create mode 100644 src/RustPlusBot.Features.Workspace/Teardown/IGuildPurgeService.cs create mode 100644 tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs new file mode 100644 index 0000000..3f832a6 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Purges a guild: tears down provisioned channels, then deletes its domain rows. +/// The bot database context. +/// Server management (RemoveAsync cascades all per-server rows). +/// Removes provisioned Discord channels/categories/messages. +internal sealed class GuildPurgeService( + BotDbContext context, + IServerService servers, + IWorkspaceTeardownService teardown) : IGuildPurgeService +{ + /// + public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default) + { + // 1) Delete provisioned Discord channels/categories/messages (Discord side + records). + await teardown.ResetGuildAsync(guildId, cancellationToken).ConfigureAwait(false); + + // 2) Remove each server; the RustServer FK cascade clears its per-server rows + // (connection state, command/map settings, switches, alarms, storage monitors, credentials). + var known = await servers.ListAsync(guildId, cancellationToken).ConfigureAwait(false); + foreach (var server in known) + { + await servers.RemoveAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false); + } + + // 3) Delete guild-keyed rows that have no cascade FK to RustServer. + await context.EventSubscriptions.Where(e => e.GuildId == guildId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.PairedEntities.Where(p => p.GuildId == guildId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.GuildSettings.Where(g => g.GuildId == guildId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/RustPlusBot.Features.Workspace/Teardown/IGuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/IGuildPurgeService.cs new file mode 100644 index 0000000..978a924 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/Teardown/IGuildPurgeService.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Workspace.Teardown; + +/// Deletes all of a guild's data: provisioned channels plus its domain rows. +internal interface IGuildPurgeService +{ + /// Purges one guild back to a just-joined state (channels + servers + guild-scoped rows). + /// The guild to purge. + /// A cancellation token. + /// A task that completes when the guild's data has been removed. + Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs index c52bb29..bfaa09e 100644 --- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services) services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); // Options (Host binds the "Workspace" section; default = danger commands off). services.AddOptions(); diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs new file mode 100644 index 0000000..c7a4422 --- /dev/null +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Entities; +using RustPlusBot.Domain.Events; +using RustPlusBot.Domain.Guilds; +using RustPlusBot.Domain.Servers; +using RustPlusBot.Domain.Switches; +using RustPlusBot.Features.Workspace.Teardown; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Workspace.Tests.Teardown; + +public sealed class GuildPurgeServiceTests +{ + private static BotDbContext NewContext(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var context = new BotDbContext(options); + context.Database.Migrate(); + return context; + } + + [Fact] + public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() + { + var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var _ = connection; + await using var context = NewContext(connection); + + var serverA = new RustServer + { + GuildId = 1, Name = "A", Ip = "a", Port = 1 + }; + var serverB = new RustServer + { + GuildId = 2, Name = "B", Ip = "b", Port = 2 + }; + context.RustServers.AddRange(serverA, serverB); + context.SmartSwitches.Add(new SmartSwitch + { + GuildId = 1, ServerId = serverA.Id, EntityId = 10, Name = "sw" + }); + context.ConnectionStates.Add(new ConnectionState + { + RustServerId = serverA.Id, GuildId = 1, Status = ConnectionStatus.Connected + }); + context.EventSubscriptions.Add(new EventSubscription + { + GuildId = 1, RustServerId = serverA.Id, EventKey = "cargo" + }); + context.EventSubscriptions.Add(new EventSubscription + { + GuildId = 2, RustServerId = serverB.Id, EventKey = "cargo" + }); + context.PairedEntities.Add(new PairedEntity + { + GuildId = 1, RustServerId = serverA.Id, EntityId = 5, Name = "dev" + }); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 1, Culture = "en" + }); + context.GuildSettings.Add(new GuildSettings + { + GuildId = 2, Culture = "fr" + }); + await context.SaveChangesAsync(); + + var teardown = Substitute.For(); + var service = new GuildPurgeService(context, new ServerService(context), teardown); + + await service.PurgeGuildAsync(1); + + await teardown.Received(1).ResetGuildAsync(1, Arg.Any()); + Assert.Empty(await context.RustServers.Where(s => s.GuildId == 1).ToListAsync()); + Assert.Empty(await context.SmartSwitches.ToListAsync()); + Assert.Empty(await context.ConnectionStates.ToListAsync()); + Assert.Empty(await context.EventSubscriptions.Where(e => e.GuildId == 1).ToListAsync()); + Assert.Empty(await context.PairedEntities.Where(p => p.GuildId == 1).ToListAsync()); + Assert.Empty(await context.GuildSettings.Where(g => g.GuildId == 1).ToListAsync()); + + // Guild 2 untouched. + Assert.Single(await context.RustServers.Where(s => s.GuildId == 2).ToListAsync()); + Assert.Single(await context.EventSubscriptions.Where(e => e.GuildId == 2).ToListAsync()); + Assert.Single(await context.GuildSettings.Where(g => g.GuildId == 2).ToListAsync()); + } +} From 7e6680aace511b897bc465f86883315070f7815f Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:40:54 +0200 Subject: [PATCH 4/9] fix: purge FcmRegistrations in GuildPurgeService (guild-keyed credentials) --- .../Teardown/GuildPurgeService.cs | 5 ++++- .../Teardown/GuildPurgeServiceTests.cs | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs index 3f832a6..ea87ffe 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs @@ -27,12 +27,15 @@ public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationT await servers.RemoveAsync(guildId, server.Id, cancellationToken).ConfigureAwait(false); } - // 3) Delete guild-keyed rows that have no cascade FK to RustServer. + // 3) Delete guild-keyed rows that have no cascade FK to RustServer (event subscriptions, + // paired entities, guild settings, FCM registrations). await context.EventSubscriptions.Where(e => e.GuildId == guildId) .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.PairedEntities.Where(p => p.GuildId == guildId) .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); await context.GuildSettings.Where(g => g.GuildId == guildId) .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await context.FcmRegistrations.Where(f => f.GuildId == guildId) + .ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs index c7a4422..b8a9194 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using NSubstitute; using RustPlusBot.Domain.Connections; +using RustPlusBot.Domain.Credentials; using RustPlusBot.Domain.Entities; using RustPlusBot.Domain.Events; using RustPlusBot.Domain.Guilds; @@ -68,6 +69,14 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() { GuildId = 2, Culture = "fr" }); + context.FcmRegistrations.Add(new FcmRegistration + { + GuildId = 1, OwnerUserId = 100, ProtectedFcmCredentials = "x" + }); + context.FcmRegistrations.Add(new FcmRegistration + { + GuildId = 2, OwnerUserId = 200, ProtectedFcmCredentials = "y" + }); await context.SaveChangesAsync(); var teardown = Substitute.For(); @@ -82,10 +91,12 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() Assert.Empty(await context.EventSubscriptions.Where(e => e.GuildId == 1).ToListAsync()); Assert.Empty(await context.PairedEntities.Where(p => p.GuildId == 1).ToListAsync()); Assert.Empty(await context.GuildSettings.Where(g => g.GuildId == 1).ToListAsync()); + Assert.Empty(await context.FcmRegistrations.Where(f => f.GuildId == 1).ToListAsync()); // Guild 2 untouched. Assert.Single(await context.RustServers.Where(s => s.GuildId == 2).ToListAsync()); Assert.Single(await context.EventSubscriptions.Where(e => e.GuildId == 2).ToListAsync()); Assert.Single(await context.GuildSettings.Where(g => g.GuildId == 2).ToListAsync()); + Assert.Single(await context.FcmRegistrations.Where(f => f.GuildId == 2).ToListAsync()); } } From 9f74d73f918adc9954bb42a4d6c0dc1761a120dc Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:49:27 +0200 Subject: [PATCH 5/9] feat: add /ping and /status diagnostics commands --- .../Modules/DiagnosticsModule.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs diff --git a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs new file mode 100644 index 0000000..b9af3ce --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs @@ -0,0 +1,74 @@ +using System.Diagnostics; +using System.Globalization; +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Commands.Formatting; +using RustPlusBot.Features.Commands.Hosting; +using RustPlusBot.Persistence.Connections; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Commands.Modules; + +/// Read-only diagnostics: /ping and /status. +/// Creates a short-lived DI scope per interaction. +public sealed class DiagnosticsModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Reports Discord gateway latency and the REST ack round-trip. + [SlashCommand("ping", "Show the bot's Discord latency")] + public async Task PingAsync() + { + var stopwatch = Stopwatch.StartNew(); + await DeferAsync(ephemeral: true).ConfigureAwait(false); + stopwatch.Stop(); + + var text = string.Create(CultureInfo.InvariantCulture, + $"Pong! Gateway: {Context.Client.Latency} ms · Response: {stopwatch.ElapsedMilliseconds} ms"); + await FollowupAsync(text, ephemeral: true).ConfigureAwait(false); + } + + /// Shows uptime, latency, per-server connection status, and counts. + [SlashCommand("status", "Show bot health and connection status")] + public async Task StatusAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var uptime = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + var connections = scope.ServiceProvider.GetRequiredService(); + + var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + + var embed = new EmbedBuilder() + .WithTitle("Bot status") + .AddField("Uptime", DurationFormat.Compact(uptime.Elapsed), inline: true) + .AddField("Gateway latency", + string.Create(CultureInfo.InvariantCulture, $"{Context.Client.Latency} ms"), inline: true) + .AddField("Guilds", + Context.Client.Guilds.Count.ToString(CultureInfo.InvariantCulture), inline: true) + .AddField("Servers (this guild)", + known.Count.ToString(CultureInfo.InvariantCulture), inline: true); + + foreach (var server in known) + { + var state = await connections.GetStateAsync(Context.Guild.Id, server.Id).ConfigureAwait(false); + var line = state is null + ? "unknown" + : string.Create(CultureInfo.InvariantCulture, + $"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players"); + embed.AddField(server.Name, line); + } + + await FollowupAsync(ephemeral: true, embed: embed.Build()).ConfigureAwait(false); + } + } +} From 7d5a4435e34fe1b245c37fad1263224830924827 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 18:58:21 +0200 Subject: [PATCH 6/9] feat: add /admin reset-database command --- .../CommandServiceCollectionExtensions.cs | 1 + .../MaintenanceOptions.cs | 11 ++++ .../Modules/MaintenanceModule.cs | 58 +++++++++++++++++++ src/RustPlusBot.Host/Program.cs | 2 + 4 files changed, 72 insertions(+) create mode 100644 src/RustPlusBot.Features.Commands/MaintenanceOptions.cs create mode 100644 src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs diff --git a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs index ddb38db..1755a4c 100644 --- a/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs @@ -23,6 +23,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) services.AddSingleton(); services.AddSingleton(); + services.AddOptions(); services.AddRustPlusBotLocalization(); services.AddItemData(); diff --git a/src/RustPlusBot.Features.Commands/MaintenanceOptions.cs b/src/RustPlusBot.Features.Commands/MaintenanceOptions.cs new file mode 100644 index 0000000..2a3b145 --- /dev/null +++ b/src/RustPlusBot.Features.Commands/MaintenanceOptions.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Commands; + +/// +/// Gates the dangerous maintenance command (/admin reset-database). Bound to the same "Workspace" +/// config section as WorkspaceOptions, so a single EnableDangerCommands flag governs all danger commands. +/// +public sealed class MaintenanceOptions +{ + /// Enables the dangerous /admin reset-database command. + public bool EnableDangerCommands { get; set; } +} diff --git a/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs b/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs new file mode 100644 index 0000000..827e4ac --- /dev/null +++ b/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs @@ -0,0 +1,58 @@ +using Discord; +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using RustPlusBot.Persistence.Maintenance; + +namespace RustPlusBot.Features.Commands.Modules; + +/// Dangerous, danger-gated bot maintenance commands. +/// Creates a short-lived DI scope per interaction. +/// Gates the command behind the danger flag. +[Group("admin", "Bot maintenance (dangerous)")] +[RequireUserPermission(GuildPermission.ManageGuild)] +public sealed class MaintenanceModule( + IServiceScopeFactory scopeFactory, + IOptions options) : InteractionModuleBase +{ + /// Wipes all bot data across all guilds after a typed confirmation. + /// Must equal the literal "RESET" to proceed. + [SlashCommand("reset-database", "Wipe ALL bot data across ALL servers (dangerous)")] + public async Task ResetDatabaseAsync( + [Summary("confirm", "Type RESET to confirm")] + string confirm) + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!options.Value.EnableDangerCommands) + { + await RespondAsync("Developer commands are disabled.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (!string.Equals(confirm, "RESET", StringComparison.Ordinal)) + { + await RespondAsync("Type `RESET` exactly to confirm the database wipe.", ephemeral: true) + .ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var maintenance = scope.ServiceProvider.GetRequiredService(); + await maintenance.ClearAllAsync().ConfigureAwait(false); + await FollowupAsync("Database cleared. **Restart the bot** for a clean state.", ephemeral: true) + .ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 172b2ff..8413f56 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -71,6 +71,8 @@ .Validate(static o => o.Cooldown > TimeSpan.Zero, "Commands:Cooldown must be positive.") .ValidateOnStart(); builder.Services.AddCommands(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Workspace")); builder.Services.AddEvents(); builder.Services.AddPlayers(); builder.Services.AddOptions() From e3e7a70fe394ed266516c24bd5cdd3ce2c4fa017 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 19:07:16 +0200 Subject: [PATCH 7/9] feat: add /workspace repair, rebuild, and purge commands --- .../Modules/WorkspaceAdminModule.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs index dcabb60..bb2e079 100644 --- a/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs +++ b/src/RustPlusBot.Features.Workspace/Modules/WorkspaceAdminModule.cs @@ -23,6 +23,12 @@ public sealed class WorkspaceAdminModule( /// Custom id for the reset confirmation button. public const string ConfirmResetId = "workspace:reset:confirm"; + /// Custom id for the rebuild confirmation button. + public const string ConfirmRebuildId = "workspace:rebuild:confirm"; + + /// Custom id for the purge confirmation button. + public const string ConfirmPurgeId = "workspace:purge:confirm"; + /// Prompts to delete the entire provisioned workspace (dev-gated). [SlashCommand("reset", "Delete ALL of the bot's channels and records in this Discord server (dangerous)")] public async Task ResetAsync() @@ -98,6 +104,128 @@ await FollowupAsync($"Registered **{name}** and published ServerRegisteredEvent. } } + /// Recreates any missing categories/channels/messages without deleting data. + [SlashCommand("repair", "Recreate any missing bot channels without deleting data")] + public async Task RepairAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This command must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var reconciler = scope.ServiceProvider.GetRequiredService(); + await reconciler.HealGuildAsync(Context.Guild.Id).ConfigureAwait(false); + await FollowupAsync("Workspace repaired.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } + + /// Prompts to delete and re-provision the entire workspace (dev-gated). + [SlashCommand("rebuild", "Delete and re-create all the bot's channels here (dangerous)")] + public async Task RebuildAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + var components = new ComponentBuilder() + .WithButton("Confirm rebuild", ConfirmRebuildId, ButtonStyle.Danger) + .Build(); + await RespondAsync( + "This deletes every provisioned channel and re-creates them from scratch. Confirm?", + ephemeral: true, components: components).ConfigureAwait(false); + } + + /// Executes the rebuild after confirmation. + [ComponentInteraction(ConfirmRebuildId)] + public async Task ConfirmRebuildAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var teardown = scope.ServiceProvider.GetRequiredService(); + var reconciler = scope.ServiceProvider.GetRequiredService(); + var servers = scope.ServiceProvider.GetRequiredService(); + + await teardown.ResetGuildAsync(Context.Guild.Id).ConfigureAwait(false); + + var result = await reconciler.ReconcileGlobalAsync(Context.Guild.Id).ConfigureAwait(false); + if (result.Status == ReconcileStatus.MissingPermissions) + { + await FollowupAsync( + $"I'm missing required permissions: {string.Join(", ", result.MissingPermissions)}.", + ephemeral: true).ConfigureAwait(false); + return; + } + + foreach (var server in await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false)) + { + await reconciler.ReconcileServerAsync(Context.Guild.Id, server.Id).ConfigureAwait(false); + } + + await FollowupAsync("Workspace rebuilt.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } + + /// Prompts to delete all of this guild's data (dev-gated). + [SlashCommand("purge", "Delete ALL of this server's bot data (servers, settings, channels) (dangerous)")] + public async Task PurgeAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + var components = new ComponentBuilder() + .WithButton("Confirm purge", ConfirmPurgeId, ButtonStyle.Danger) + .Build(); + await RespondAsync( + "This deletes every server, setting, and channel the bot stores for this Discord server. Confirm?", + ephemeral: true, components: components).ConfigureAwait(false); + } + + /// Executes the purge after confirmation. + [ComponentInteraction(ConfirmPurgeId)] + public async Task ConfirmPurgeAsync() + { + if (!await EnsureEnabledAsync().ConfigureAwait(false)) + { + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var scope = scopeFactory.CreateAsyncScope(); + try + { + var purge = scope.ServiceProvider.GetRequiredService(); + await purge.PurgeGuildAsync(Context.Guild.Id).ConfigureAwait(false); + await FollowupAsync("Guild data purged.", ephemeral: true).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsync().ConfigureAwait(false); + } + } + private async Task EnsureEnabledAsync() { if (Context.Guild is null) From e61d22607508e292987d951781a7467b36fb4cf6 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 19:21:16 +0200 Subject: [PATCH 8/9] fix: cap /status server fields under Discord's 25-field embed limit Co-Authored-By: Claude Opus 4.8 --- src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs index b9af3ce..1f62b79 100644 --- a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs @@ -58,7 +58,8 @@ public async Task StatusAsync() .AddField("Servers (this guild)", known.Count.ToString(CultureInfo.InvariantCulture), inline: true); - foreach (var server in known) + // Cap server fields so the embed stays under Discord's 25-field limit (4 header fields above). + foreach (var server in known.Take(20)) { var state = await connections.GetStateAsync(Context.Guild.Id, server.Id).ConfigureAwait(false); var line = state is null From a2dc8bfd514d08f9a5aad8bfeda7e5d19b347a65 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Wed, 1 Jul 2026 19:46:42 +0200 Subject: [PATCH 9/9] fix: address Copilot review on PR #38 (atomicity, N+1, wording, purge lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DatabaseMaintenanceService: wipe in a single transaction with defer_foreign_keys so an interruption rolls back instead of leaving a partially-cleared DB; throw on an unexpected table identifier instead of silently skipping it (and reporting a misleading success). - DiagnosticsModule /status: replace the per-server GetStateAsync N+1 with a single IConnectionStore.GetStatesForGuildAsync bulk read + dictionary lookup. - MaintenanceModule: clearer danger-off message for the operator-facing /admin group instead of "Developer commands are disabled". - GuildPurgeService: hold the per-guild provisioning lock across the whole purge (teardown + domain deletes) so concurrent reconciliation — including the self-heal triggered by teardown deleting channels — cannot re-provision into a half-purged guild. Adds a lock-free WorkspaceTeardownService core. Co-Authored-By: Claude Opus 4.8 --- .../Modules/DiagnosticsModule.cs | 11 ++++--- .../Modules/MaintenanceModule.cs | 4 ++- .../Teardown/GuildPurgeService.cs | 15 +++++++-- .../Teardown/WorkspaceTeardownService.cs | 13 ++++++++ .../Connections/ConnectionStore.cs | 9 ++++++ .../Connections/IConnectionStore.cs | 8 +++++ .../Maintenance/DatabaseMaintenanceService.cs | 31 +++++++++---------- .../Teardown/GuildPurgeServiceTests.cs | 18 +++++++++-- .../Connections/ConnectionStoreTests.cs | 26 ++++++++++++++++ 9 files changed, 107 insertions(+), 28 deletions(-) diff --git a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs index 1f62b79..04ba1fc 100644 --- a/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/DiagnosticsModule.cs @@ -47,6 +47,8 @@ public async Task StatusAsync() var connections = scope.ServiceProvider.GetRequiredService(); var known = await servers.ListAsync(Context.Guild.Id).ConfigureAwait(false); + var states = (await connections.GetStatesForGuildAsync(Context.Guild.Id).ConfigureAwait(false)) + .ToDictionary(state => state.RustServerId); var embed = new EmbedBuilder() .WithTitle("Bot status") @@ -61,11 +63,10 @@ public async Task StatusAsync() // Cap server fields so the embed stays under Discord's 25-field limit (4 header fields above). foreach (var server in known.Take(20)) { - var state = await connections.GetStateAsync(Context.Guild.Id, server.Id).ConfigureAwait(false); - var line = state is null - ? "unknown" - : string.Create(CultureInfo.InvariantCulture, - $"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players"); + var line = states.TryGetValue(server.Id, out var state) + ? string.Create(CultureInfo.InvariantCulture, + $"{state.Status} · {state.PlayerCount?.ToString(CultureInfo.InvariantCulture) ?? "?"} players") + : "unknown"; embed.AddField(server.Name, line); } diff --git a/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs b/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs index 827e4ac..098ac11 100644 --- a/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs +++ b/src/RustPlusBot.Features.Commands/Modules/MaintenanceModule.cs @@ -30,7 +30,9 @@ public async Task ResetDatabaseAsync( if (!options.Value.EnableDangerCommands) { - await RespondAsync("Developer commands are disabled.", ephemeral: true).ConfigureAwait(false); + await RespondAsync( + "Dangerous maintenance commands are disabled. Set `Workspace:EnableDangerCommands` to enable them.", + ephemeral: true).ConfigureAwait(false); return; } diff --git a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs index ea87ffe..e9a0c43 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/GuildPurgeService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Persistence; using RustPlusBot.Persistence.Servers; @@ -8,16 +9,24 @@ namespace RustPlusBot.Features.Workspace.Teardown; /// The bot database context. /// Server management (RemoveAsync cascades all per-server rows). /// Removes provisioned Discord channels/categories/messages. +/// Held across the whole purge to block concurrent reconciliation. internal sealed class GuildPurgeService( BotDbContext context, IServerService servers, - IWorkspaceTeardownService teardown) : IGuildPurgeService + WorkspaceTeardownService teardown, + IProvisioningLock provisioningLock) : IGuildPurgeService { /// public async Task PurgeGuildAsync(ulong guildId, CancellationToken cancellationToken = default) { - // 1) Delete provisioned Discord channels/categories/messages (Discord side + records). - await teardown.ResetGuildAsync(guildId, cancellationToken).ConfigureAwait(false); + // Hold the per-guild provisioning lock across the ENTIRE purge. Otherwise reconciliation could + // interleave after the teardown step — in particular the self-heal that fires when teardown + // deletes channels — and re-provision resources or add rows into a half-purged guild. + using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + + // 1) Delete provisioned Discord channels/categories/messages (Discord side + records). Use the + // lock-free core since we already hold the lock (ResetGuildAsync would deadlock re-acquiring). + await teardown.ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false); // 2) Remove each server; the RustServer FK cascade clears its per-server rows // (connection state, command/map settings, switches, alarms, storage monitors, credentials). diff --git a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs index f2e358d..251d831 100644 --- a/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs +++ b/src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs @@ -24,6 +24,19 @@ public async Task RemoveServerAsync(ulong guildId, Guid serverId, CancellationTo public async Task ResetGuildAsync(ulong guildId, CancellationToken cancellationToken = default) { using var handle = await provisioningLock.AcquireAsync(guildId, cancellationToken).ConfigureAwait(false); + await ResetGuildCoreAsync(guildId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes the guild's provisioned resources and records WITHOUT taking the provisioning lock. + /// The caller MUST already hold the guild's + /// uses this so it can hold the lock across the wider purge. External callers use . + /// + /// The guild to reset. + /// A cancellation token. + /// A task. + internal async Task ResetGuildCoreAsync(ulong guildId, CancellationToken cancellationToken = default) + { var categories = await store.GetAllCategoriesAsync(guildId, cancellationToken).ConfigureAwait(false); foreach (var category in categories) { diff --git a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs index 3e42649..fae04d5 100644 --- a/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs +++ b/src/RustPlusBot.Persistence/Connections/ConnectionStore.cs @@ -18,6 +18,15 @@ public sealed class ConnectionStore(BotDbContext context, IClock clock) : IConne context.ConnectionStates .SingleOrDefaultAsync(s => s.GuildId == guildId && s.RustServerId == serverId, cancellationToken); + /// + public async Task> GetStatesForGuildAsync( + ulong guildId, + CancellationToken cancellationToken = default) => + await context.ConnectionStates + .Where(s => s.GuildId == guildId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + /// public async Task UpsertStatusAsync( ulong guildId, diff --git a/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs b/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs index b7cb332..e65f873 100644 --- a/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs +++ b/src/RustPlusBot.Persistence/Connections/IConnectionStore.cs @@ -16,6 +16,14 @@ public interface IConnectionStore /// The connection state, or null. Task GetStateAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + /// Gets every server's connection state for a guild in a single query. + /// Owning Discord guild snowflake. + /// A cancellation token. + /// The guild's connection states (empty if none). + Task> GetStatesForGuildAsync( + ulong guildId, + CancellationToken cancellationToken = default); + /// /// Upserts the connection state. Returns true only when the persisted (status, player count, active /// credential) actually changed, so callers can skip a redundant #info refresh. diff --git a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs index 9a93aea..76e5249 100644 --- a/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs +++ b/src/RustPlusBot.Persistence/Maintenance/DatabaseMaintenanceService.cs @@ -16,35 +16,34 @@ public async Task ClearAllAsync(CancellationToken cancellationToken = default) .Distinct(StringComparer.Ordinal) .ToList(); - // Keep one connection open across every statement so the FK pragma persists - // (with per-statement connections the pragma would reset before the DELETEs). - await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); - try + // Wipe every table in one transaction so an interruption rolls back rather than leaving the + // database partially cleared. defer_foreign_keys defers FK enforcement to commit time (and + // resets itself when the transaction ends), so tables can be cleared in any order — once every + // table is empty the commit-time check has nothing to violate. This also avoids leaving a + // connection-level foreign_keys pragma toggled off on a pooled connection. + var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await using (transaction.ConfigureAwait(false)) { - await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", cancellationToken) + await context.Database.ExecuteSqlRawAsync("PRAGMA defer_foreign_keys = ON", cancellationToken) .ConfigureAwait(false); foreach (var table in tables) { - // Deletion order is deliberately irrelevant: PRAGMA foreign_keys = OFF (above) suspends - // FK enforcement for the wipe, so do not "fix" this by adding a topological sort. - // Table names come from the EF model (never user input); the identifier guard keeps - // the raw statement demonstrably injection-safe for the Sonar gate. + // Table names come from the EF model (never user input). Fail loud on an unexpected + // identifier rather than silently skipping it and reporting a misleading success; the + // guard also keeps the raw statement demonstrably injection-safe for the Sonar gate. if (!IsSafeIdentifier(table!)) { - continue; + throw new InvalidOperationException( + string.Create(CultureInfo.InvariantCulture, + $"Refusing to clear table with an unexpected identifier: '{table}'.")); } var sql = string.Create(CultureInfo.InvariantCulture, $"DELETE FROM \"{table}\""); await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false); } - await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", cancellationToken) - .ConfigureAwait(false); - } - finally - { - await context.Database.CloseConnectionAsync().ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } } diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs index b8a9194..26c896e 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Teardown/GuildPurgeServiceTests.cs @@ -8,9 +8,13 @@ using RustPlusBot.Domain.Guilds; using RustPlusBot.Domain.Servers; using RustPlusBot.Domain.Switches; +using RustPlusBot.Domain.Workspace; +using RustPlusBot.Features.Workspace.Gateway; +using RustPlusBot.Features.Workspace.Reconciler; using RustPlusBot.Features.Workspace.Teardown; using RustPlusBot.Persistence; using RustPlusBot.Persistence.Servers; +using RustPlusBot.Persistence.Workspace; namespace RustPlusBot.Features.Workspace.Tests.Teardown; @@ -79,12 +83,20 @@ public async Task PurgeGuild_RemovesTargetGuildRows_AndLeavesOtherGuildIntact() }); await context.SaveChangesAsync(); - var teardown = Substitute.For(); - var service = new GuildPurgeService(context, new ServerService(context), teardown); + // Real teardown over fake Discord I/O, sharing the lock the purge holds. An empty category set + // makes the teardown core a no-op on channels while still proving it runs under the held lock. + var gateway = Substitute.For(); + var store = Substitute.For(); + IReadOnlyList noCategories = []; + store.GetAllCategoriesAsync(Arg.Any(), Arg.Any()) + .Returns(noCategories); + var provisioningLock = new ProvisioningLock(); + var teardown = new WorkspaceTeardownService(gateway, store, provisioningLock); + var service = new GuildPurgeService(context, new ServerService(context), teardown, provisioningLock); await service.PurgeGuildAsync(1); - await teardown.Received(1).ResetGuildAsync(1, Arg.Any()); + await store.Received(1).GetAllCategoriesAsync(1, Arg.Any()); Assert.Empty(await context.RustServers.Where(s => s.GuildId == 1).ToListAsync()); Assert.Empty(await context.SmartSwitches.ToListAsync()); Assert.Empty(await context.ConnectionStates.ToListAsync()); diff --git a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs index e1a4131..1df3c23 100644 --- a/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs @@ -147,4 +147,30 @@ public async Task ListConnectableServers_ExcludesAllInvalidServers() var after = await store.ListConnectableServersAsync(); Assert.DoesNotContain((10UL, serverId), after); } + + [Fact] + public async Task GetStatesForGuild_ReturnsOnlyThatGuildsStates() + { + var (store, context, conn) = Create(); + await using var _ = conn; + await using var __ = context; + + var serverA = new RustServer + { + GuildId = 10UL, Name = "A", Ip = "1.1.1.1", Port = 28015 + }; + var serverB = new RustServer + { + GuildId = 20UL, Name = "B", Ip = "2.2.2.2", Port = 28015 + }; + context.RustServers.AddRange(serverA, serverB); + await context.SaveChangesAsync(); + await store.UpsertStatusAsync(10UL, serverA.Id, ConnectionStatus.Connected, 5, null); + await store.UpsertStatusAsync(20UL, serverB.Id, ConnectionStatus.Connecting, null, null); + + var states = await store.GetStatesForGuildAsync(10UL); + + Assert.Equal(serverA.Id, Assert.Single(states).RustServerId); + Assert.Empty(await store.GetStatesForGuildAsync(30UL)); + } }