diff --git a/Directory.Packages.props b/Directory.Packages.props index df46bc64..05408d4a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx index dce34291..6a828257 100644 --- a/RustPlusBot.slnx +++ b/RustPlusBot.slnx @@ -4,11 +4,13 @@ + + diff --git a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs index c01f8508..09be754b 100644 --- a/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs +++ b/src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs @@ -1,13 +1,23 @@ namespace RustPlusBot.Abstractions.Credentials; -/// Persists and counts player credentials. Tokens are protected at rest. +/// Persists and counts per-server player credentials. Tokens are protected at rest. public interface ICredentialStore { - /// Stores a credential (as Standby) and returns its new id. + /// + /// Inserts or refreshes the credential for (GuildId, RustServerId, OwnerUserId). A NEWLY INSERTED + /// credential is Active when is true (the owner whose pairing + /// first registered the server), otherwise Standby. Re-pairing refreshes the SteamId and token + /// and resets an Invalid credential to Standby (the existing designation is preserved; + /// is ignored on update). Returns the credential's id. + /// /// The plaintext credential inputs. + /// When inserting, whether this credential becomes the server's active identity. /// A cancellation token. - /// The new credential's id. - Task StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default); + /// The credential's id. + Task UpsertFromPairingAsync( + StoreCredentialRequest request, + bool markActive, + CancellationToken cancellationToken = default); /// Counts stored credentials for a server within a guild. /// Owning Discord guild snowflake. diff --git a/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs b/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs index e46919f2..4a4617fd 100644 --- a/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs +++ b/src/RustPlusBot.Abstractions/Credentials/StoreCredentialRequest.cs @@ -1,16 +1,14 @@ namespace RustPlusBot.Abstractions.Credentials; -/// Plaintext inputs to register a credential; tokens are protected by the store before persistence. +/// Plaintext inputs to upsert a per-server player credential from a pairing; the token is protected before persistence. /// Owning Discord guild snowflake. /// The server this credential connects to. -/// The Discord user registering the credential. -/// The player's Steam64 id. +/// The Discord user who owns the paired identity. +/// The player's Steam64 id (from the pairing notification). /// The plaintext Rust+ player token (protected before storage). -/// The plaintext FCM/Expo credential JSON (protected before storage). public sealed record StoreCredentialRequest( ulong GuildId, Guid RustServerId, ulong OwnerUserId, ulong SteamId, - string PlayerToken, - string FcmCredentialsJson); + string PlayerToken); diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs new file mode 100644 index 00000000..7e94458c --- /dev/null +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistration.cs @@ -0,0 +1,26 @@ +namespace RustPlusBot.Domain.Credentials; + +/// +/// One Discord user's Rust+ FCM listener registration within a guild. One per (GuildId, OwnerUserId). +/// The credentials blob is stored protected at rest (see ICredentialProtector) and feeds the pairing listener. +/// +public sealed class FcmRegistration +{ + /// Surrogate primary key. + public Guid Id { get; set; } = Guid.NewGuid(); + + /// The owning Discord guild snowflake. + public ulong GuildId { get; set; } + + /// The Discord user who connected these credentials. + public ulong OwnerUserId { get; set; } + + /// Protected FCM/Expo credentials JSON. + public string ProtectedFcmCredentials { get; set; } = string.Empty; + + /// Listener lifecycle state. + public FcmRegistrationStatus Status { get; set; } = FcmRegistrationStatus.Active; + + /// When the registration was last upserted or had its status changed (UTC). + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs b/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs new file mode 100644 index 00000000..3deab34d --- /dev/null +++ b/src/RustPlusBot.Domain/Credentials/FcmRegistrationStatus.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Domain.Credentials; + +/// Lifecycle state of a user's FCM listener registration. +public enum FcmRegistrationStatus +{ + /// Credentials accepted; the listener should run. + Active = 0, + + /// FCM rejected the credentials; the user must reconnect. + Expired = 1, + + /// Removed by the user; the listener must not run. + Disabled = 2, +} diff --git a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs index 2f0de779..f2b34a6b 100644 --- a/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs +++ b/src/RustPlusBot.Domain/Credentials/PlayerCredential.cs @@ -24,9 +24,6 @@ public sealed class PlayerCredential /// Protected Rust+ player token. public string ProtectedPlayerToken { get; set; } = string.Empty; - /// Protected FCM/Expo credential blob (JSON), used by the pairing listener later. - public string ProtectedFcmCredentials { get; set; } = string.Empty; - /// Pool lifecycle state. public CredentialStatus Status { get; set; } = CredentialStatus.Standby; } diff --git a/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs b/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs new file mode 100644 index 00000000..8089bbc1 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("RustPlusBot.Features.Pairing.Tests")] diff --git a/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs new file mode 100644 index 00000000..b030a5e7 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Hosting/PairingHostedService.cs @@ -0,0 +1,58 @@ +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RustPlusBot.Features.Pairing.Supervisor; + +namespace RustPlusBot.Features.Pairing.Hosting; + +/// Drives the supervisor from the host lifecycle: start listeners on Ready, stop them on shutdown. +/// The socket client (for the Ready event). +/// The pairing supervisor. +/// The logger. +internal sealed partial class PairingHostedService( + DiscordSocketClient client, + IPairingSupervisor supervisor, + ILogger logger) : IHostedService +{ + private bool _started; + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + client.Ready += OnReadyAsync; + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + client.Ready -= OnReadyAsync; + await supervisor.StopAllAsync().ConfigureAwait(false); + } + + private async Task OnReadyAsync() + { + // Ready fires on every (re)connect; only start listeners once per process. Ready is dispatched + // serially on the gateway thread, so the plain bool guard needs no synchronization (same pattern + // as DiscordBotService and WorkspaceHostedService). + if (_started) + { + return; + } + + _started = true; + try + { + await supervisor.StartAllActiveAsync().ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch is intentional: a failed startup must not crash the host. + catch (Exception ex) + { + LogStartupFailed(ex); + } +#pragma warning restore CA1031 + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to start FCM pairing listeners.")] + private partial void LogStartupFailed(Exception exception); +} diff --git a/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs new file mode 100644 index 00000000..202ef3dc --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Listening/IPairingSource.cs @@ -0,0 +1,27 @@ +namespace RustPlusBot.Features.Pairing.Listening; + +/// A live FCM listener for one user's credentials. Pushes pairing notifications to a callback. +internal interface IPairingListener : IAsyncDisposable +{ + /// + /// Connects and waits up to for the first successful check-in. After a + /// result the listener keeps delivering notifications + /// to its callback until disposed. + /// + /// How long to wait for the initial check-in. + /// Cancels the connection. + /// The initial connect outcome. + Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken); +} + +/// Creates s from credentials. The seam that abstracts FCM for tests. +internal interface IPairingSource +{ + /// Creates a listener that delivers notifications to . + /// The plaintext FCM credentials JSON. + /// Invoked for every pairing notification received. + /// A not-yet-connected listener. + IPairingListener Create( + string fcmCredentialsJson, + Func onNotification); +} diff --git a/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs new file mode 100644 index 00000000..73482a17 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Listening/PairingNotification.cs @@ -0,0 +1,39 @@ +namespace RustPlusBot.Features.Pairing.Listening; + +/// Whether a pairing notification is for a server or an in-game entity. +internal enum PairingKind +{ + /// A server pairing (registers a server). + Server = 0, + + /// An entity pairing (switch/alarm/camera) — ignored in 1b-i. + Entity = 1, +} + +/// The outcome of an initial FCM connect attempt. +internal enum PairingConnectOutcome +{ + /// Connected and checked in. + Connected = 0, + + /// FCM rejected the credentials. + Rejected = 1, + + /// No result within the probe window (network); retried in the background. + Timeout = 2, +} + +/// A pairing notification delivered by a listener. +/// Server or entity. +/// The server's display name. +/// The server host or ip. +/// The Rust+ app port. +/// The paired player's Steam64 id. +/// The Rust+ player token for this (server, player). +internal sealed record PairingNotification( + PairingKind Kind, + string ServerName, + string Ip, + int Port, + ulong PlayerId, + string PlayerToken); diff --git a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs new file mode 100644 index 00000000..30539589 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using RustPlusApi.Fcm; +using RustPlusApi.Fcm.Data; +using RustPlusApi.Fcm.Data.Events; + +namespace RustPlusBot.Features.Pairing.Listening; + +/// Real backed by RustPlusApi.Fcm. Untested by unit tests (integration shim). +/// The logger. +internal sealed partial class RustPlusFcmPairingSource(ILogger logger) : IPairingSource +{ + /// + public IPairingListener Create( + string fcmCredentialsJson, + Func onNotification) + { + try + { + return new RustPlusFcmListener(fcmCredentialsJson, onNotification, logger); + } +#pragma warning disable CA1031 // Broad catch: a malformed credential blob must surface as Rejected, not fault the listener loop. + catch (Exception ex) + { + LogConstructionFailed(ex); + return new RejectedListener(); + } +#pragma warning restore CA1031 + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "FCM listener construction failed; treating credentials as rejected.")] + private partial void LogConstructionFailed(Exception exception); + + /// A listener returned when credential construction failed; reports Rejected and does nothing else. + private sealed class RejectedListener : IPairingListener + { + /// + public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) => + Task.FromResult(PairingConnectOutcome.Rejected); + + /// + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } + + private sealed partial class RustPlusFcmListener : IPairingListener + { + private static readonly JsonSerializerOptions JsonOptions = + new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly RustPlusFcm _fcm; + private readonly ILogger _logger; + private readonly Func _onNotification; + + public RustPlusFcmListener( + string fcmCredentialsJson, + Func onNotification, + ILogger logger) + { + var credentials = JsonSerializer.Deserialize(fcmCredentialsJson, JsonOptions) + ?? throw new ArgumentException("FCM credentials JSON deserialized to null.", + nameof(fcmCredentialsJson)); + + _onNotification = onNotification; + _logger = logger; + _fcm = new RustPlusFcm(credentials, persistentIds: null, options: null, loggerFactory: null); + _fcm.OnServerPairing += OnServerPairing; + } + + /// + public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + + try + { + await _fcm.ConnectAsync(timeoutCts.Token).ConfigureAwait(false); + return PairingConnectOutcome.Connected; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timed out waiting for the connection — not the caller's CancellationToken. + return PairingConnectOutcome.Timeout; + } +#pragma warning disable CA1031 // Broad catch is intentional: any non-cancellation exception (auth, TLS, protocol error) maps to Rejected. + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) +#pragma warning restore CA1031 + { + LogConnectionFailed(_logger, ex); + return PairingConnectOutcome.Rejected; + } + } + + /// + public async ValueTask DisposeAsync() + { + _fcm.OnServerPairing -= OnServerPairing; + await _fcm.DisposeAsync().ConfigureAwait(false); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "FCM credentials rejected or connection failed.")] + private static partial void LogConnectionFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Exception while dispatching pairing notification; it is swallowed to keep the listener alive.")] + private static partial void LogNotificationDispatchFailed(ILogger logger, Exception ex); + + private void OnServerPairing(object? sender, Notification e) + { + if (e?.Data is null) + { + return; + } + + var notification = new PairingNotification( + Kind: PairingKind.Server, + ServerName: e.Data.Name ?? string.Empty, + Ip: e.Data.Ip ?? string.Empty, + Port: e.Data.Port, + PlayerId: e.PlayerId, + PlayerToken: e.PlayerToken.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // Fire-and-forget bridge from synchronous event to async callback. + // Exception must never propagate back into the package's event dispatcher. +#pragma warning disable CA2008 // Task.Run without TaskScheduler: acceptable here; default is ThreadPool. + _ = Task.Run(async () => +#pragma warning restore CA2008 + { + try + { + await _onNotification(notification, CancellationToken.None).ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch is intentional: an unhandled exception here would silently fault an unobserved task, crashing the process. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogNotificationDispatchFailed(_logger, ex); + } + }); + } + } +} diff --git a/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs b/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs new file mode 100644 index 00000000..2545dad9 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Modules/ConnectModal.cs @@ -0,0 +1,23 @@ +using Discord; +using Discord.Interactions; + +namespace RustPlusBot.Features.Pairing.Modules; + +/// The ephemeral modal that collects a user's FCM credentials JSON. +public sealed class ConnectModal : IModal +{ + /// Custom id of the modal, handled by . + public const string ModalId = "pairing:connect:modal"; + + /// Custom id of the credentials text input. + public const string CredentialsInputId = "pairing:connect:credentials"; + + /// The pasted FCM credentials JSON. + [InputLabel("FCM credentials JSON")] + [ModalTextInput(CredentialsInputId, TextInputStyle.Paragraph, + placeholder: "Paste the JSON from the credentials helper")] + public string Credentials { get; set; } = string.Empty; + + /// + public string Title => "Connect your Rust+ account"; +} diff --git a/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs new file mode 100644 index 00000000..03ace00e --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs @@ -0,0 +1,71 @@ +using Discord.Interactions; +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Features.Pairing.Validation; +using RustPlusBot.Features.Workspace; +using RustPlusBot.Persistence.Credentials; + +namespace RustPlusBot.Features.Pairing.Modules; + +/// Handles the #setup "Connect account" button and the credentials modal submission. +/// Creates a short-lived DI scope per interaction. +public sealed class CredentialModule(IServiceScopeFactory scopeFactory) + : InteractionModuleBase +{ + /// Opens the credentials modal when the #setup button is clicked. + [ComponentInteraction(WorkspaceComponentIds.ConnectAccount)] + public async Task OpenAsync() + { + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await RespondWithModalAsync(ConnectModal.ModalId).ConfigureAwait(false); + } + + /// Validates, stores, and connects the submitted credentials. + /// The submitted modal. + [ModalInteraction(ConnectModal.ModalId)] + public async Task SubmitAsync(ConnectModal modal) + { + ArgumentNullException.ThrowIfNull(modal); + if (Context.Guild is null) + { + await RespondAsync("This control must be used in a server.", ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + if (!FcmCredentialValidator.IsWellFormed(modal.Credentials)) + { + await FollowupAsync( + "I couldn't read those credentials. Paste the full JSON produced by the credentials helper.", + ephemeral: true).ConfigureAwait(false); + return; + } + + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var registrations = scope.ServiceProvider.GetRequiredService(); + await registrations.UpsertAsync(Context.Guild.Id, Context.User.Id, modal.Credentials).ConfigureAwait(false); + + var supervisor = scope.ServiceProvider.GetRequiredService(); + var outcome = await supervisor.EnsureListenerAsync(Context.Guild.Id, Context.User.Id).ConfigureAwait(false); + + var message = outcome switch + { + PairingConnectOutcome.Connected => + "Account connected. Pair with a server in-game and it'll show up here automatically.", + PairingConnectOutcome.Rejected => + "Those credentials were rejected by Rust+. Generate fresh credentials and reconnect.", + _ => "Account saved — still verifying the connection. Pairings will appear once it's up.", + }; + await FollowupAsync(message, ephemeral: true).ConfigureAwait(false); + } + } +} diff --git a/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs new file mode 100644 index 00000000..ba1713dc --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Notifications/DiscordOwnerNotifier.cs @@ -0,0 +1,45 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; + +namespace RustPlusBot.Features.Pairing.Notifications; + +/// DMs the owner. A closed-DM failure is swallowed (logged): the Expired state still shows in #setup. +/// The socket client. +/// The logger. +internal sealed partial class DiscordOwnerNotifier(DiscordSocketClient client, ILogger logger) + : IOwnerNotifier +{ + /// + public async Task NotifyCredentialsExpiredAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + try + { + var user = await client.GetUserAsync(ownerUserId).ConfigureAwait(false); + if (user is null) + { + LogUserNotFound(logger, ownerUserId); + return; + } + + await user.SendMessageAsync( + "Your Rust+ credentials were rejected. Reconnect your account in #setup to keep receiving pairings.") + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Broad catch is intentional: a closed DM (or any send failure) must not break the supervisor. + catch (Exception ex) +#pragma warning restore CA1031 + { + LogDmFailed(logger, ex, ownerUserId); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Cannot DM owner {OwnerId}: user not found.")] + private static partial void LogUserNotFound(ILogger logger, ulong ownerId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to DM owner {OwnerId} about expired credentials.")] + private static partial void LogDmFailed(ILogger logger, Exception ex, ulong ownerId); +} diff --git a/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs new file mode 100644 index 00000000..e25ce220 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Notifications/IOwnerNotifier.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Pairing.Notifications; + +/// Notifies a credential owner out-of-band (e.g. by DM) when action is needed. +internal interface IOwnerNotifier +{ + /// Tells the owner their FCM credentials expired and to reconnect. + /// The guild the registration belongs to. + /// The Discord user to notify. + /// A cancellation token. + Task NotifyCredentialsExpiredAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs new file mode 100644 index 00000000..b719fe1c --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Pairing/IPairingHandler.cs @@ -0,0 +1,18 @@ +using RustPlusBot.Features.Pairing.Listening; + +namespace RustPlusBot.Features.Pairing.Pairing; + +/// Turns a pairing notification into a registered server and a pooled credential. +internal interface IPairingHandler +{ + /// Handles one notification for a given owner. + /// The owning guild snowflake. + /// The Discord user whose listener received it. + /// The pairing notification. + /// A cancellation token. + Task HandleAsync( + ulong guildId, + ulong ownerUserId, + PairingNotification notification, + CancellationToken cancellationToken); +} diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs new file mode 100644 index 00000000..fe234485 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Pairing; + +/// Default : resolve-or-create the server, upsert the credential, announce new servers. +/// Server resolve-or-create. +/// The credential pool store. +/// Publishes the real ServerRegisteredEvent on new-server creation. +/// The logger. +internal sealed partial class PairingHandler( + IServerService servers, + ICredentialStore credentials, + IEventBus eventBus, + ILogger logger) : IPairingHandler +{ + /// + public async Task HandleAsync( + ulong guildId, + ulong ownerUserId, + PairingNotification notification, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + if (notification.Kind != PairingKind.Server) + { + LogIgnoringKind(logger, notification.Kind); + return; + } + + var (server, created) = await servers.ResolveOrCreateByEndpointAsync( + guildId, ownerUserId, notification.ServerName, notification.Ip, notification.Port, cancellationToken) + .ConfigureAwait(false); + + await credentials.UpsertFromPairingAsync( + new StoreCredentialRequest(guildId, server.Id, ownerUserId, notification.PlayerId, + notification.PlayerToken), + markActive: created, + cancellationToken).ConfigureAwait(false); + + if (created) + { + await eventBus.PublishAsync(new ServerRegisteredEvent(guildId, server.Id), cancellationToken) + .ConfigureAwait(false); + } + } + + [LoggerMessage(Level = LogLevel.Debug, + Message = "Ignoring {Kind} pairing notification (deferred to a later subsystem).")] + private static partial void LogIgnoringKind(ILogger logger, PairingKind kind); +} diff --git a/src/RustPlusBot.Features.Pairing/PairingOptions.cs b/src/RustPlusBot.Features.Pairing/PairingOptions.cs new file mode 100644 index 00000000..cc2381c6 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/PairingOptions.cs @@ -0,0 +1,14 @@ +namespace RustPlusBot.Features.Pairing; + +/// Pairing feature configuration, bound from the "Pairing" config section. +public sealed class PairingOptions +{ + /// How long the modal connect waits for an initial FCM check-in before reporting "still verifying". + public TimeSpan ProbeTimeout { get; set; } = TimeSpan.FromSeconds(8); + + /// First delay before retrying a timed-out initial connect. + public TimeSpan InitialRetryDelay { get; set; } = TimeSpan.FromSeconds(5); + + /// Cap on the exponential backoff between connect retries. + public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs new file mode 100644 index 00000000..ba0da9e3 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using RustPlusBot.Discord; +using RustPlusBot.Features.Pairing.Hosting; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Features.Pairing.Notifications; +using RustPlusBot.Features.Pairing.Pairing; +using RustPlusBot.Features.Pairing.Supervisor; + +namespace RustPlusBot.Features.Pairing; + +/// DI registration for the pairing feature. +public static class PairingServiceCollectionExtensions +{ + /// Registers the pairing source, supervisor, handler, notifier, module seam, and hosted service. + /// The service collection to add to. + /// The same service collection, for chaining. + public static IServiceCollection AddPairing(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Contribute this assembly's interaction modules to the Discord layer. + services.AddSingleton(new InteractionModuleAssembly(typeof(PairingServiceCollectionExtensions).Assembly)); + + services.AddHostedService(); + + return services; + } +} diff --git a/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj new file mode 100644 index 00000000..dd788166 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + + + + + + + + + + + + + + + + + diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs new file mode 100644 index 00000000..7380754b --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Features.Pairing.Listening; + +namespace RustPlusBot.Features.Pairing.Supervisor; + +/// Owns the live FCM listeners — one per active registration. +internal interface IPairingSupervisor +{ + /// + /// Starts (or restarts) the listener for (guild, owner) and returns the initial connect outcome. + /// On the registration is marked Expired and the owner notified. + /// + /// The owning guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The initial connect outcome. + Task EnsureListenerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default); + + /// Starts listeners for every Active registration (called once at startup). + /// A cancellation token. + Task StartAllActiveAsync(CancellationToken cancellationToken = default); + + /// Cancels and disposes every listener (called on shutdown). + Task StopAllAsync(); +} diff --git a/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs new file mode 100644 index 00000000..09202a7a --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs @@ -0,0 +1,286 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Features.Pairing.Notifications; +using RustPlusBot.Features.Pairing.Pairing; +using RustPlusBot.Persistence.Credentials; + +namespace RustPlusBot.Features.Pairing.Supervisor; + +/// Default : one connect-retry loop per active registration. +/// Creates listeners (FCM in production, a fake in tests). +/// Opens scopes for the scoped store and handler. +/// Notifies owners when their credentials expire. +/// Unprotects stored credentials before connecting. +/// Probe timeout and backoff settings. +/// The logger. +internal sealed partial class PairingSupervisor( + IPairingSource source, + IServiceScopeFactory scopeFactory, + IOwnerNotifier notifier, + ICredentialProtector protector, + IOptions options, + ILogger logger) : IPairingSupervisor, IAsyncDisposable +{ + private readonly ConcurrentDictionary<(ulong Guild, ulong Owner), Handle> _listeners = new(); + private readonly PairingOptions _options = options.Value; + private readonly CancellationTokenSource _shutdown = new(); + + /// + public async ValueTask DisposeAsync() + { + await StopAllAsync().ConfigureAwait(false); + _shutdown.Dispose(); + } + + /// + public async Task EnsureListenerAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + var key = (guildId, ownerUserId); + await StopListenerAsync(key).ConfigureAwait(false); + + FcmRegistration? registration; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + registration = await store.GetAsync(guildId, ownerUserId, cancellationToken).ConfigureAwait(false); + } + + if (registration is null || registration.Status == FcmRegistrationStatus.Disabled) + { + return PairingConnectOutcome.Rejected; + } + + string credentialsJson; + try + { + credentialsJson = protector.Unprotect(registration.ProtectedFcmCredentials); + } + catch (CryptographicException ex) + { + LogUnreadableCredentials(logger, ex, ownerUserId); + await MarkExpiredAsync(key, registration.Id).ConfigureAwait(false); + return PairingConnectOutcome.Rejected; + } + + if (_shutdown.IsCancellationRequested) + { + return PairingConnectOutcome.Timeout; + } + + var firstOutcome = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cts = CancellationTokenSource.CreateLinkedTokenSource(_shutdown.Token); + _listeners[key] = new Handle( + cts, + Task.Run( + () => RunAsync(key, registration.Id, credentialsJson, firstOutcome, cts.Token), + CancellationToken.None)); + return await firstOutcome.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task StartAllActiveAsync(CancellationToken cancellationToken = default) + { + IReadOnlyList active; + var scope = scopeFactory.CreateAsyncScope(); + await using (scope.ConfigureAwait(false)) + { + var store = scope.ServiceProvider.GetRequiredService(); + active = await store.ListActiveAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var registration in active) + { + await EnsureListenerAsync(registration.GuildId, registration.OwnerUserId, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + public async Task StopAllAsync() + { + await _shutdown.CancelAsync().ConfigureAwait(false); + foreach (var key in _listeners.Keys.ToList()) + { + await StopListenerAsync(key).ConfigureAwait(false); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Listener loop for owner {OwnerId} faulted.")] + private static partial void LogListenerLoopFaulted(ILogger logger, Exception exception, ulong ownerId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to handle pairing notification for owner {OwnerId}.")] + private static partial void LogNotificationFailed(ILogger logger, Exception exception, ulong ownerId); + + [LoggerMessage(Level = LogLevel.Error, Message = "Stored FCM credentials for owner {OwnerId} are unreadable.")] + private static partial void LogUnreadableCredentials(ILogger logger, Exception exception, ulong ownerId); + + private async Task StopListenerAsync((ulong Guild, ulong Owner) key) + { + if (!_listeners.TryRemove(key, out var handle)) + { + return; + } + + await handle.StopAsync().ConfigureAwait(false); + await handle.DisposeAsync().ConfigureAwait(false); + } + + private async Task RunAsync( + (ulong Guild, ulong Owner) key, + Guid registrationId, + string credentialsJson, + TaskCompletionSource firstOutcome, + CancellationToken cancellationToken) + { + var delay = _options.InitialRetryDelay; + try + { + while (!cancellationToken.IsCancellationRequested) + { + var listener = source.Create( + credentialsJson, + (notification, ct) => HandleNotificationAsync(key, notification, ct)); + + PairingConnectOutcome outcome; + try + { + outcome = await listener.ConnectAsync(_options.ProbeTimeout, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await listener.DisposeAsync().ConfigureAwait(false); + throw; + } + + if (outcome == PairingConnectOutcome.Connected) + { + firstOutcome.TrySetResult(outcome); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Stopping. + } + finally + { + await listener.DisposeAsync().ConfigureAwait(false); + } + + return; + } + + await listener.DisposeAsync().ConfigureAwait(false); + + if (outcome == PairingConnectOutcome.Rejected) + { + await MarkExpiredAsync(key, registrationId).ConfigureAwait(false); + firstOutcome.TrySetResult(outcome); + return; + } + + // Timeout: signal the caller so they get the outcome immediately, then retry in background. + firstOutcome.TrySetResult(outcome); + + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + delay = delay < _options.MaxRetryDelay + ? TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, _options.MaxRetryDelay.Ticks)) + : _options.MaxRetryDelay; + } + } + catch (OperationCanceledException) + { + // Stopping. + } +#pragma warning disable CA1031 // Broad catch is intentional: a faulting listener loop must not crash the host. + catch (Exception ex) + { + LogListenerLoopFaulted(logger, ex, key.Owner); + } +#pragma warning restore CA1031 + finally + { + firstOutcome.TrySetResult(PairingConnectOutcome.Timeout); + } + } + + private async Task HandleNotificationAsync( + (ulong Guild, ulong Owner) key, + PairingNotification notification, + CancellationToken cancellationToken) + { + try + { + var notificationScope = scopeFactory.CreateAsyncScope(); + await using (notificationScope.ConfigureAwait(false)) + { + var handler = notificationScope.ServiceProvider.GetRequiredService(); + await handler.HandleAsync(key.Guild, key.Owner, notification, cancellationToken).ConfigureAwait(false); + } + } +#pragma warning disable CA1031 // Broad catch is intentional: one bad notification must not kill the listener. + catch (Exception ex) + { + LogNotificationFailed(logger, ex, key.Owner); + } +#pragma warning restore CA1031 + } + + private async Task MarkExpiredAsync((ulong Guild, ulong Owner) key, Guid registrationId) + { + var expireScope = scopeFactory.CreateAsyncScope(); + await using (expireScope.ConfigureAwait(false)) + { + var store = expireScope.ServiceProvider.GetRequiredService(); + await store.SetStatusAsync(registrationId, FcmRegistrationStatus.Expired).ConfigureAwait(false); + } + + await notifier.NotifyCredentialsExpiredAsync(key.Guild, key.Owner).ConfigureAwait(false); + } + + private sealed class Handle : IAsyncDisposable + { + private readonly CancellationTokenSource _cts; + private readonly Task _runTask; + + public Handle(CancellationTokenSource cts, Task runTask) + { + _cts = cts; + _runTask = runTask; + } + + public ValueTask DisposeAsync() + { + _cts.Dispose(); + return ValueTask.CompletedTask; + } + + public async Task StopAsync() + { + await _cts.CancelAsync().ConfigureAwait(false); + try + { +#pragma warning disable VSTHRD003 // Suppress: task is owned by this Handle and explicitly joined on stop. + await _runTask.ConfigureAwait(false); +#pragma warning restore VSTHRD003 + } + catch (OperationCanceledException) + { + // Expected on stop. + } + } + } +} diff --git a/src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs b/src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs new file mode 100644 index 00000000..f39af9e1 --- /dev/null +++ b/src/RustPlusBot.Features.Pairing/Validation/FcmCredentialValidator.cs @@ -0,0 +1,31 @@ +using System.Text.Json; + +namespace RustPlusBot.Features.Pairing.Validation; + +/// +/// A cheap structural guard against pasted garbage: confirms the blob is a JSON object. The authoritative +/// check is the live connect probe (a malformed-but-objecty blob is rejected by FCM at connect time). +/// +internal static class FcmCredentialValidator +{ + /// True when is non-empty and parses as a JSON object. + /// The pasted credentials blob. + /// Whether it is structurally usable. + public static bool IsWellFormed(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(json); + return document.RootElement.ValueKind == JsonValueKind.Object; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs index 5b9cecaa..8f3c0ea0 100644 --- a/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs +++ b/src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs @@ -23,7 +23,8 @@ internal sealed class LocalizationCatalog ["information.servers"] = "Servers registered: {0}", ["setup.title"] = "Connect your Rust+ account", ["setup.body"] = - "Account connection arrives in the next update. Once connected, pair a server in-game and its channels appear automatically.", + "Click **Connect account** below and paste your Rust+ FCM credentials. Then pair a server in-game and its channels appear automatically.", + ["setup.connect.button"] = "Connect account", ["settings.title"] = "Settings", ["settings.body"] = "Configure the bot for this server.", ["settings.language.label"] = "Language", @@ -42,7 +43,9 @@ internal sealed class LocalizationCatalog "Connectez votre compte Rust+ dans #configuration, puis appairez un serveur en jeu.", ["information.servers"] = "Serveurs enregistres : {0}", ["setup.title"] = "Connectez votre compte Rust+", - ["setup.body"] = "La connexion de compte arrive dans la prochaine mise a jour.", + ["setup.body"] = + "Cliquez sur **Connecter le compte** ci-dessous et collez vos identifiants FCM Rust+. Appairez ensuite un serveur en jeu et ses salons apparaitront automatiquement.", + ["setup.connect.button"] = "Connecter le compte", ["settings.title"] = "Parametres", ["settings.body"] = "Configurez le bot pour ce serveur.", ["settings.language.label"] = "Langue", diff --git a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs index 7df8ad40..8b0c2843 100644 --- a/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs +++ b/src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs @@ -5,7 +5,7 @@ namespace RustPlusBot.Features.Workspace.Messages; -/// Renders the global #setup instructions (the interactive button arrives in 1b). +/// Renders the global #setup instructions with a Connect account button. /// String resolution. internal sealed class SetupMessageRenderer(ILocalizer localizer) : IMessageRenderer { @@ -21,6 +21,12 @@ public ValueTask RenderAsync(MessageRenderContext context, Cance .WithDescription(localizer.Get("setup.body", context.Culture)) .WithColor(Color.Blue) .Build(); - return ValueTask.FromResult(new MessagePayload(null, embed, null)); + var components = new ComponentBuilder() + .WithButton( + localizer.Get("setup.connect.button", context.Culture), + WorkspaceComponentIds.ConnectAccount, + ButtonStyle.Primary) + .Build(); + return ValueTask.FromResult(new MessagePayload(null, embed, components)); } } diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs new file mode 100644 index 00000000..5e4683a8 --- /dev/null +++ b/src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs @@ -0,0 +1,11 @@ +namespace RustPlusBot.Features.Workspace; + +/// +/// Public custom ids for components rendered into the workspace by Workspace but handled by other features. +/// Feature modules reference these so the rendered control and its handler share one contract. +/// +public static class WorkspaceComponentIds +{ + /// The #setup "Connect account" button; handled by the Pairing feature. + public const string ConnectAccount = "workspace:setup:connect"; +} diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs index 21357d5f..a4307e4a 100644 --- a/src/RustPlusBot.Host/Program.cs +++ b/src/RustPlusBot.Host/Program.cs @@ -6,6 +6,7 @@ using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; using RustPlusBot.Discord; +using RustPlusBot.Features.Pairing; using RustPlusBot.Features.Workspace; using RustPlusBot.Host.Credentials; using RustPlusBot.Persistence; @@ -28,6 +29,14 @@ builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Workspace")); builder.Services.AddWorkspace(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Pairing")) + .Validate(static o => o.ProbeTimeout > TimeSpan.Zero, "Pairing:ProbeTimeout must be positive.") + .Validate(static o => o.InitialRetryDelay > TimeSpan.Zero, "Pairing:InitialRetryDelay must be positive.") + .Validate(static o => o.MaxRetryDelay >= o.InitialRetryDelay, + "Pairing:MaxRetryDelay must be at least InitialRetryDelay.") + .ValidateOnStart(); +builder.Services.AddPairing(); var host = builder.Build(); diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj index 2a4e261e..04d6a367 100644 --- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj +++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj @@ -20,5 +20,6 @@ + diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs index cdb89621..de5a0572 100644 --- a/src/RustPlusBot.Persistence/BotDbContext.cs +++ b/src/RustPlusBot.Persistence/BotDbContext.cs @@ -24,6 +24,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco /// Stored player credentials. public DbSet PlayerCredentials => Set(); + /// Per-user FCM listener registrations. + public DbSet FcmRegistrations => Set(); + /// Per-server connection state. public DbSet ConnectionStates => Set(); @@ -54,6 +57,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder .ApplyConfiguration(new RustServerConfiguration()) .ApplyConfiguration(new PlayerCredentialConfiguration()) + .ApplyConfiguration(new FcmRegistrationConfiguration()) .ApplyConfiguration(new ConnectionStateConfiguration()) .ApplyConfiguration(new GuildSettingsConfiguration()) .ApplyConfiguration(new PairedEntityConfiguration()) diff --git a/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs new file mode 100644 index 00000000..d2a1d6e4 --- /dev/null +++ b/src/RustPlusBot.Persistence/Configurations/FcmRegistrationConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RustPlusBot.Domain.Credentials; + +namespace RustPlusBot.Persistence.Configurations; + +internal sealed class FcmRegistrationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.HasKey(r => r.Id); + builder.HasIndex(r => new + { + r.GuildId, r.OwnerUserId + }).IsUnique(); + builder.Property(r => r.ProtectedFcmCredentials).IsRequired(); + } +} diff --git a/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs index 168ee0cd..51dfc805 100644 --- a/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/PlayerCredentialConfiguration.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; namespace RustPlusBot.Persistence.Configurations; @@ -12,9 +13,14 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(c => c.Id); builder.HasIndex(c => new { - c.GuildId, c.RustServerId - }); + c.GuildId, c.RustServerId, c.OwnerUserId + }).IsUnique(); builder.Property(c => c.ProtectedPlayerToken).IsRequired(); - builder.Property(c => c.ProtectedFcmCredentials).IsRequired(); + + // Removing a RustServer cascades to its credentials so orphaned secrets cannot linger. + builder.HasOne() + .WithMany() + .HasForeignKey(c => c.RustServerId) + .OnDelete(DeleteBehavior.Cascade); } } diff --git a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs index 5335b4d7..6925d714 100644 --- a/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs +++ b/src/RustPlusBot.Persistence/Configurations/RustServerConfiguration.cs @@ -13,5 +13,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(s => s.Name).IsRequired().HasMaxLength(128); builder.Property(s => s.Ip).IsRequired().HasMaxLength(255); builder.HasIndex(s => s.GuildId); + builder.HasIndex(s => new + { + s.GuildId, s.Ip, s.Port + }).IsUnique(); } } diff --git a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs index 10789c57..6a02f12c 100644 --- a/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs +++ b/src/RustPlusBot.Persistence/Credentials/CredentialStore.cs @@ -10,10 +10,34 @@ namespace RustPlusBot.Persistence.Credentials; public sealed class CredentialStore(BotDbContext context, ICredentialProtector protector) : ICredentialStore { /// - public async Task StoreAsync(StoreCredentialRequest request, CancellationToken cancellationToken = default) + public async Task UpsertFromPairingAsync( + StoreCredentialRequest request, + bool markActive, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + var existing = await context.PlayerCredentials + .SingleOrDefaultAsync( + c => c.GuildId == request.GuildId + && c.RustServerId == request.RustServerId + && c.OwnerUserId == request.OwnerUserId, + cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + existing.SteamId = request.SteamId; + existing.ProtectedPlayerToken = protector.Protect(request.PlayerToken); + if (existing.Status == CredentialStatus.Invalid) + { + existing.Status = CredentialStatus.Standby; + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return existing.Id; + } + var credential = new PlayerCredential { GuildId = request.GuildId, @@ -21,8 +45,7 @@ public async Task StoreAsync(StoreCredentialRequest request, CancellationT OwnerUserId = request.OwnerUserId, SteamId = request.SteamId, ProtectedPlayerToken = protector.Protect(request.PlayerToken), - ProtectedFcmCredentials = protector.Protect(request.FcmCredentialsJson), - Status = CredentialStatus.Standby, + Status = markActive ? CredentialStatus.Active : CredentialStatus.Standby, }; context.PlayerCredentials.Add(credential); diff --git a/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs new file mode 100644 index 00000000..5b4fec39 --- /dev/null +++ b/src/RustPlusBot.Persistence/Credentials/FcmRegistrationStore.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Credentials; + +namespace RustPlusBot.Persistence.Credentials; + +/// EF-backed that protects credentials before persisting. +/// The bot database context. +/// Protects credential material before it is written. +/// Supplies the update timestamp. +public sealed class FcmRegistrationStore(BotDbContext context, ICredentialProtector protector, IClock clock) + : IFcmRegistrationStore +{ + /// + public async Task UpsertAsync( + ulong guildId, + ulong ownerUserId, + string fcmCredentialsJson, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(fcmCredentialsJson); + + var existing = await context.FcmRegistrations + .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken) + .ConfigureAwait(false); + + if (existing is not null) + { + existing.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson); + existing.Status = FcmRegistrationStatus.Active; + existing.UpdatedAt = clock.UtcNow; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return existing.Id; + } + + var registration = new FcmRegistration + { + GuildId = guildId, + OwnerUserId = ownerUserId, + ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson), + Status = FcmRegistrationStatus.Active, + UpdatedAt = clock.UtcNow, + }; + + context.FcmRegistrations.Add(registration); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return registration.Id; + } + catch (DbUpdateException) + { + // A concurrent submission for the same (guild, owner) won the unique-index race; update the winner. + context.Entry(registration).State = EntityState.Detached; + var winner = await context.FcmRegistrations + .SingleAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken) + .ConfigureAwait(false); + winner.ProtectedFcmCredentials = protector.Protect(fcmCredentialsJson); + winner.Status = FcmRegistrationStatus.Active; + winner.UpdatedAt = clock.UtcNow; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return winner.Id; + } + } + + /// + public async Task> ListActiveAsync(CancellationToken cancellationToken = default) => + await context.FcmRegistrations + .Where(r => r.Status == FcmRegistrationStatus.Active) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + /// + public async Task SetStatusAsync( + Guid id, + FcmRegistrationStatus status, + CancellationToken cancellationToken = default) + { + var registration = await context.FcmRegistrations + .SingleOrDefaultAsync(r => r.Id == id, cancellationToken) + .ConfigureAwait(false); + if (registration is null) + { + return; + } + + registration.Status = status; + registration.UpdatedAt = clock.UtcNow; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public Task GetAsync( + ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) => + context.FcmRegistrations + .SingleOrDefaultAsync(r => r.GuildId == guildId && r.OwnerUserId == ownerUserId, cancellationToken); +} diff --git a/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs b/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs new file mode 100644 index 00000000..4c918e53 --- /dev/null +++ b/src/RustPlusBot.Persistence/Credentials/IFcmRegistrationStore.cs @@ -0,0 +1,42 @@ +using RustPlusBot.Domain.Credentials; + +namespace RustPlusBot.Persistence.Credentials; + +/// +/// Persists per-user FCM listener registrations. Credentials are protected at rest. Lives in the +/// persistence layer (not Abstractions) because it returns the Domain +/// type, and Abstractions has no Domain reference. +/// +public interface IFcmRegistrationStore +{ + /// Inserts or refreshes the registration for (guild, owner), setting it Active. Returns its id. + /// Owning Discord guild snowflake. + /// The Discord user connecting credentials. + /// The plaintext FCM credentials JSON (protected before storage). + /// A cancellation token. + /// The registration's id. + Task UpsertAsync( + ulong guildId, + ulong ownerUserId, + string fcmCredentialsJson, + CancellationToken cancellationToken = default); + + /// Lists every Active registration across all guilds (used at startup). + /// A cancellation token. + /// The active registrations. + Task> ListActiveAsync(CancellationToken cancellationToken = default); + + /// Sets a registration's status. + /// The registration id. + /// The new status. + /// A cancellation token. + /// A task that completes when the status has been persisted. + Task SetStatusAsync(Guid id, FcmRegistrationStatus status, CancellationToken cancellationToken = default); + + /// Gets the registration for (guild, owner), or null. + /// Owning Discord guild snowflake. + /// The Discord user. + /// A cancellation token. + /// The registration, or null. + Task GetAsync(ulong guildId, ulong ownerUserId, CancellationToken cancellationToken = default); +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs new file mode 100644 index 00000000..68a0f4be --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.Designer.cs @@ -0,0 +1,463 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using RustPlusBot.Persistence; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + [DbContext(typeof(BotDbContext))] + [Migration("20260615085447_PairingCredentials")] + partial class PairingCredentials + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.9"); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("ParentId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Guilds"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("JoinedAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasColumnType("TEXT"); + + b.HasKey("GuildId", "UserId"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("GlobalName") + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b => + { + b.Property("RustServerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveCredentialId") + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("IsHealthy") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("RustServerId"); + + b.ToTable("ConnectionStates"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedFcmCredentials") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + + b.Property("ProtectedPlayerToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SteamId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); + + b.ToTable("PlayerCredentials"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EntityId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("PairedEntities"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EventKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "RustServerId"); + + b.ToTable("EventSubscriptions"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b => + { + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("GuildId"); + + b.ToTable("GuildSettings"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("Ip") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + + b.ToTable("RustServers"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordCategoryId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId") + .IsUnique(); + + b.ToTable("ProvisionedCategories"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChannelKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "ChannelKey") + .IsUnique(); + + b.ToTable("ProvisionedChannels"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("DiscordMessageId") + .HasColumnType("INTEGER"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("MessageKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RustServerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "MessageKey") + .IsUnique(); + + b.ToTable("ProvisionedMessages"); + }); + + modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b => + { + b.HasOne("Persistord.Core.Entities.ChannelEntity", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs new file mode 100644 index 00000000..f7ba1456 --- /dev/null +++ b/src/RustPlusBot.Persistence/Migrations/20260615085447_PairingCredentials.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RustPlusBot.Persistence.Migrations +{ + /// + public partial class PairingCredentials : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PlayerCredentials_GuildId_RustServerId", + table: "PlayerCredentials"); + + migrationBuilder.DropColumn( + name: "ProtectedFcmCredentials", + table: "PlayerCredentials"); + + migrationBuilder.CreateTable( + name: "FcmRegistrations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + GuildId = table.Column(type: "INTEGER", nullable: false), + OwnerUserId = table.Column(type: "INTEGER", nullable: false), + ProtectedFcmCredentials = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FcmRegistrations", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RustServers_GuildId_Ip_Port", + table: "RustServers", + columns: new[] { "GuildId", "Ip", "Port" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerCredentials_GuildId_RustServerId_OwnerUserId", + table: "PlayerCredentials", + columns: new[] { "GuildId", "RustServerId", "OwnerUserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerCredentials_RustServerId", + table: "PlayerCredentials", + column: "RustServerId"); + + migrationBuilder.CreateIndex( + name: "IX_FcmRegistrations_GuildId_OwnerUserId", + table: "FcmRegistrations", + columns: new[] { "GuildId", "OwnerUserId" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_PlayerCredentials_RustServers_RustServerId", + table: "PlayerCredentials", + column: "RustServerId", + principalTable: "RustServers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_PlayerCredentials_RustServers_RustServerId", + table: "PlayerCredentials"); + + migrationBuilder.DropTable( + name: "FcmRegistrations"); + + migrationBuilder.DropIndex( + name: "IX_RustServers_GuildId_Ip_Port", + table: "RustServers"); + + migrationBuilder.DropIndex( + name: "IX_PlayerCredentials_GuildId_RustServerId_OwnerUserId", + table: "PlayerCredentials"); + + migrationBuilder.DropIndex( + name: "IX_PlayerCredentials_RustServerId", + table: "PlayerCredentials"); + + migrationBuilder.AddColumn( + name: "ProtectedFcmCredentials", + table: "PlayerCredentials", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_PlayerCredentials_GuildId_RustServerId", + table: "PlayerCredentials", + columns: new[] { "GuildId", "RustServerId" }); + } + } +} diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs index 215fe39b..4863537a 100644 --- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs +++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs @@ -145,7 +145,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ConnectionStates"); }); - modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -161,6 +161,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GuildId", "OwnerUserId") + .IsUnique(); + + b.ToTable("FcmRegistrations"); + }); + + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GuildId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .HasColumnType("INTEGER"); + b.Property("ProtectedPlayerToken") .IsRequired() .HasColumnType("TEXT"); @@ -176,7 +202,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("GuildId", "RustServerId"); + b.HasIndex("RustServerId"); + + b.HasIndex("GuildId", "RustServerId", "OwnerUserId") + .IsUnique(); b.ToTable("PlayerCredentials"); }); @@ -279,6 +308,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("GuildId"); + b.HasIndex("GuildId", "Ip", "Port") + .IsUnique(); + b.ToTable("RustServers"); }); @@ -390,6 +422,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b => + { + b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) + .WithMany() + .HasForeignKey("RustServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b => { b.HasOne("RustPlusBot.Domain.Servers.RustServer", null) diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs index 5ae42e94..efabc3ec 100644 --- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs +++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; diff --git a/src/RustPlusBot.Persistence/Servers/IServerService.cs b/src/RustPlusBot.Persistence/Servers/IServerService.cs index 57f77484..81490e5d 100644 --- a/src/RustPlusBot.Persistence/Servers/IServerService.cs +++ b/src/RustPlusBot.Persistence/Servers/IServerService.cs @@ -39,4 +39,23 @@ Task AddAsync(ulong guildId, /// A cancellation token. /// The server, or null if not found in this guild. Task GetAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken = default); + + /// + /// Returns the server matching (guild, ip, port), creating it if none exists. Handles the + /// concurrent-create race (two pairings for the same new server) by re-reading the winner. + /// + /// Owning Discord guild snowflake. + /// The Discord user whose pairing created it (only used on create). + /// Display name (only used on create). + /// Server host or ip. + /// Rust+ app port. + /// A cancellation token. + /// The resolved server and whether it was newly created. + Task<(RustServer Server, bool Created)> ResolveOrCreateByEndpointAsync( + ulong guildId, + ulong addedByUserId, + string name, + string ip, + int port, + CancellationToken cancellationToken = default); } diff --git a/src/RustPlusBot.Persistence/Servers/ServerService.cs b/src/RustPlusBot.Persistence/Servers/ServerService.cs index a20c27b5..6c23ae48 100644 --- a/src/RustPlusBot.Persistence/Servers/ServerService.cs +++ b/src/RustPlusBot.Persistence/Servers/ServerService.cs @@ -63,4 +63,46 @@ public async Task RemoveAsync( await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return true; } + + /// + public async Task<(RustServer Server, bool Created)> ResolveOrCreateByEndpointAsync( + ulong guildId, + ulong addedByUserId, + string name, + string ip, + int port, + CancellationToken cancellationToken = default) + { + var existing = await context.RustServers + .SingleOrDefaultAsync(s => s.GuildId == guildId && s.Ip == ip && s.Port == port, cancellationToken) + .ConfigureAwait(false); + if (existing is not null) + { + return (existing, false); + } + + var server = new RustServer + { + GuildId = guildId, + AddedByUserId = addedByUserId, + Name = name, + Ip = ip, + Port = port, + }; + context.RustServers.Add(server); + try + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return (server, true); + } + catch (DbUpdateException) + { + // Another pairing created the same endpoint between the read and the write; re-read the winner. + context.Entry(server).State = EntityState.Detached; + var winner = await context.RustServers + .SingleAsync(s => s.GuildId == guildId && s.Ip == ip && s.Port == port, cancellationToken) + .ConfigureAwait(false); + return (winner, false); + } + } } diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs new file mode 100644 index 00000000..19aa4b7b --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs @@ -0,0 +1,51 @@ +using System.Collections.Concurrent; +using RustPlusBot.Features.Pairing.Listening; + +namespace RustPlusBot.Features.Pairing.Tests.Fakes; + +/// A scripted : each created listener returns the next queued outcome. +internal sealed class FakePairingSource : IPairingSource +{ + private readonly ConcurrentQueue _outcomes = new(); + + private int _createCount; + + /// How many listeners have been created. + public int CreateCount => Volatile.Read(ref _createCount); + + /// The last notification callback registered by the supervisor. + public Func? LastCallback { get; private set; } + + /// Signaled when the first Connected outcome fires. + public TaskCompletionSource ConnectedSignal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + public IPairingListener Create( + string fcmCredentialsJson, + Func onNotification) + { + Interlocked.Increment(ref _createCount); + LastCallback = onNotification; + var outcome = _outcomes.TryDequeue(out var next) ? next : PairingConnectOutcome.Connected; + return new FakeListener(outcome, () => ConnectedSignal.TrySetResult()); + } + + /// Enqueues an outcome to be returned by the next listener created. + /// The outcome the next listener will return from ConnectAsync. + public void EnqueueOutcome(PairingConnectOutcome outcome) => _outcomes.Enqueue(outcome); + + private sealed class FakeListener(PairingConnectOutcome outcome, Action onConnected) : IPairingListener + { + public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + if (outcome == PairingConnectOutcome.Connected) + { + onConnected(); + } + + return Task.FromResult(outcome); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs new file mode 100644 index 00000000..18a8e64c --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/Fakes/RecordingOwnerNotifier.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; +using RustPlusBot.Features.Pairing.Notifications; + +namespace RustPlusBot.Features.Pairing.Tests.Fakes; + +/// Records expiry notifications instead of DMing. +internal sealed class RecordingOwnerNotifier : IOwnerNotifier +{ + /// All (guild, owner) pairs that received an expiry notification. + public ConcurrentBag<(ulong Guild, ulong Owner)> Notified { get; } = []; + + /// + public Task NotifyCredentialsExpiredAsync(ulong guildId, + ulong ownerUserId, + CancellationToken cancellationToken = default) + { + Notified.Add((guildId, ownerUserId)); + return Task.CompletedTask; + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs new file mode 100644 index 00000000..ec649dc6 --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/FcmCredentialValidatorTests.cs @@ -0,0 +1,22 @@ +using RustPlusBot.Features.Pairing.Validation; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class FcmCredentialValidatorTests +{ + [Theory] + [InlineData("{\"fcm\":{\"token\":\"abc\"}}")] + [InlineData(" {\"a\":1} ")] + public void IsWellFormed_True_ForJsonObject(string json) => + Assert.True(FcmCredentialValidator.IsWellFormed(json)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + [InlineData("not json")] + [InlineData("[1,2,3]")] + [InlineData("42")] + public void IsWellFormed_False_ForNonObjectOrGarbage(string? json) => + Assert.False(FcmCredentialValidator.IsWellFormed(json)); +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs new file mode 100644 index 00000000..85fbda89 --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingHandlerTests.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Features.Pairing.Pairing; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Credentials; +using RustPlusBot.Persistence.Servers; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class PairingHandlerTests +{ + private static ICredentialProtector PassThrough() + { + var p = Substitute.For(); + p.Protect(Arg.Any()).Returns(c => c.Arg()); + return p; + } + + private static PairingHandler CreateHandler(BotDbContext context, IEventBus bus) => + new(new ServerService(context), new CredentialStore(context, PassThrough()), bus, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + + private static PairingNotification ServerPairing(string ip = "1.2.3.4", int port = 28015, ulong steam = 7UL) => + new(PairingKind.Server, "Rustopia", ip, port, steam, "ptoken"); + + [Fact] + public async Task ServerPairing_CreatesServerCredentialAndFiresEventOnce() + { + var (context, connection) = TestDb.Create(); + await using var _ = context; + await using var __ = connection; + var bus = Substitute.For(); + var handler = CreateHandler(context, bus); + + await handler.HandleAsync(10UL, 99UL, ServerPairing(), CancellationToken.None); + + var server = await context.RustServers.SingleAsync(); + Assert.Equal("Rustopia", server.Name); + var credential = await context.PlayerCredentials.SingleAsync(); + Assert.Equal(server.Id, credential.RustServerId); + Assert.Equal(CredentialStatus.Active, credential.Status); + await bus.Received(1).PublishAsync( + Arg.Is(e => e.GuildId == 10UL && e.ServerId == server.Id), + Arg.Any()); + } + + [Fact] + public async Task SecondOwnerSameServer_AddsStandbyCredentialNoEvent() + { + var (context, connection) = TestDb.Create(); + await using var _ = context; + await using var __ = connection; + var bus = Substitute.For(); + var handler = CreateHandler(context, bus); + + await handler.HandleAsync(10UL, 1UL, ServerPairing(steam: 1UL), CancellationToken.None); + bus.ClearReceivedCalls(); + await handler.HandleAsync(10UL, 2UL, ServerPairing(steam: 2UL), CancellationToken.None); + + Assert.Single(await context.RustServers.ToListAsync()); + Assert.Equal(2, await context.PlayerCredentials.CountAsync()); + var second = await context.PlayerCredentials.SingleAsync(c => c.OwnerUserId == 2UL); + Assert.Equal(CredentialStatus.Standby, second.Status); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EntityPairing_IsIgnored() + { + var (context, connection) = TestDb.Create(); + await using var _ = context; + await using var __ = connection; + var bus = Substitute.For(); + var handler = CreateHandler(context, bus); + + await handler.HandleAsync(10UL, 1UL, + new PairingNotification(PairingKind.Entity, "x", "1.2.3.4", 28015, 1UL, "t"), CancellationToken.None); + + Assert.Empty(await context.RustServers.ToListAsync()); + Assert.Empty(await context.PlayerCredentials.ToListAsync()); + await bus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs new file mode 100644 index 00000000..891283cf --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs @@ -0,0 +1,38 @@ +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Features.Pairing; +using RustPlusBot.Features.Pairing.Pairing; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Persistence; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class PairingRegistrationTests +{ + [Fact] + public async Task Services_Resolve() + { + var services = new ServiceCollection(); + services.AddSingleton(new DiscordSocketClient()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Substitute.For()); + services.AddLogging(); + services.AddBotPersistence("DataSource=:memory:"); + services.AddOptions(); + services.AddPairing(); + + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateScopes = true + }); + + Assert.NotNull(provider.GetRequiredService()); + await using var scope = provider.CreateAsyncScope(); + Assert.NotNull(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs new file mode 100644 index 00000000..334aa34a --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs @@ -0,0 +1,187 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Events; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Features.Pairing.Listening; +using RustPlusBot.Features.Pairing.Notifications; +using RustPlusBot.Features.Pairing.Pairing; +using RustPlusBot.Features.Pairing.Supervisor; +using RustPlusBot.Features.Pairing.Tests.Fakes; +using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Credentials; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class PairingSupervisorTests +{ + private static Harness CreateHarness(FakePairingSource source, PairingOptions? options = null) + { + var protector = Substitute.For(); + protector.Protect(Arg.Any()).Returns(c => c.Arg()); + protector.Unprotect(Arg.Any()).Returns(c => c.Arg()); + + var clock = Substitute.For(); + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(clock); + services.AddSingleton(protector); + services.AddSingleton(); + + // Keep one open in-memory SQLite connection (singleton) and give each scope its OWN context. + var (seed, connection) = TestDb.Create(); + seed.Dispose(); + services.AddSingleton(connection); + services.AddScoped(sp => new BotDbContext( + new DbContextOptionsBuilder().UseSqlite(sp.GetRequiredService()).Options)); + services.AddScoped(); + services.AddScoped(_ => Substitute.For()); + + var notifier = new RecordingOwnerNotifier(); + services.AddSingleton(notifier); + services.AddSingleton(source); + services.AddSingleton(Options.Create(options ?? new PairingOptions + { + ProbeTimeout = TimeSpan.FromSeconds(1), + InitialRetryDelay = TimeSpan.FromMilliseconds(5), + MaxRetryDelay = TimeSpan.FromMilliseconds(20), + })); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return new Harness + { + Provider = provider, + Source = source, + Notifier = notifier, + Supervisor = provider.GetRequiredService(), + }; + } + + private static async Task SeedRegistrationAsync(ServiceProvider provider, ulong guild, ulong owner) + { + using var scope = provider.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + return await store.UpsertAsync(guild, owner, "{}"); + } + + [Fact] + public async Task EnsureListener_Connected_ReturnsConnectedAndStaysActive() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + + var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + Assert.Equal(PairingConnectOutcome.Connected, outcome); + Assert.Empty(h.Notifier.Notified); + using var scope = h.Provider.CreateScope(); + var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL); + Assert.Equal(FcmRegistrationStatus.Active, reg!.Status); + } + + [Fact] + public async Task EnsureListener_Rejected_MarksExpiredAndNotifies() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Rejected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + + var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + Assert.Equal(PairingConnectOutcome.Rejected, outcome); + using var scope = h.Provider.CreateScope(); + var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL); + Assert.Equal(FcmRegistrationStatus.Expired, reg!.Status); + Assert.Contains((10UL, 99UL), h.Notifier.Notified); + } + + [Fact] + public async Task EnsureListener_TimeoutThenConnected_RetriesAndStaysActive() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Timeout); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + + var outcome = await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + Assert.Equal(PairingConnectOutcome.Timeout, outcome); + + await h.Source.ConnectedSignal.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(h.Source.CreateCount >= 2); + using var scope = h.Provider.CreateScope(); + var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL); + Assert.Equal(FcmRegistrationStatus.Active, reg!.Status); + } + + [Fact] + public async Task ConnectedListener_DeliversNotificationWithoutFaulting() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + var note = new PairingNotification(PairingKind.Server, "S", "1.2.3.4", 28015, 7UL, "tok"); + await h.Source.LastCallback!(note, CancellationToken.None); + + using var scope = h.Provider.CreateScope(); + var reg = await scope.ServiceProvider.GetRequiredService().GetAsync(10UL, 99UL); + Assert.Equal(FcmRegistrationStatus.Active, reg!.Status); + } + + [Fact] + public async Task StartAllActive_StartsAListenerPerActiveRegistration() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 1UL); + await SeedRegistrationAsync(h.Provider, 10UL, 2UL); + + await h.Supervisor.StartAllActiveAsync(); + + Assert.Equal(2, h.Source.CreateCount); + } + + [Fact] + public async Task EnsureListener_CalledTwiceForSameOwner_RestartsListener() + { + var source = new FakePairingSource(); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + source.EnqueueOutcome(PairingConnectOutcome.Connected); + await using var h = CreateHarness(source); + await SeedRegistrationAsync(h.Provider, 10UL, 99UL); + + await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + await h.Supervisor.EnsureListenerAsync(10UL, 99UL); + + Assert.Equal(2, h.Source.CreateCount); + } + + private sealed class Harness : IAsyncDisposable + { + public required ServiceProvider Provider { get; init; } + public required FakePairingSource Source { get; init; } + public required RecordingOwnerNotifier Notifier { get; init; } + public required PairingSupervisor Supervisor { get; init; } + + public async ValueTask DisposeAsync() + { + await Supervisor.StopAllAsync(); + await Provider.DisposeAsync(); + } + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj new file mode 100644 index 00000000..8e789ae2 --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusBot.Features.Pairing.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs new file mode 100644 index 00000000..126c2417 --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/RustPlusFcmPairingSourceTests.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Logging.Abstractions; +using RustPlusBot.Features.Pairing.Listening; + +namespace RustPlusBot.Features.Pairing.Tests; + +public sealed class RustPlusFcmPairingSourceTests +{ + [Fact] + public async Task Create_DoesNotThrow_AndConnectReturnsRejected_ForUnparseableCredentials() + { + var source = new RustPlusFcmPairingSource(NullLogger.Instance); + + // "null" deserializes to a null Credentials, which the listener ctor rejects — Create must not throw. + var listener = source.Create("null", (_, _) => Task.CompletedTask); + var outcome = await listener.ConnectAsync(TimeSpan.FromSeconds(1), CancellationToken.None); + + Assert.Equal(PairingConnectOutcome.Rejected, outcome); + await listener.DisposeAsync(); + } +} diff --git a/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs new file mode 100644 index 00000000..8343b303 --- /dev/null +++ b/tests/RustPlusBot.Features.Pairing.Tests/TestDb.cs @@ -0,0 +1,19 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Persistence; + +namespace RustPlusBot.Features.Pairing.Tests; + +/// Creates a BotDbContext over a private in-memory SQLite connection kept open for the test. +internal static class TestDb +{ + public static (BotDbContext Context, SqliteConnection Connection) Create() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var context = new BotDbContext(options); + context.Database.Migrate(); + return (context, connection); + } +} diff --git a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs index 325626e7..37e17cd3 100644 --- a/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs +++ b/tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs @@ -75,4 +75,17 @@ public async Task ServerInfo_TitleIsServerName_AndEndpointShown() Assert.Equal("Rustopia EU", payload.Embed!.Title); Assert.Contains("1.2.3.4", payload.Embed.Description, StringComparison.Ordinal); } + + [Fact] + public async Task Setup_HasConnectAccountButton() + { + var renderer = new SetupMessageRenderer(Loc); + + var payload = await renderer.RenderAsync(Global, default); + + Assert.NotNull(payload.Components); + var buttons = payload.Components!.Components.OfType() + .SelectMany(r => r.Components).OfType(); + Assert.Contains(buttons, b => b.CustomId == "workspace:setup:connect"); + } } diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs index 84841094..a5f4e78e 100644 --- a/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs @@ -2,6 +2,7 @@ using NSubstitute; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; using RustPlusBot.Persistence.Credentials; namespace RustPlusBot.Persistence.Tests.Credentials; @@ -15,51 +16,100 @@ private static ICredentialProtector PassThroughProtector() return protector; } + private static async Task SeedServerAsync(BotDbContext context, ulong guildId, int port = 28015) + { + var server = new RustServer + { + GuildId = guildId, Name = "S", Ip = "1.1.1.1", Port = port + }; + context.RustServers.Add(server); + await context.SaveChangesAsync(); + return server.Id; + } + [Fact] - public async Task StoreAsync_ProtectsTokenAndPersistsAsStandby() + public async Task UpsertFromPairing_FirstCredentialForServer_IsActiveAndProtected() { var (context, connection) = SqliteContextFixture.Create(); await using var _ = context; await using var __ = connection; - + var serverId = await SeedServerAsync(context, 10UL); var protector = PassThroughProtector(); var store = new CredentialStore(context, protector); - var serverId = Guid.NewGuid(); - var id = await store.StoreAsync(new StoreCredentialRequest( - GuildId: 10UL, - RustServerId: serverId, - OwnerUserId: 99UL, - SteamId: 76561198000000000UL, - PlayerToken: "raw-token", - FcmCredentialsJson: "{}")); + var id = await store.UpsertFromPairingAsync( + new StoreCredentialRequest(10UL, serverId, 99UL, 76561198000000000UL, "raw-token"), + markActive: true); var saved = await context.PlayerCredentials.SingleAsync(); Assert.Equal(id, saved.Id); Assert.Equal("enc:raw-token", saved.ProtectedPlayerToken); - Assert.Equal("enc:{}", saved.ProtectedFcmCredentials); - Assert.Equal(CredentialStatus.Standby, saved.Status); - - // Both secret fields must be protected before persistence (security invariant). + Assert.Equal(CredentialStatus.Active, saved.Status); protector.Received(1).Protect("raw-token"); - protector.Received(1).Protect("{}"); } [Fact] - public async Task CountForServerAsync_CountsOnlyMatchingGuildAndServer() + public async Task UpsertFromPairing_SecondOwnerForServer_IsStandby() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverId = await SeedServerAsync(context, 10UL); + var store = new CredentialStore(context, PassThroughProtector()); + + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "t1"), + markActive: true); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 2UL, 2UL, "t2"), + markActive: false); + + var second = await context.PlayerCredentials.SingleAsync(c => c.OwnerUserId == 2UL); + Assert.Equal(CredentialStatus.Standby, second.Status); + Assert.Equal(2, await store.CountForServerAsync(10UL, serverId)); + } + + [Fact] + public async Task UpsertFromPairing_SameOwnerAgain_RefreshesTokenAndResetsInvalid() { var (context, connection) = SqliteContextFixture.Create(); await using var _ = context; await using var __ = connection; + var serverId = await SeedServerAsync(context, 10UL); + var store = new CredentialStore(context, PassThroughProtector()); + + var id = await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "old"), + markActive: true); + var row = await context.PlayerCredentials.SingleAsync(); + row.Status = CredentialStatus.Invalid; + await context.SaveChangesAsync(); + + var again = await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverId, 1UL, 1UL, "new"), + markActive: false); + + Assert.Equal(id, again); + var saved = await context.PlayerCredentials.SingleAsync(); + Assert.Equal("enc:new", saved.ProtectedPlayerToken); + Assert.Equal(CredentialStatus.Standby, saved.Status); + } + [Fact] + public async Task CountForServer_CountsOnlyMatchingGuildAndServer() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var serverA = await SeedServerAsync(context, 10UL); + var serverB = await SeedServerAsync(context, 10UL, port: 28016); + var serverAGuild20 = await SeedServerAsync(context, 20UL); var store = new CredentialStore(context, PassThroughProtector()); - var serverA = Guid.NewGuid(); - var serverB = Guid.NewGuid(); - await store.StoreAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1", "{}")); - await store.StoreAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t2", "{}")); - await store.StoreAsync(new StoreCredentialRequest(10UL, serverB, 3UL, 3UL, "t3", "{}")); - await store.StoreAsync(new StoreCredentialRequest(20UL, serverA, 4UL, 4UL, "t4", "{}")); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 1UL, 1UL, "t1"), + markActive: false); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverA, 2UL, 2UL, "t2"), + markActive: false); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(10UL, serverB, 3UL, 3UL, "t3"), + markActive: false); + await store.UpsertFromPairingAsync(new StoreCredentialRequest(20UL, serverAGuild20, 4UL, 4UL, "t4"), + markActive: false); Assert.Equal(2, await store.CountForServerAsync(10UL, serverA)); } diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs new file mode 100644 index 00000000..8b7bcf95 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/FcmRegistrationStoreTests.cs @@ -0,0 +1,110 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using RustPlusBot.Abstractions.Credentials; +using RustPlusBot.Abstractions.Time; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Persistence.Credentials; + +namespace RustPlusBot.Persistence.Tests.Credentials; + +public sealed class FcmRegistrationStoreTests +{ + private static ICredentialProtector PassThroughProtector() + { + var protector = Substitute.For(); + protector.Protect(Arg.Any()).Returns(call => "enc:" + call.Arg()); + return protector; + } + + private static IClock FixedClock() + { + var clock = Substitute.For(); + clock.UtcNow.Returns(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero)); + return clock; + } + + [Fact] + public async Task Upsert_StoresProtectedAndActive() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + + var id = await store.UpsertAsync(10UL, 99UL, "{\"a\":1}"); + + var saved = await context.FcmRegistrations.SingleAsync(); + Assert.Equal(id, saved.Id); + Assert.Equal("enc:{\"a\":1}", saved.ProtectedFcmCredentials); + Assert.Equal(FcmRegistrationStatus.Active, saved.Status); + Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt); + } + + [Fact] + public async Task Upsert_SameOwner_RefreshesAndReactivates() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + + var id = await store.UpsertAsync(10UL, 99UL, "old"); + await store.SetStatusAsync(id, FcmRegistrationStatus.Expired); + + var again = await store.UpsertAsync(10UL, 99UL, "new"); + + Assert.Equal(id, again); + var saved = await context.FcmRegistrations.SingleAsync(); + Assert.Equal("enc:new", saved.ProtectedFcmCredentials); + Assert.Equal(FcmRegistrationStatus.Active, saved.Status); + } + + [Fact] + public async Task ListActive_ReturnsOnlyActiveAcrossGuilds() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + + await store.UpsertAsync(10UL, 1UL, "a"); + var expiredId = await store.UpsertAsync(10UL, 2UL, "b"); + await store.SetStatusAsync(expiredId, FcmRegistrationStatus.Expired); + await store.UpsertAsync(20UL, 3UL, "c"); + + var active = await store.ListActiveAsync(); + + Assert.Equal(2, active.Count); + Assert.All(active, r => Assert.Equal(FcmRegistrationStatus.Active, r.Status)); + } + + [Fact] + public async Task SetStatus_UpdatesStatusAndTimestamp() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + + var id = await store.UpsertAsync(10UL, 99UL, "a"); + await store.SetStatusAsync(id, FcmRegistrationStatus.Expired); + + var saved = await context.FcmRegistrations.SingleAsync(); + Assert.Equal(FcmRegistrationStatus.Expired, saved.Status); + Assert.Equal(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero), saved.UpdatedAt); + } + + [Fact] + public async Task Get_ReturnsRegistrationForOwner_OrNull() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var store = new FcmRegistrationStore(context, PassThroughProtector(), FixedClock()); + + await store.UpsertAsync(10UL, 99UL, "a"); + + Assert.NotNull(await store.GetAsync(10UL, 99UL)); + Assert.Null(await store.GetAsync(10UL, 1UL)); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs new file mode 100644 index 00000000..2f5bc289 --- /dev/null +++ b/tests/RustPlusBot.Persistence.Tests/Credentials/PairingSchemaTests.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using RustPlusBot.Domain.Credentials; +using RustPlusBot.Domain.Servers; + +namespace RustPlusBot.Persistence.Tests.Credentials; + +public sealed class PairingSchemaTests +{ + [Fact] + public async Task RemovingServer_CascadeDeletesItsCredentials() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + var server = new RustServer + { + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 + }; + context.RustServers.Add(server); + context.PlayerCredentials.Add(new PlayerCredential + { + GuildId = 10UL, + RustServerId = server.Id, + OwnerUserId = 1UL, + SteamId = 1UL, + ProtectedPlayerToken = "x", + Status = CredentialStatus.Active + }); + await context.SaveChangesAsync(); + + context.RustServers.Remove(server); + await context.SaveChangesAsync(); + + Assert.Empty(await context.PlayerCredentials.ToListAsync()); + } + + [Fact] + public async Task DuplicateServerEndpoint_ViolatesUniqueIndex() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + context.RustServers.Add(new RustServer + { + GuildId = 10UL, Name = "A", Ip = "1.1.1.1", Port = 28015 + }); + context.RustServers.Add(new RustServer + { + GuildId = 10UL, Name = "B", Ip = "1.1.1.1", Port = 28015 + }); + + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } + + [Fact] + public async Task DuplicateRegistrationForOwner_ViolatesUniqueIndex() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + + context.FcmRegistrations.Add(new FcmRegistration + { + GuildId = 10UL, OwnerUserId = 1UL, ProtectedFcmCredentials = "a" + }); + context.FcmRegistrations.Add(new FcmRegistration + { + GuildId = 10UL, OwnerUserId = 1UL, ProtectedFcmCredentials = "b" + }); + + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } +} diff --git a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs index 7ad23bac..f811dc00 100644 --- a/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/PersistenceRegistrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Persistence; +using RustPlusBot.Persistence.Credentials; using RustPlusBot.Persistence.Servers; namespace RustPlusBot.Persistence.Tests; @@ -29,5 +30,6 @@ public void AddBotPersistence_RegistersDbContextFactoryAndScopedServices() // ICredentialStore is registered here, but its ICredentialProtector dependency is // supplied by the Host, so verify the registration descriptor rather than resolving it. Assert.Contains(services, d => d.ServiceType == typeof(ICredentialStore)); + Assert.Contains(services, d => d.ServiceType == typeof(IFcmRegistrationStore)); } } diff --git a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs index b188371a..8e8baaf6 100644 --- a/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs +++ b/tests/RustPlusBot.Persistence.Tests/Servers/ServerServiceTests.cs @@ -50,4 +50,50 @@ public async Task RemoveAsync_OnlyRemovesWithinGuild() Assert.True(await service.RemoveAsync(10UL, server.Id)); Assert.Empty(await service.ListAsync(10UL)); } + + [Fact] + public async Task ResolveOrCreateByEndpoint_CreatesWhenNew() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var service = new ServerService(context); + + var (server, created) = await service.ResolveOrCreateByEndpointAsync(10UL, 99UL, "Main", "1.2.3.4", 28015); + + Assert.True(created); + Assert.NotEqual(Guid.Empty, server.Id); + Assert.Single(await service.ListAsync(10UL)); + } + + [Fact] + public async Task ResolveOrCreateByEndpoint_ReturnsExistingForSameEndpoint() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var service = new ServerService(context); + + var first = await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "Main", "1.2.3.4", 28015); + var second = await service.ResolveOrCreateByEndpointAsync(10UL, 2UL, "Main again", "1.2.3.4", 28015); + + Assert.True(first.Created); + Assert.False(second.Created); + Assert.Equal(first.Server.Id, second.Server.Id); + Assert.Single(await service.ListAsync(10UL)); + } + + [Fact] + public async Task ResolveOrCreateByEndpoint_DifferentPortIsDistinct() + { + var (context, connection) = SqliteContextFixture.Create(); + await using var _ = context; + await using var __ = connection; + var service = new ServerService(context); + + await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "A", "1.2.3.4", 28015); + await service.ResolveOrCreateByEndpointAsync(10UL, 1UL, "B", "1.2.3.4", 28016); + + Assert.Equal(2, (await service.ListAsync(10UL)).Count); + } }