diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61b70ec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git/ +.github/ +.vs/ +**/bin/ +**/obj/ +**/TestResults/ +coverage*/ +tests/ +docs/ +scripts/ +*.md +*.suo +*.user +*.log +src/SimPle.Api/.env +src/SimPle.Api/appsettings.Development.json +src/SimPle.Api/appsettings.Local.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2eeff3b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd216a..75f48bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,30 +6,138 @@ on: pull_request: branches: ["main"] +# Keep the normal validation token read-only. Artifact provenance gets its write +# permissions only in the dedicated, push-only job below. +permissions: + contents: read + jobs: backend: - name: Build, test, vulnerability scan + name: Build, test, package, and container smoke runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + + env: + MIGRATION_TEST_CONNECTION_STRING: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up .NET 8 - uses: actions/setup-dotnet@v4 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 with: dotnet-version: "8.0.x" - name: Restore - run: dotnet restore + # NuGet lock files are not in the repository yet. Do not use + # --locked-mode until they are intentionally committed, or every CI + # run would fail before it can test the application. + run: dotnet restore SimPle.sln - name: Build - run: dotnet build --no-restore --configuration Release + run: dotnet build SimPle.sln --no-restore --configuration Release - name: Unit tests - run: dotnet test tests/SimPle.UnitTests/SimPle.UnitTests.csproj --no-build --configuration Release + run: dotnet test tests/SimPle.UnitTests/SimPle.UnitTests.csproj --no-build --configuration Release --logger "trx;LogFileName=unit.trx" --results-directory TestResults + + - name: Integration and real-PostgreSQL migration tests + run: dotnet test tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj --no-build --configuration Release --logger "trx;LogFileName=integration.trx" --results-directory TestResults + + - name: Fail on high or critical NuGet vulnerabilities + shell: bash + run: | + set -o pipefail + dotnet list SimPle.sln package --vulnerable --include-transitive | tee TestResults/nuget-vulnerabilities.txt + if grep -Eq ' (High|Critical) ' TestResults/nuget-vulnerabilities.txt; then + echo "High or critical NuGet vulnerability found. Update it or add an owned, time-bounded waiver." + exit 1 + fi + + - name: Generate CycloneDX SBOM + run: | + dotnet tool install --tool-path .tools cyclonedx --version 6.2.0 + ./.tools/dotnet-CycloneDX SimPle.sln --output artifacts/sbom --json + + - name: Build container image + run: docker build --tag simple-backend:${{ github.sha }} . + + - name: Smoke the non-root, read-only container + shell: bash + run: | + set -euo pipefail + # Liveness deliberately proves only that the process serves HTTP. The + # integration job above owns real PostgreSQL migration verification; + # readiness is expected to stay unhealthy until a migration job runs. + docker run --detach --name simple-backend-smoke --network host \ + --read-only --tmpfs /tmp:rw,nosuid,nodev,noexec \ + -e 'ConnectionStrings__DefaultConnection=Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres' \ + -e 'Jwt__SecretKey=ci-smoke-jwt-secret-that-is-at-least-thirty-two-characters' \ + -e 'LobbyCredential__Key=ci-smoke-lobby-key-that-is-at-least-thirty-two-characters' \ + -e 'Recaptcha__SecretKey=ci-smoke-recaptcha-secret' \ + -e 'Google__ClientId=ci-smoke.apps.googleusercontent.com' \ + -e 'Email__SmtpHost=localhost' \ + -e 'Email__Username=ci-smoke@example.test' \ + -e 'Email__Password=ci-smoke-password' \ + -e 'Email__From=ci-smoke@example.test' \ + -e 'Google__ClientId=ci-smoke-google-client-id' \ + -e 'Storage__Provider=S3Compatible' \ + -e 'Storage__BucketName=ci-smoke-assets' \ + -e 'Storage__Region=us-east-1' \ + -e 'Storage__AccessKey=ci-smoke-access-key' \ + -e 'Storage__SecretKey=ci-smoke-secret-key' \ + -e 'Storage__ProfilePrefix=profile-assets' \ + simple-backend:${{ github.sha }} + for attempt in {1..30}; do + if curl --fail --silent --show-error http://127.0.0.1:8080/health/live | grep -qx '{"status":"healthy"}'; then + docker exec simple-backend-smoke id | grep -q 'uid=1654(app)' + exit 0 + fi + sleep 1 + done + docker logs simple-backend-smoke + exit 1 - - name: Integration tests - run: dotnet test tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj --no-build --configuration Release + - name: Remove smoke container + if: always() + run: docker rm --force simple-backend-smoke || true - - name: NuGet vulnerability scan - run: dotnet list package --vulnerable + - name: Upload test and SBOM evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backend-evidence-${{ github.sha }} + path: | + TestResults/ + artifacts/sbom/ + if-no-files-found: error + retention-days: 30 + + attest-sbom: + name: Attest backend SBOM + needs: backend + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + attestations: write + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: backend-evidence-${{ github.sha }} + path: evidence + + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: evidence/artifacts/sbom/*.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..034c394 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,29 @@ +name: CodeQL + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "23 4 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze C# + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: csharp + build-mode: manual + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: "8.0.x" + - run: dotnet build SimPle.sln --configuration Release + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..679d589 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,53 @@ +name: Publish immutable backend image + +on: + workflow_dispatch: + +# Run this only for an already-green commit on main. The resulting digest and +# provenance attestation are release evidence; a mutable tag is never enough. +permissions: + contents: read + packages: write + attestations: write + id-token: write + +jobs: + publish: + name: Publish and attest backend image + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + env: + IMAGE_NAME: ghcr.io/simpleplatform/simple-backend + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push immutable candidate + id: push + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6.19.0 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + + - name: Attest image build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Record digest in job summary + run: | + echo '### Immutable backend image' >> "$GITHUB_STEP_SUMMARY" + echo "\`${IMAGE_NAME}@${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9511c3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["src/SimPle.Api/SimPle.Api.csproj", "src/SimPle.Api/"] +COPY ["src/SimPle.Application/SimPle.Application.csproj", "src/SimPle.Application/"] +COPY ["src/SimPle.Domain/SimPle.Domain.csproj", "src/SimPle.Domain/"] +COPY ["src/SimPle.Infrastructure/SimPle.Infrastructure.csproj", "src/SimPle.Infrastructure/"] +COPY ["src/SimPle.Shared/SimPle.Shared.csproj", "src/SimPle.Shared/"] +RUN dotnet restore "src/SimPle.Api/SimPle.Api.csproj" + +COPY src/ ./src/ +RUN dotnet publish "src/SimPle.Api/SimPle.Api.csproj" --configuration Release --no-restore --output /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app + +# Container Apps probes this port. Secrets are injected by the platform as environment variables, never copied here. +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ + DOTNET_EnableDiagnostics=0 +EXPOSE 8080 + +COPY --from=build /app/publish ./ + +# The official ASP.NET image supplies this non-root user (UID 1654). The application does not require a writable +# filesystem, allowing the deployment manifest to set a read-only root filesystem as an additional hardening layer. +USER $APP_UID +ENTRYPOINT ["dotnet", "SimPle.Api.dll"] diff --git a/src/SimPle.Api/Controllers/ChatController.cs b/src/SimPle.Api/Controllers/ChatController.cs new file mode 100644 index 0000000..e70894f --- /dev/null +++ b/src/SimPle.Api/Controllers/ChatController.cs @@ -0,0 +1,127 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SimPle.Api.Models; +using SimPle.Application.Chat; +using SimPle.Application.Realtime.Contracts; +using SimPle.Shared.Common; +using Swashbuckle.AspNetCore.Annotations; + +namespace SimPle.Api.Controllers; + +/// +/// The Module 7 chat REST surface (docs/specs/module-07-realtime-presence-chat-spec.md, "REST"). Only history and +/// delete are REST routes — sending a message is hub-only (RealtimeHub.SendLobbyMessage); there is no REST +/// send endpoint in the approved API contract, so none is added here. +/// +/// Follows 's conventions: the actor is always the JWT sub claim, state- +/// changing routes require the X-Requested-With: XMLHttpRequest CSRF header, and privacy-safe 404s collapse +/// missing/unauthorized/nonexistent into the identical . +/// +[ApiController] +[Route("api/chat")] +[Authorize] +[Produces("application/json")] +[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)] +public sealed class ChatController : ControllerBase +{ + private readonly IChatService _chat; + + public ChatController(IChatService chat) + { + _chat = chat; + } + + [HttpGet("lobbies/{lobbyId:guid}/messages")] + [SwaggerOperation( + Summary = "Lobby chat history (keyset cursor paged)", + Description = "limit default 30, cap 50. Ordered (createdAt, id). direction: before (scrollback, default) " + + "| after (reconnect repair). A private or foreign lobby is a privacy-safe Chat.NotFound.", + OperationId = "Chat_GetHistory", Tags = new[] { "Chat" })] + [ProducesResponseType(typeof(CursorPage), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetHistory( + [FromRoute] Guid lobbyId, + [FromQuery] string? cursor, + [FromQuery] string direction = "before", + [FromQuery] int? limit = null, + CancellationToken ct = default) + { + if (!TryGetUserId(out var userId)) return Unauthorized(); + + if (!Enum.TryParse(direction, ignoreCase: true, out var parsedDirection)) + return BadRequest(Error(ChatErrors.ValidationFailed, "direction must be 'before' or 'after'.")); + + Response.Headers.CacheControl = "private, no-store"; + var result = await _chat.GetHistoryAsync(userId, lobbyId, parsedDirection, cursor, limit, ct); + return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!); + } + + [HttpDelete("messages/{messageId:guid}")] + [SwaggerOperation( + Summary = "Author: delete a chat message", + Description = "Produces a tombstone (body cleared, Deleted=true) and fans out ChatMessageDeleted. A retried " + + "delete on an already-deleted message is idempotent and replays the original DeletedAtUtc.", + OperationId = "Chat_DeleteMessage", Tags = new[] { "Chat" })] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + public async Task DeleteMessage([FromRoute] Guid messageId, CancellationToken ct) + { + if (!HasCsrfHeader()) return MissingCsrfHeader(); + if (!TryGetUserId(out var userId)) return Unauthorized(); + + var result = await _chat.DeleteAsync(userId, messageId, ct); + return result.IsSuccess ? NoContent() : MapError(result.Error!); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private const string CsrfHeader = "X-Requested-With"; + private const string CsrfHeaderValue = "XMLHttpRequest"; + + private bool HasCsrfHeader() => + string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal); + + private IActionResult MissingCsrfHeader() => BadRequest(Error( + "Auth.CsrfHeaderRequired", + $"The {CsrfHeader} header is required for this request.")); + + private bool TryGetUserId(out Guid userId) => + Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId); + + /// Maps the M07-B2 chat error catalogue to HTTP. Only (not the + /// author on delete) is a 403; everything else that would otherwise disclose existence collapses to 404. + private IActionResult MapError(Error error) + { + var body = new ApiErrorResponse(new ApiErrorDetail(error.Code, error.Message, error.RetryAfterUtc)); + + switch (error.Code) + { + case ChatErrors.NotFound: + return NotFound(body); + + case ChatErrors.Forbidden: + return StatusCode(StatusCodes.Status403Forbidden, body); + + case ChatErrors.RateLimitExceeded: + if (error.RetryAfterUtc is DateTime until) + { + var seconds = Math.Max(0, (int)Math.Ceiling((until - DateTime.UtcNow).TotalSeconds)); + Response.Headers.RetryAfter = seconds.ToString(); + } + return StatusCode(StatusCodes.Status429TooManyRequests, body); + + default: + // Chat.InvalidBody, Chat.ProfanityRejected, Chat.MessageExpired, Validation.Failed, + // Pagination.InvalidCursor, Auth.CsrfHeaderRequired. + return BadRequest(body); + } + } + + private static ApiErrorResponse Error(string code, string message) => + new(new ApiErrorDetail(code, message)); +} diff --git a/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs b/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs new file mode 100644 index 0000000..628124d --- /dev/null +++ b/src/SimPle.Api/Health/DatabaseReadinessHealthCheck.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.Api.Health; + +/// +/// Verifies that PostgreSQL accepts a connection and that the deployed schema is current. The endpoint response is +/// intentionally generic; diagnostic detail remains in normal application logs and deployment tooling. +/// +public sealed class DatabaseReadinessHealthCheck : IHealthCheck +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public DatabaseReadinessHealthCheck( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Database.CanConnectAsync(cancellationToken)) + return HealthCheckResult.Unhealthy("Database readiness check failed."); + + // WebApplicationFactory uses EF's in-memory provider. Production uses Npgsql, where this additionally + // prevents the app from receiving traffic before its migration job has completed. + if (db.Database.IsRelational() && (await db.Database.GetPendingMigrationsAsync(cancellationToken)).Any()) + return HealthCheckResult.Unhealthy("Database readiness check failed."); + + return HealthCheckResult.Healthy(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "Database readiness check failed."); + return HealthCheckResult.Unhealthy("Database readiness check failed."); + } + } +} diff --git a/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs b/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs new file mode 100644 index 0000000..8c857fd --- /dev/null +++ b/src/SimPle.Api/Health/StorageConfigurationHealthCheck.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Options; + +namespace SimPle.Api.Health; + +/// +/// Confirms that the S3-compatible storage integration is configured without issuing a network call on every probe. +/// Media reachability belongs to deployment smoke tests; a readiness endpoint must remain fast and side-effect free. +/// +public sealed class StorageConfigurationHealthCheck : IHealthCheck +{ + private readonly IOptions _options; + + public StorageConfigurationHealthCheck(IOptions options) => _options = options; + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var options = _options.Value; + var isConfigured = + IsConfiguredValue(options.Provider) && + IsConfiguredValue(options.BucketName) && + IsConfiguredValue(options.Region) && + IsConfiguredValue(options.AccessKey) && + IsConfiguredValue(options.SecretKey) && + IsConfiguredValue(options.ProfilePrefix) && + options.UploadUrlExpiryMinutes > 0 && + options.ReadUrlExpiryMinutes > 0 && + (string.IsNullOrWhiteSpace(options.ServiceUrl) || + Uri.TryCreate(options.ServiceUrl, UriKind.Absolute, out var serviceUri) && + (serviceUri.Scheme == Uri.UriSchemeHttp || serviceUri.Scheme == Uri.UriSchemeHttps)); + + return Task.FromResult(isConfigured + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Storage readiness check failed.")); + } + + private static bool IsConfiguredValue(string? value) => + !string.IsNullOrWhiteSpace(value) && + !value.StartsWith("CONFIGURE", StringComparison.OrdinalIgnoreCase) && + !value.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs b/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs new file mode 100644 index 0000000..02c2e57 --- /dev/null +++ b/src/SimPle.Api/Health/WorkerReadinessHealthCheck.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SimPle.Infrastructure.Health; + +namespace SimPle.Api.Health; + +/// Ensures every required in-process background worker has started and has not reported a failed cycle. +public sealed class WorkerReadinessHealthCheck : IHealthCheck +{ + private readonly IWorkerReadinessRegistry _workers; + + public WorkerReadinessHealthCheck(IWorkerReadinessRegistry workers) => _workers = workers; + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) => + Task.FromResult(_workers.AreRequiredWorkersReady + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Background worker readiness check failed.")); +} diff --git a/src/SimPle.Api/Hubs/RealtimeHub.cs b/src/SimPle.Api/Hubs/RealtimeHub.cs new file mode 100644 index 0000000..5e0dabb --- /dev/null +++ b/src/SimPle.Api/Hubs/RealtimeHub.cs @@ -0,0 +1,219 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using SimPle.Api.Realtime; +using SimPle.Application.Chat; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Realtime; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Contracts; +using SimPle.Application.Realtime.Presence; + +namespace SimPle.Api.Hubs; + +/// +/// The single authenticated realtime endpoint (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the +/// existing HttpOnly access_token cookie JWT auth already wired in Program.cs +/// (OnMessageReceived/OnTokenValidated) — no separate auth path for realtime. +/// +/// SendLobbyMessage (added in backend session B, M07-B2) delegates entirely to : +/// idempotency, rate limiting, normalization, profanity screening, persistence, and fan-out all live there, not +/// in this file — the hub method is a thin authenticated transport shim, same as every other method here. +/// +/// Every method that touches a scope re-authorizes against current owner data — a cached principal from +/// handshake time is never trusted alone for authorization (see "the load-bearing rule" in the spec). Groups are +/// a delivery optimization only, never an authorization mechanism. +/// +[Authorize] +public sealed class RealtimeHub : Hub +{ + private readonly IPresenceRegistry _presence; + private readonly IRealtimeNotifier _notifier; + private readonly IPresenceViewerResolver _viewerResolver; + private readonly ILobbyRepository _lobbies; + private readonly IRealtimeRateLimiter _rateLimiter; + private readonly RealtimeConnectionTracker _tracker; + private readonly IReadOnlyDictionary _authorizers; + private readonly IChatService _chat; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public RealtimeHub( + IPresenceRegistry presence, + IRealtimeNotifier notifier, + IPresenceViewerResolver viewerResolver, + ILobbyRepository lobbies, + IRealtimeRateLimiter rateLimiter, + RealtimeConnectionTracker tracker, + IEnumerable authorizers, + IChatService chat, + TimeProvider timeProvider, + ILogger logger) + { + _presence = presence; + _notifier = notifier; + _viewerResolver = viewerResolver; + _lobbies = lobbies; + _rateLimiter = rateLimiter; + _tracker = tracker; + _authorizers = authorizers.ToDictionary(a => a.ScopeKind); + _chat = chat; + _timeProvider = timeProvider; + _logger = logger; + } + + public override async Task OnConnectedAsync() + { + var userId = GetUserId(); + + // Captured before any mutation, for the before/after diff below — the registry's own PresenceUpdateResult + // is only reliable straight off SetLobbyMembership; TryConnect returns a bare bool, and a GetStatus called + // immediately after it would recompute the *same* status it just set, so its own Changed flag is always + // false at that point. Comparing this snapshot against a later GetStatus is the only way to detect the + // real transition. + var beforeStatus = _presence.GetStatus(userId).Status; + + var connectionAccepted = _rateLimiter.TryAcquireConnection(userId); + var presenceAccepted = connectionAccepted && _presence.TryConnect(userId, Context.ConnectionId); + + if (!connectionAccepted || !presenceAccepted) + { + if (connectionAccepted) + _rateLimiter.ReleaseConnection(userId); + + _logger.LogInformation( + "Realtime connection rejected: connection limit reached. UserId={UserId}", userId); + throw new HubException("Realtime.ConnectionLimit"); + } + + // Capture the HubCallerContext itself, not `this` — the Hub instance is transient and disposed right + // after this method returns, so a lambda that reads the `Context` property lazily (`() => Context.Abort()`) + // throws ObjectDisposedException the moment it's invoked from a later, unrelated request. + var callerContext = Context; + _tracker.Register(userId, callerContext.ConnectionId, () => callerContext.Abort()); + + try + { + // Reconnect gap: a lobby member whose connection dropped (network blip, server restart) and now + // reconnects never actually left the lobby, so they should show InLobby immediately — not wait for a + // lobby event that isn't coming, since nothing about the lobby itself changed. + var activeLobby = await _lobbies.GetActiveLobbyForUserAsync(userId, callerContext.ConnectionAborted); + if (activeLobby is not null) + _presence.SetLobbyMembership(userId, activeLobby.Id, true); + + await BroadcastPresenceIfChangedAsync(userId, beforeStatus, callerContext.ConnectionAborted); + + var envelope = RealtimeEnvelope.ForUser(userId, Now()); + await Clients.Caller.Connected(envelope, _presence.ServerEpoch); + + await base.OnConnectedAsync(); + } + catch + { + // M07-002: everything acquired above (rate-limit lease, presence entry, tracker registration) must + // be released on any failure past this point — otherwise a client that connects then drops before + // the handshake reply completes leaks a permanent slot against its connection/rate limits. + _tracker.Unregister(userId, callerContext.ConnectionId); + _rateLimiter.ReleaseConnection(userId); + _presence.Disconnect(userId, callerContext.ConnectionId); + throw; + } + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = GetUserId(); + var beforeStatus = _presence.GetStatus(userId).Status; + + _tracker.Unregister(userId, Context.ConnectionId); + _rateLimiter.ReleaseConnection(userId); + _presence.Disconnect(userId, Context.ConnectionId); + + // CancellationToken.None, not Context.ConnectionAborted: that token belongs to the connection that just + // died, and is typically already cancelling by the time this runs — it must not cancel a broadcast meant + // for the *other* users watching this one's presence. + await BroadcastPresenceIfChangedAsync(userId, beforeStatus, CancellationToken.None); + + await base.OnDisconnectedAsync(exception); + } + + /// Privacy-safe: a missing lobby, a private lobby the caller cannot see, and an existing-but- + /// unauthorized lobby all surface the identical Lobbies.NotFound error — existence is never + /// disclosed. A lobby-scope authorizer always exists in B1; a request for a scope kind with no registered + /// authorizer (defensive only — should be unreachable while only "lobby" is ever routed here) also denies via + /// . + public async Task SubscribeLobby(Guid lobbyId) + { + var userId = GetUserId(); + var result = await AuthorizeAsync(RealtimeEnvelope.LobbyScope, userId, lobbyId, RealtimeAction.Subscribe); + if (!result.IsAllowed) + throw new HubException(result.ErrorCode); + + await Groups.AddToGroupAsync(Context.ConnectionId, RealtimeGroups.Lobby(lobbyId), Context.ConnectionAborted); + + // Revision is not known here without a repository round trip beyond what the authorizer already did; + // callers fetch the authoritative snapshot via GET /api/lobbies/{lobbyId} immediately after subscribing + // (see spec API Contract). 0 signals "fetch the snapshot yourself", never a real revision. + return new SubscribeLobbyResultDto(0); + } + + public async Task UnsubscribeLobby(Guid lobbyId) + { + await Groups.RemoveFromGroupAsync( + Context.ConnectionId, RealtimeGroups.Lobby(lobbyId), Context.ConnectionAborted); + } + + /// A duplicate returns the original message rather than erroring + /// or creating a second row — 's idempotency contract. Detailed failure + /// reasons never reach the client as free text: the message is always the stable + /// ChatErrors code, matching every other authorization failure in this hub. + public async Task SendLobbyMessage(Guid lobbyId, string body, Guid clientCommandId) + { + var userId = GetUserId(); + var result = await _chat.SendAsync(userId, lobbyId, body, clientCommandId, Context.ConnectionAborted); + if (!result.IsSuccess) + throw new HubException(result.Error!.Code); + + return new SendLobbyMessageResultDto(result.Value!); + } + + /// Throttled to at most once per 60s per connection at the presence-registry level; a throttled + /// (rejected) signal is a silent no-op — it is not an error, and it mutates nothing. + public async Task ReportActivity() + { + var userId = GetUserId(); + var beforeStatus = _presence.GetStatus(userId).Status; + + if (!_presence.TryReportActivity(userId, Context.ConnectionId)) + return; + + await BroadcastPresenceIfChangedAsync(userId, beforeStatus, Context.ConnectionAborted); + } + + private async Task AuthorizeAsync( + string scopeKind, Guid userId, Guid scopeId, RealtimeAction action) + { + if (!_authorizers.TryGetValue(scopeKind, out var authorizer)) + return RealtimeScopeAuthorizationResult.Deny(NullMatchScopeAuthorizer.ScopeNotAvailableCode); + + return await authorizer.AuthorizeAsync(userId, scopeId, action, Context.ConnectionAborted); + } + + /// Broadcasts the caller's current presence only if it actually differs from — the external diff this hub relies on throughout, since none of TryConnect/ + /// Disconnect/TryReportActivity hand back a trustworthy Changed flag of their own once a second GetStatus call + /// has already re-observed the same state. + private async Task BroadcastPresenceIfChangedAsync(Guid userId, PresenceStatus beforeStatus, CancellationToken ct) + { + var after = _presence.GetStatus(userId); + if (after.Status == beforeStatus) + return; + + var viewers = await _viewerResolver.ResolveAsync(userId, ct); + await _notifier.NotifyPresenceChangedAsync( + userId, viewers, after.Status.ToString(), after.ServerEpoch, after.UserVersion, ct); + } + + private Guid GetUserId() => Guid.Parse(Context.UserIdentifier!); + + private DateTime Now() => _timeProvider.GetUtcNow().UtcDateTime; +} diff --git a/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs b/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..80bcb9e --- /dev/null +++ b/src/SimPle.Api/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,68 @@ +using System.Diagnostics; +using SimPle.Api.Observability; + +namespace SimPle.Api.Middleware; + +/// +/// Propagates a safe correlation token to application logs and the response. Untrusted or malformed input is replaced +/// rather than logged, preventing a request header from becoming a log-injection vector. +/// +public sealed class CorrelationIdMiddleware +{ + public const string HeaderName = "X-Correlation-ID"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public CorrelationIdMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var correlationId = GetCorrelationId(context.Request.Headers[HeaderName]); + context.TraceIdentifier = correlationId; + context.Response.Headers[HeaderName] = correlationId; + Activity.Current?.SetTag("simple.correlation_id", correlationId); + + var startedAt = Stopwatch.GetTimestamp(); + + try + { + using (_logger.BeginScope(new Dictionary { ["CorrelationId"] = correlationId })) + { + await _next(context); + } + } + finally + { + RequestTelemetry.Record(context, Stopwatch.GetElapsedTime(startedAt)); + } + } + + private static string GetCorrelationId(Microsoft.Extensions.Primitives.StringValues suppliedValues) + { + if (suppliedValues.Count == 1 && IsSafeCorrelationId(suppliedValues[0])) + return suppliedValues[0]!; + + return Activity.Current?.TraceId.ToString() is { Length: > 0 } traceId + ? traceId + : Guid.NewGuid().ToString("N"); + } + + private static bool IsSafeCorrelationId(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 64) + return false; + + foreach (var character in value) + { + if (!char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_' and not '.') + return false; + } + + return true; + } +} diff --git a/src/SimPle.Api/Observability/RequestTelemetry.cs b/src/SimPle.Api/Observability/RequestTelemetry.cs new file mode 100644 index 0000000..f8c99bb --- /dev/null +++ b/src/SimPle.Api/Observability/RequestTelemetry.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace SimPle.Api.Observability; + +/// +/// Low-cardinality request measurements. Exporters are intentionally configured by the deployment environment so +/// telemetry endpoints and credentials never need to be committed to the application repository. +/// +public static class RequestTelemetry +{ + public static readonly Meter Meter = new("SimPle.Api", "1.0.0"); + + private static readonly Counter RequestCount = Meter.CreateCounter("simple.http.server.requests"); + private static readonly Histogram RequestDuration = Meter.CreateHistogram("simple.http.server.duration", "ms"); + + public static void Record(HttpContext context, TimeSpan duration) + { + // Do not attach a path, user ID, correlation ID, or exception message: those cause high-cardinality or + // sensitive telemetry. An observability backend can safely aggregate these tags for alerting. + var tags = new TagList + { + { "http.request.method", context.Request.Method }, + { "http.response.status_code", context.Response.StatusCode } + }; + + RequestCount.Add(1, tags); + RequestDuration.Record(duration.TotalMilliseconds, tags); + } +} diff --git a/src/SimPle.Api/Program.cs b/src/SimPle.Api/Program.cs index 345c7ac..0efd57e 100644 --- a/src/SimPle.Api/Program.cs +++ b/src/SimPle.Api/Program.cs @@ -6,20 +6,26 @@ using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Microsoft.IdentityModel.Tokens; +using SimPle.Api.Hubs; using SimPle.Api.Middleware; +using SimPle.Api.Health; using SimPle.Api.Models; using SimPle.Api.OpenApi; +using SimPle.Api.Realtime; using SimPle.Application; using SimPle.Application.Auth.Validators; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; using SimPle.Application.GameHost.Services; +using SimPle.Application.Realtime.Contracts; using SimPle.Domain.GameHost; using SimPle.Domain.Games; using SimPle.Domain.Lobbies; @@ -31,6 +37,17 @@ var builder = WebApplication.CreateBuilder(args); +// Container platforms consume structured stdout. Development keeps the familiar readable console provider while +// production JSON includes logging scopes such as CorrelationId for request-to-log and request-to-response tracing. +if (!builder.Environment.IsDevelopment()) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddJsonConsole(options => options.IncludeScopes = true); + // EF parameter values are redacted by default, but command text is still high-volume operational noise and can + // reveal data shape. Keep production logs structured and useful without turning them into a query transcript. + builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning); +} + if (builder.Environment.IsDevelopment()) { var localEnvPath = Path.Combine(builder.Environment.ContentRootPath, ".env"); @@ -77,6 +94,41 @@ builder.Services.AddApplicationServices(); builder.Services.AddInfrastructureServices(builder.Configuration); +// Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1): transport, +// authorization, presence only — no chat, no migration. The 16 KiB caps are enforced twice, deliberately: once +// as the hub-wide message size (MaximumReceiveMessageSize, a HubOptions concern) and once as the connection's +// buffer size (ApplicationMaxBufferSize, set where the hub is mapped below) — neither is ever set to 0 (which +// would disable the limit entirely rather than bound it). +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(RealtimeOptions.SectionName)) + .Validate( + options => options.AllowedOrigins.Length > 0 && options.AllowedOrigins.All(o => o != "*"), + "Realtime:AllowedOrigins must list one or more exact origins and must never contain a wildcard.") + .ValidateOnStart(); + +builder.Services.AddSignalR(options => +{ + options.MaximumReceiveMessageSize = 16 * 1024; + // MaximumParallelInvocationsPerClient is deliberately left unset — the default of 1 is exactly what B1 + // relies on (see config tests asserting this default is untouched). +}); + +// Maps SignalR's "user" to the JWT sub claim, matching every other consumer of the access_token cookie. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// These two need the concrete RealtimeHub type (via IHubContext), which +// Infrastructure cannot reference — hence registered here rather than in AddInfrastructureServices. The +// IRealtimeConnectionCloser registration below deliberately overrides AddInfrastructureServices' no-op default +// now that the hub actually exists. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHealthChecks() + .AddCheck("database", tags: ["ready"]) + .AddCheck("storage-configuration", tags: ["ready"]) + .AddCheck("background-workers", tags: ["ready"]); + // The composition root's list of installed Phase 2 game engines. Empty today — Module 5 hosts no product // game yet, only the test-only HiddenTokenDraft reference engine, which is never registered here. A duplicate // (Slug, EngineVersion) across two real entries throws from Create() and fails application startup, never a @@ -171,11 +223,25 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse( }); builder.Services.AddAuthorization(); -builder.Services.AddCors(options => options.AddPolicy("AllowFrontend", policy => - policy.WithOrigins(builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3000") - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials())); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowFrontend", policy => + policy.WithOrigins(builder.Configuration["Cors:AllowedOrigin"] ?? "http://localhost:3000") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials()); + + // Realtime hub CORS: exact-origin only, sourced from the same Realtime:AllowedOrigins list the handshake + // middleware (RealtimeOriginValidationMiddleware) enforces directly — browsers do not apply CORS to + // WebSocket upgrades, so this policy alone would not be sufficient; both exist together deliberately. + var realtimeAllowedOrigins = builder.Configuration.GetSection("Realtime:AllowedOrigins").Get() + ?? Array.Empty(); + options.AddPolicy("RealtimeHub", policy => + policy.WithOrigins(realtimeAllowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials()); +}); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(GoogleOptions.SectionName)) @@ -255,7 +321,6 @@ await context.Response.WriteAsJsonAsync(new ApiErrorResponse( } // Security event (brief: rate-limit rejections are logged with actor, target, action, result). - // No correlation-id infrastructure exists in this codebase yet (pre-existing gap, tracked separately). var rateLimitLogger = context.HttpContext.RequestServices .GetRequiredService().CreateLogger("RateLimiting"); var policyName = context.HttpContext.GetEndpoint()?.Metadata @@ -405,8 +470,10 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( // Must be first — sets RemoteIpAddress from X-Forwarded-For before any other middleware reads it. app.UseForwardedHeaders(); +app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); +app.UseMiddleware(); if (app.Environment.IsDevelopment()) { @@ -427,8 +494,47 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse( app.UseAuthorization(); app.MapControllers(); + +// The single authenticated realtime endpoint (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the +// same JWT cookie auth as REST (OnMessageReceived/OnTokenValidated above) — [Authorize] on RealtimeHub is what +// enforces it. CloseOnAuthenticationExpiration closes a connection the instant its token naturally expires; it +// is only one of three independent mechanisms the authorization model needs (see "the load-bearing rule" in the +// spec) — proactive close (AuthService -> IRealtimeConnectionCloser) and the per-method scope recheck +// (IRealtimeScopeAuthorizer, called from inside RealtimeHub) are the other two, and none of the three is +// sufficient alone. ApplicationMaxBufferSize bounds the connection's receive buffer at the same 16 KiB as +// HubOptions.MaximumReceiveMessageSize above — neither is ever set to 0, which would disable the limit. +app.MapHub("/hubs/realtime", options => +{ + options.CloseOnAuthenticationExpiration = true; + options.ApplicationMaxBufferSize = 16 * 1024; + options.TransportMaxBufferSize = 16 * 1024; +}).RequireCors("RealtimeHub"); + +app.MapHealthChecks("/health/live", new HealthCheckOptions +{ + // A liveness probe answers only whether this process can serve HTTP. It must never restart the container because + // PostgreSQL, storage, or a background worker is temporarily unavailable. + Predicate = _ => false, + ResponseWriter = WriteHealthResponse, +}); +app.MapHealthChecks("/health/ready", new HealthCheckOptions +{ + Predicate = registration => registration.Tags.Contains("ready"), + ResponseWriter = WriteHealthResponse, +}); app.MapGet("/health", () => Results.Ok(new { status = "healthy", utc = DateTime.UtcNow })); +static Task WriteHealthResponse(HttpContext context, HealthReport report) +{ + // Dependency names, connection errors, pending migration IDs, and worker state stay out of this public endpoint. + // The HTTP status and this single status field are sufficient for Container Apps / Caddy probes. + context.Response.ContentType = "application/json"; + return context.Response.WriteAsJsonAsync(new + { + status = report.Status == HealthStatus.Healthy ? "healthy" : "unhealthy", + }); +} + static RateLimitPartition AuthWindow(HttpContext context, int permitLimit, TimeSpan window) => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", @@ -459,6 +565,39 @@ static RateLimitPartition FriendWindow(HttpContext context, string prefi }); } +// Explicit, one-shot release jobs. They reuse the application composition root so migrations and seeders use the +// same configuration and provider as the running API, but they never start the HTTP server or background workers. +// A deployment must invoke them one at a time and record their exit code as release evidence. +if (args.Contains("--apply-migrations")) +{ + using var scope = app.Services.CreateScope(); + var migrationDb = scope.ServiceProvider.GetRequiredService(); + migrationDb.Database.Migrate(); + Console.WriteLine("Database migrations applied successfully."); + Environment.Exit(0); +} + +if (args.Contains("--seed")) +{ + using var scope = app.Services.CreateScope(); + var seedDb = scope.ServiceProvider.GetRequiredService(); + var seedLogger = scope.ServiceProvider.GetRequiredService().CreateLogger(); + var gameSeeder = new GameCatalogSeeder(seedDb, seedLogger); + var gameResult = gameSeeder.SeedAsync().GetAwaiter().GetResult(); + if (!gameResult.Success) + { + Console.Error.WriteLine(gameResult.Message); + Environment.Exit(1); + } + + var capabilityClock = scope.ServiceProvider.GetRequiredService(); + var capabilityLogger = scope.ServiceProvider.GetRequiredService().CreateLogger(); + var capabilitySeeder = new GameCapabilitySeeder(seedDb, capabilityClock, capabilityLogger); + var capabilityResult = capabilitySeeder.SeedAsync().GetAwaiter().GetResult(); + Console.WriteLine(capabilityResult.Message); + Environment.Exit(capabilityResult.Success ? 0 : 1); +} + if (args.Contains("--seed-game-catalog")) { using var scope = app.Services.CreateScope(); diff --git a/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs b/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs new file mode 100644 index 0000000..3691171 --- /dev/null +++ b/src/SimPle.Api/Realtime/RealtimeConnectionCloser.cs @@ -0,0 +1,28 @@ +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Realtime.Contracts; + +namespace SimPle.Api.Realtime; + +/// +/// Real, SignalR-backed : one of the three independent mechanisms Module +/// 7's authorization model requires (docs/specs/module-07-realtime-presence-chat-spec.md, "the load-bearing +/// rule"). Sends AccessRevoked (best-effort) then forcibly aborts every live connection for the user via +/// , rather than waiting for token expiry or the next per-method recheck. +/// +public sealed class RealtimeConnectionCloser : IRealtimeConnectionCloser +{ + private readonly IRealtimeNotifier _notifier; + private readonly RealtimeConnectionTracker _tracker; + + public RealtimeConnectionCloser(IRealtimeNotifier notifier, RealtimeConnectionTracker tracker) + { + _notifier = notifier; + _tracker = tracker; + } + + public async Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default) + { + await _notifier.NotifyAccessRevokedAsync(userId, reason, ct); + _tracker.AbortAll(userId); + } +} diff --git a/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs b/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs new file mode 100644 index 0000000..bb6c4b8 --- /dev/null +++ b/src/SimPle.Api/Realtime/RealtimeConnectionTracker.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace SimPle.Api.Realtime; + +/// +/// Maps a user id to their live hub connections' abort actions. SignalR's IHubContext can message a user +/// (Clients.User(id)) but cannot forcibly close their connection — only HubCallerContext.Abort(), +/// called from inside the hub instance that owns that connection, can. This singleton is the bridge: +/// RealtimeHub registers each connection's abort delegate on connect and removes it on disconnect; +/// RealtimeConnectionCloser (the real, SignalR-backed IRealtimeConnectionCloser) invokes them by +/// user id from anywhere in the app (e.g. AuthService.LogoutAsync). +/// +public sealed class RealtimeConnectionTracker +{ + private readonly ConcurrentDictionary> _byUser = new(); + + public void Register(Guid userId, string connectionId, Action abort) + { + var connections = _byUser.GetOrAdd(userId, _ => new ConcurrentDictionary()); + connections[connectionId] = abort; + } + + public void Unregister(Guid userId, string connectionId) + { + if (_byUser.TryGetValue(userId, out var connections)) + { + connections.TryRemove(connectionId, out _); + if (connections.IsEmpty) + _byUser.TryRemove(userId, out _); + } + } + + /// Aborts every live connection currently registered for this user. Never throws for a user with + /// no connections. + public void AbortAll(Guid userId) + { + if (!_byUser.TryRemove(userId, out var connections)) + return; + + foreach (var abort in connections.Values) + abort(); + } +} diff --git a/src/SimPle.Api/Realtime/RealtimeNotifier.cs b/src/SimPle.Api/Realtime/RealtimeNotifier.cs new file mode 100644 index 0000000..9371c46 --- /dev/null +++ b/src/SimPle.Api/Realtime/RealtimeNotifier.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.SignalR; +using SimPle.Api.Hubs; +using SimPle.Application.Realtime.Contracts; + +namespace SimPle.Api.Realtime; + +/// +/// Real, SignalR-backed . Lives in the API layer (not Infrastructure) because it +/// needs the concrete type via , and Infrastructure +/// cannot reference Api in this codebase's Clean Architecture layering (Api references Infrastructure, never the +/// reverse) — a deliberate, documented deviation from the literal "implementations live in Infrastructure" +/// instruction (see final report). No caller invokes these methods yet in B1 (no chat, no lobby command wiring); +/// they exist now so B2 needs zero new hub/client contract surface. +/// +public sealed class RealtimeNotifier : IRealtimeNotifier +{ + private readonly IHubContext _hub; + private readonly TimeProvider _timeProvider; + + public RealtimeNotifier(IHubContext hub, TimeProvider timeProvider) + { + _hub = hub; + _timeProvider = timeProvider; + } + + public async Task NotifyLobbyChangedAsync( + Guid lobbyId, int revision, string changeType, CancellationToken ct = default) + { + var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now()); + await _hub.Clients.Group(RealtimeGroups.Lobby(lobbyId)).LobbyChanged(envelope, revision, changeType); + } + + public async Task NotifyPresenceChangedAsync( + Guid subjectUserId, IReadOnlyCollection viewerUserIds, string status, Guid serverEpoch, + long userVersion, CancellationToken ct = default) + { + if (viewerUserIds.Count == 0) + return; + + var envelope = RealtimeEnvelope.ForUser(subjectUserId, Now()); + await _hub.Clients.Users(viewerUserIds.Select(id => id.ToString()).ToList()) + .PresenceChanged(envelope, subjectUserId, status, serverEpoch, userVersion); + } + + public async Task NotifyChatMessageCreatedAsync( + Guid lobbyId, IReadOnlyCollection recipientUserIds, ChatMessageDto message, + CancellationToken ct = default) + { + if (recipientUserIds.Count == 0) + return; + + var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now()); + await _hub.Clients.Users(recipientUserIds.Select(id => id.ToString()).ToList()) + .ChatMessageCreated(envelope, message); + } + + public async Task NotifyChatMessageDeletedAsync( + Guid lobbyId, IReadOnlyCollection recipientUserIds, Guid messageId, DateTime deletedAtUtc, + CancellationToken ct = default) + { + if (recipientUserIds.Count == 0) + return; + + var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now()); + await _hub.Clients.Users(recipientUserIds.Select(id => id.ToString()).ToList()) + .ChatMessageDeleted(envelope, messageId, deletedAtUtc); + } + + public async Task NotifyAccessRevokedAsync(Guid userId, string reason, CancellationToken ct = default) + { + var envelope = RealtimeEnvelope.ForUser(userId, Now()); + await _hub.Clients.User(userId.ToString()).AccessRevoked(envelope, reason); + } + + public async Task NotifyResyncRequiredAsync( + Guid lobbyId, string reason, int? currentRevision, CancellationToken ct = default) + { + var envelope = RealtimeEnvelope.ForLobby(lobbyId, Now()); + await _hub.Clients.Group(RealtimeGroups.Lobby(lobbyId)).ResyncRequired(envelope, reason, currentRevision); + } + + private DateTime Now() => _timeProvider.GetUtcNow().UtcDateTime; +} diff --git a/src/SimPle.Api/Realtime/RealtimeOptions.cs b/src/SimPle.Api/Realtime/RealtimeOptions.cs new file mode 100644 index 0000000..1778c64 --- /dev/null +++ b/src/SimPle.Api/Realtime/RealtimeOptions.cs @@ -0,0 +1,14 @@ +namespace SimPle.Api.Realtime; + +/// +/// Config-driven exact-origin allowlist for the realtime hub handshake (docs/specs/module-07-realtime-presence- +/// chat-spec.md). Deliberately separate from the general Cors:AllowedOrigin policy: browsers do not apply +/// CORS to WebSocket upgrade requests, so the hub's origin check cannot rely on UseCors alone and must be +/// enforced explicitly, in application code, against an exact-match allowlist — never a wildcard. +/// +public sealed class RealtimeOptions +{ + public const string SectionName = "Realtime"; + + public string[] AllowedOrigins { get; set; } = Array.Empty(); +} diff --git a/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs b/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs new file mode 100644 index 0000000..df93faf --- /dev/null +++ b/src/SimPle.Api/Realtime/RealtimeOriginValidationMiddleware.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using SimPle.Api.Models; + +namespace SimPle.Api.Realtime; + +/// +/// Rejects any request to the realtime hub whose Origin header is not an exact match against the +/// configured allowlist (). Runs ahead of SignalR's own request +/// handling for both the negotiate call and the WebSocket upgrade — WebSocket upgrades are not subject to browser +/// CORS enforcement, so this check is the actual security boundary, not UseCors. +/// +public sealed class RealtimeOriginValidationMiddleware +{ + public const string HubPathPrefix = "/hubs/realtime"; + + private readonly RequestDelegate _next; + private readonly IOptionsMonitor _options; + + public RealtimeOriginValidationMiddleware(RequestDelegate next, IOptionsMonitor options) + { + _next = next; + _options = options; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.StartsWithSegments(HubPathPrefix)) + { + var origin = context.Request.Headers.Origin.ToString(); + var allowedOrigins = _options.CurrentValue.AllowedOrigins; + + // M07-003: a missing Origin header is rejected, not waved through. Real browsers always send Origin + // on the negotiate POST and the WebSocket upgrade — same-origin or not, unsafe-method fetches carry + // it per the Fetch spec — so the only client that reaches this path with no Origin at all is a + // non-browser tool forging the request, exactly the actor this check exists to stop. A + // present-but-unlisted Origin remains rejected too. + if (string.IsNullOrEmpty(origin) || !allowedOrigins.Contains(origin, StringComparer.Ordinal)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new ApiErrorResponse( + new ApiErrorDetail("Realtime.OriginRejected", "Origin not allowed for the realtime hub."))); + return; + } + } + + await _next(context); + } +} diff --git a/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs b/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs new file mode 100644 index 0000000..c82be22 --- /dev/null +++ b/src/SimPle.Api/Realtime/SubjectUserIdProvider.cs @@ -0,0 +1,17 @@ +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.SignalR; + +namespace SimPle.Api.Realtime; + +/// +/// Maps SignalR's notion of "user" to the JWT sub claim, matching every other consumer of the access +/// token cookie in this codebase (Program.cs uses options.MapInboundClaims = false, so the claim +/// type stays the literal "sub" rather than being remapped to ClaimTypes.NameIdentifier). This is +/// what makes Clients.User(userId) and HubCallerContext.UserIdentifier resolve to the same value +/// used everywhere else ( parsed from the claim). +/// +public sealed class SubjectUserIdProvider : IUserIdProvider +{ + public string? GetUserId(HubConnectionContext connection) => + connection.User?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; +} diff --git a/src/SimPle.Api/appsettings.Development.example.json b/src/SimPle.Api/appsettings.Development.example.json index 2033f54..4cffe19 100644 --- a/src/SimPle.Api/appsettings.Development.example.json +++ b/src/SimPle.Api/appsettings.Development.example.json @@ -45,5 +45,8 @@ }, "Cors": { "AllowedOrigin": "http://localhost:3000" + }, + "Realtime": { + "AllowedOrigins": [ "http://localhost:3000" ] } } diff --git a/src/SimPle.Api/appsettings.json b/src/SimPle.Api/appsettings.json index 661a835..33df597 100644 --- a/src/SimPle.Api/appsettings.json +++ b/src/SimPle.Api/appsettings.json @@ -9,6 +9,9 @@ "Cors": { "AllowedOrigin": "http://localhost:3000" }, + "Realtime": { + "AllowedOrigins": [ "http://localhost:3000" ] + }, "ConnectionStrings": { "DefaultConnection": "See appsettings.Development.json or environment variables" }, diff --git a/src/SimPle.Application/Auth/Services/AuthService.cs b/src/SimPle.Application/Auth/Services/AuthService.cs index fe251b4..4a1b76e 100644 --- a/src/SimPle.Application/Auth/Services/AuthService.cs +++ b/src/SimPle.Application/Auth/Services/AuthService.cs @@ -19,6 +19,7 @@ public sealed class AuthService : IAuthService private readonly IEmailService _email; private readonly IGoogleTokenValidationService _googleValidator; private readonly IRevokedJtiStore _revokedJtis; + private readonly IRealtimeConnectionCloser _realtimeCloser; private readonly AuthOptions _authOptions; private readonly EmailOptions _emailOptions; private readonly ILogger _logger; @@ -33,6 +34,7 @@ public AuthService( IEmailService email, IGoogleTokenValidationService googleValidator, IRevokedJtiStore revokedJtis, + IRealtimeConnectionCloser realtimeCloser, IOptions authOptions, IOptions emailOptions, ILogger logger) @@ -46,6 +48,7 @@ public AuthService( _email = email; _googleValidator = googleValidator; _revokedJtis = revokedJtis; + _realtimeCloser = realtimeCloser; _authOptions = authOptions.Value; _emailOptions = emailOptions.Value; _logger = logger; @@ -237,8 +240,19 @@ public async Task LogoutAsync(string rawRefreshToken, CancellationToken if (stored is { IsRevoked: false }) { + // Module 7: also revoke the session family, matching LogoutAllAsync/RevokeSessionAsync. Previously + // this method revoked only the refresh-token row, leaving a stale-but-still-valid access cookie live + // until natural expiry — harmless on plain REST, but a realtime hub connection would otherwise survive + // a "logout" indefinitely and simply reconnect with that cookie. This is an approved, intentional + // Module 1 behavior change (see docs/specs/module-07-realtime-presence-chat-spec.md). + _revokedJtis.Revoke(stored.FamilyId.ToString(), TimeSpan.FromMinutes(20)); + stored.Revoke("", "Logout"); await _tokens.UpdateAsync(stored, ct); + + // Realtime connections cache the authenticated principal for their lifetime and are not + // automatically revalidated — close them proactively rather than letting a live socket outlive logout. + await _realtimeCloser.CloseUserConnectionsAsync(stored.UserId, "auth.session_revoked", ct); } return Result.Ok(); @@ -252,6 +266,7 @@ public async Task LogoutAllAsync(Guid userId, CancellationToken ct = def _revokedJtis.Revoke(t.FamilyId.ToString(), TimeSpan.FromMinutes(20)); await _tokens.RevokeAllByUserIdAsync(userId, "Logout all", ct); + await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct); _logger.LogInformation("Security: Logout-all. UserId={UserId}", userId); return Result.Ok(); } @@ -517,6 +532,7 @@ public async Task RevokeSessionAsync(Guid userId, Guid sessionId, Cancel token.Revoke(string.Empty, "user_revoked"); await _tokens.UpdateAsync(token, ct); + await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct); _logger.LogInformation("Session {SessionId} revoked by user {UserId}", sessionId, userId); return Result.Ok(); } @@ -531,6 +547,7 @@ public async Task DeleteAccountAsync(Guid userId, string password, Cance await _tokens.RevokeAllByUserIdAsync(userId, "account_deleted", ct); await _users.DeleteAsync(user, ct); + await _realtimeCloser.CloseUserConnectionsAsync(userId, "auth.session_revoked", ct); _logger.LogInformation("Account deleted for user {UserId}", userId); return Result.Ok(); } diff --git a/src/SimPle.Application/Chat/ChatBodyNormalizer.cs b/src/SimPle.Application/Chat/ChatBodyNormalizer.cs new file mode 100644 index 0000000..1b722cc --- /dev/null +++ b/src/SimPle.Application/Chat/ChatBodyNormalizer.cs @@ -0,0 +1,54 @@ +using System.Text; +using SimPle.Shared.Common; + +namespace SimPle.Application.Chat; + +/// +/// Normalizes and validates a raw chat body before it is ever stored (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Test Matrix": "NFC normalization; CRLF/CR to LF; outer Unicode whitespace trimmed; LF allowed; other +/// C0/C1 controls rejected; 0 scalars rejected, 1 accepted, 1000 accepted, 1001 rejected; astral-plane scalars +/// counted correctly."). +/// +/// Counts Unicode scalar values (), never UTF-16 code units — a single astral +/// character (e.g. an emoji outside the BMP) is one scalar even though it is two chars. +/// +public static class ChatBodyNormalizer +{ + public const int MinScalars = 1; + public const int MaxScalars = 1000; + + public static Result Normalize(string? rawBody) + { + if (rawBody is null) return Fail(); + + // CRLF/CR -> LF first, so a lone CR or a CRLF pair both collapse to the one allowed control character. + var unified = rawBody.Replace("\r\n", "\n").Replace("\r", "\n"); + + // NFC normalization. Combining-mark sequences and their precomposed equivalents must count and compare + // identically; skipping this would let visually-identical bodies evade the profanity deny-list. + var normalizedForm = unified.Normalize(NormalizationForm.FormC); + + // Outer Unicode whitespace trim only — String.Trim() uses Char.IsWhiteSpace, which is Unicode-aware, and + // only removes from the ends, so an internal LF (a deliberate multi-line message) survives untouched. + var trimmed = normalizedForm.Trim(); + + var scalarCount = 0; + foreach (var rune in trimmed.EnumerateRunes()) + { + if (IsRejectedControl(rune.Value)) return Fail(); + scalarCount++; + } + + if (scalarCount is < MinScalars or > MaxScalars) return Fail(); + + return Result.Ok(trimmed); + } + + /// LF (U+000A) is the one allowed control character (it is how a multi-line body is represented post + /// CRLF/CR collapse). Every other C0 control (U+0000-U+001F) and every C1 control plus DEL (U+007F-U+009F) is + /// rejected. + private static bool IsRejectedControl(int scalarValue) => + (scalarValue <= 0x1F && scalarValue != 0x0A) || (scalarValue is >= 0x7F and <= 0x9F); + + private static Result Fail() => Result.Fail(ChatErrors.InvalidBody, "Message body is invalid."); +} diff --git a/src/SimPle.Application/Chat/ChatErrors.cs b/src/SimPle.Application/Chat/ChatErrors.cs new file mode 100644 index 0000000..4ec78f4 --- /dev/null +++ b/src/SimPle.Application/Chat/ChatErrors.cs @@ -0,0 +1,33 @@ +namespace SimPle.Application.Chat; + +/// Error catalogue for Module 7 chat (docs/specs/module-07-realtime-presence-chat-spec.md, "Error +/// catalogue"). PascalCase dot-namespaced codes, matching every other module's per-module error class. +public static class ChatErrors +{ + /// Message absent or not visible to the viewer. Privacy-safe: existence is never disclosed, so an + /// unauthorized viewer and a genuinely-missing message collapse to the same code. + public const string NotFound = "Chat.NotFound"; + + /// The one legitimate distinguishing code in this module: not the author on delete. + public const string Forbidden = "Chat.Forbidden"; + + /// Failed normalization/length/control-character rules. + public const string InvalidBody = "Chat.InvalidBody"; + + /// Deny-list match. A normal validation error, not a security event — message is not stored, sender + /// is told why. + public const string ProfanityRejected = "Chat.ProfanityRejected"; + + /// Hold requested (M12) on a message that already aged out of retention. + public const string MessageExpired = "Chat.MessageExpired"; + + /// Shared literal with every other module's own rate-limit constant — same string, independently + /// declared, matching this codebase's per-module-duplicate-constant convention. + public const string RateLimitExceeded = "RateLimit.Exceeded"; + + /// Shared cross-module literal (matches LobbyErrors.ValidationFailed). + public const string ValidationFailed = "Validation.Failed"; + + /// Shared cross-module literal (matches LobbyErrors.InvalidCursor). + public const string InvalidCursor = "Pagination.InvalidCursor"; +} diff --git a/src/SimPle.Application/Chat/ChatHistoryDirection.cs b/src/SimPle.Application/Chat/ChatHistoryDirection.cs new file mode 100644 index 0000000..704d240 --- /dev/null +++ b/src/SimPle.Application/Chat/ChatHistoryDirection.cs @@ -0,0 +1,13 @@ +namespace SimPle.Application.Chat; + +/// docs/specs/module-07-realtime-presence-chat-spec.md, API contract: "direction: before (scrollback, +/// default) | after (reconnect repair)." +public enum ChatHistoryDirection +{ + /// Scrollback. No cursor => the most recent page. With a cursor => the page immediately older than + /// it. Default. + Before, + + /// Reconnect repair: the page immediately newer than the cursor. + After, +} diff --git a/src/SimPle.Application/Chat/ChatProfanityFilter.cs b/src/SimPle.Application/Chat/ChatProfanityFilter.cs new file mode 100644 index 0000000..d8d23de --- /dev/null +++ b/src/SimPle.Application/Chat/ChatProfanityFilter.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; + +namespace SimPle.Application.Chat; + +/// Default : whole-word, case-insensitive matching against +/// . Deliberately simple and dependency-free — this is a first-pass automated +/// screen, not a replacement for M12's manual moderation pipeline. +public sealed class ChatProfanityFilter : IChatProfanityFilter +{ + private readonly IReadOnlyList _patterns; + + public ChatProfanityFilter(IOptions options) + { + _patterns = options.Value.Terms + .Where(term => !string.IsNullOrWhiteSpace(term)) + .Select(term => new Regex( + $@"\b{Regex.Escape(term.Trim())}\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled)) + .ToList(); + } + + public bool IsProfane(string normalizedBody) => + _patterns.Count > 0 && _patterns.Any(pattern => pattern.IsMatch(normalizedBody)); +} diff --git a/src/SimPle.Application/Chat/ChatRetentionOptions.cs b/src/SimPle.Application/Chat/ChatRetentionOptions.cs new file mode 100644 index 0000000..e5d42f2 --- /dev/null +++ b/src/SimPle.Application/Chat/ChatRetentionOptions.cs @@ -0,0 +1,18 @@ +namespace SimPle.Application.Chat; + +/// Backs (docs/specs/ +/// module-07-realtime-presence-chat-spec.md, "Ownership, retention, deletion" and Risk #5). Mirrors +/// TokenCleanupOptions's shape: an interval plus a per-run bound, both environment/appsettings-supplied. +/// +public sealed class ChatRetentionOptions +{ + public const string SectionName = "ChatRetention"; + + /// How often the sweep runs. + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1); + + /// Rows deleted per sweep pass. Bounded on purpose — the sweep is one statement per batch + /// (LIMIT + FOR UPDATE OF m SKIP LOCKED), so a large backlog is drained over several passes + /// rather than one unbounded transaction. + public int BatchSize { get; set; } = 500; +} diff --git a/src/SimPle.Application/Chat/ChatService.cs b/src/SimPle.Application/Chat/ChatService.cs new file mode 100644 index 0000000..6d661f6 --- /dev/null +++ b/src/SimPle.Application/Chat/ChatService.cs @@ -0,0 +1,296 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Common.Pagination; +using SimPle.Application.Realtime; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Contracts; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; +using SimPle.Shared.Common; + +namespace SimPle.Application.Chat; + +/// +/// Chat command/query implementation (docs/specs/module-07-realtime-presence-chat-spec.md). Reuses the same +/// surface the hub uses for subscribe, so send/delete/history all +/// re-authorize against current owner data on every call — never a cached principal from connection time. +/// +public sealed class ChatService : IChatService +{ + private const int DefaultLimit = 30; + private const int MaxLimit = 50; + + /// No precise retry-after instant is available from + /// (bool-only contract) — this is a conservative synthesized value matching the documented 5-per-5s burst + /// window, not a measured one. + private static readonly TimeSpan RateLimitRetryAfter = TimeSpan.FromSeconds(5); + + /// Safe non-navigable placeholder (spec: "no username leak") for a sender absent from + /// 's result (deleted/unresolvable account) — never the real + /// fields. + private static readonly PublicIdentityDto TombstoneSender = new( + Guid.Empty, "deleted-user", "Chat participant", "??", "#6B7280", null, "Player"); + + private readonly IChatRepository _chat; + private readonly IReadOnlyDictionary _authorizers; + private readonly IRealtimeNotifier _notifier; + private readonly IRealtimeRateLimiter _rateLimiter; + private readonly IChatProfanityFilter _profanity; + private readonly IFileStorageService _storage; + private readonly StorageOptions _storageOptions; + private readonly ILobbyRepository _lobbies; + private readonly IFriendRepository _friends; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + public ChatService( + IChatRepository chat, + IEnumerable authorizers, + IRealtimeNotifier notifier, + IRealtimeRateLimiter rateLimiter, + IChatProfanityFilter profanity, + IFileStorageService storage, + IOptions storageOptions, + ILobbyRepository lobbies, + IFriendRepository friends, + TimeProvider clock, + ILogger logger) + { + _chat = chat; + _authorizers = authorizers.ToDictionary(a => a.ScopeKind); + _notifier = notifier; + _rateLimiter = rateLimiter; + _profanity = profanity; + _storage = storage; + _storageOptions = storageOptions.Value; + _lobbies = lobbies; + _friends = friends; + _clock = clock; + _logger = logger; + } + + private DateTime NowUtc => _clock.GetUtcNow().UtcDateTime; + + public async Task> SendAsync( + Guid actorUserId, Guid lobbyId, string? body, Guid clientCommandId, CancellationToken ct = default) + { + if (!await IsAllowedAsync(actorUserId, lobbyId, RealtimeAction.Send, ct)) + return Result.Fail(ChatErrors.NotFound, "Lobby not found."); + + // Idempotency first: a client retry of the same command must never cost a second rate-limit slot or get + // a different profanity/validation verdict the second time around. + var existing = await _chat.FindByClientCommandIdAsync(actorUserId, clientCommandId, ct); + if (existing is not null) + return Result.Ok(await ToOwnMessageDtoAsync(existing, ct)); + + if (!_rateLimiter.TryAcquireMessage(actorUserId)) + { + return Result.Fail(new Error( + ChatErrors.RateLimitExceeded, "You are sending messages too quickly.") + { + RetryAfterUtc = NowUtc + RateLimitRetryAfter, + }); + } + + var normalized = ChatBodyNormalizer.Normalize(body); + if (!normalized.IsSuccess) + return Result.Fail(ChatErrors.InvalidBody, normalized.Error!.Message); + + if (_profanity.IsProfane(normalized.Value!)) + return Result.Fail(ChatErrors.ProfanityRejected, "That message isn't allowed."); + + var message = ChatMessage.Create( + ChatScope.Lobby, lobbyId, actorUserId, normalized.Value!, clientCommandId, NowUtc); + + // AddAsync is itself idempotent against ux_chat_messages_sender_command: a concurrent duplicate send that + // raced past the FindByClientCommandIdAsync check above hits the unique index instead of throwing, and + // returns whichever row actually won. + var persisted = await _chat.AddAsync(message, ct); + + var dto = await ToOwnMessageDtoAsync(persisted, ct); + var recipients = await GetDeliverableRecipientsAsync(lobbyId, actorUserId, ct); + await _notifier.NotifyChatMessageCreatedAsync(lobbyId, recipients, dto, ct); + return Result.Ok(dto); + } + + public async Task DeleteAsync(Guid actorUserId, Guid messageId, CancellationToken ct = default) + { + var message = await _chat.GetByIdAsync(messageId, ct); + if (message is null) + return Result.Fail(ChatErrors.NotFound, "Message not found."); + + if (!await IsAllowedAsync(actorUserId, message.ScopeId, RealtimeAction.Delete, ct)) + return Result.Fail(ChatErrors.NotFound, "Message not found."); + + if (message.SenderId != actorUserId) + return Result.Fail(ChatErrors.Forbidden, "Only the author can delete this message."); + + message.Delete(actorUserId, NowUtc); + await _chat.SaveAsync(ct); + + // Uses the message's own DeletedAtUtc (set on first delete, unchanged on a retried one) rather than + // "now" directly, so a retried delete re-broadcasts the original tombstone instant, not a new one. + var recipients = await GetDeliverableRecipientsAsync(message.ScopeId, actorUserId, ct); + await _notifier.NotifyChatMessageDeletedAsync( + message.ScopeId, recipients, message.Id, message.DeletedAtUtc!.Value, ct); + return Result.Ok(); + } + + public async Task>> GetHistoryAsync( + Guid actorUserId, + Guid lobbyId, + ChatHistoryDirection direction, + string? cursor, + int? limit, + CancellationToken ct = default) + { + var pageSize = limit ?? DefaultLimit; + if (pageSize < 1 || pageSize > MaxLimit) + return Result>.Fail( + ChatErrors.ValidationFailed, "Page size must be between 1 and 50."); + + if (!await IsAllowedAsync(actorUserId, lobbyId, RealtimeAction.Subscribe, ct)) + return Result>.Fail(ChatErrors.NotFound, "Lobby not found."); + + DateTime? cursorCreatedAt = null; + Guid? cursorId = null; + if (cursor is not null) + { + if (!Cursor.TryDecodeTimeId(cursor, out var createdAt, out var id)) + return Result>.Fail( + ChatErrors.InvalidCursor, "The pagination cursor is invalid."); + cursorCreatedAt = createdAt; + cursorId = id; + } + + var rows = await _chat.GetHistoryPageAsync( + ChatScope.Lobby, lobbyId, direction, cursorCreatedAt, cursorId, pageSize, ct); + + // Block-aware history (M07-F1-security fix, mirrors GetDeliverableRecipientsAsync's realtime + // fan-out filter): a blocked co-member's messages are excluded from every history page — including + // reconnect resync and a fresh page load, not just live broadcast — so "blocked users do not receive + // or infer presence/chat" holds for REST history the same way it already holds for realtime delivery. + var visibleRows = await FilterBlockedSendersAsync(actorUserId, rows, ct); + + var senders = await _chat.GetSendersAsync(visibleRows.Select(m => m.SenderId).Distinct().ToList(), ct); + + var items = new List(visibleRows.Count); + foreach (var row in visibleRows) + items.Add(ToMessageDto(row, await ResolveSenderAsync(row.SenderId, senders, ct))); + + // The cursor always advances past the last row the *query* saw in its own scan direction — for `Before` + // (queried DESC, then reversed to ascending output) that is the oldest row returned, i.e. items[0]; for + // `After` (queried ASC) that is the newest row returned, i.e. items[^1]. + string? next = null; + if (rows.Count == pageSize) + { + var cursorRow = direction == ChatHistoryDirection.Before ? rows[0] : rows[^1]; + next = Cursor.EncodeTimeId(cursorRow.CreatedAt, cursorRow.Id); + } + + return Result>.Ok(new CursorPage(items, next)); + } + + /// Block-aware fan-out list (M07-001 fix): every currently-joined lobby member receives the + /// broadcast except one blocked (either direction) with — a plain + /// group broadcast would otherwise deliver a blocked co-member's chat activity to the blocker (and vice + /// versa). is always included so the sender's own other connections stay in + /// sync. + private async Task> GetDeliverableRecipientsAsync( + Guid lobbyId, Guid senderId, CancellationToken ct) + { + var lobby = await _lobbies.GetByIdAsync(lobbyId, ct); + if (lobby is null) + return Array.Empty(); + + var recipients = new List(); + foreach (var member in lobby.JoinedMembers) + { + if (member.UserId == senderId) + { + recipients.Add(member.UserId); + continue; + } + + if (!await _friends.IsBlockedInEitherDirectionAsync(senderId, member.UserId, ct)) + recipients.Add(member.UserId); + } + + return recipients; + } + + /// Excludes any row whose sender is blocked (either direction) with + /// from a history page. The actor's own messages are always visible. Mirrors + /// 's per-member check; lobby history pages are bounded (max 50 + /// rows, capped distinct senders), so a per-sender pairwise check is cheap. + private async Task> FilterBlockedSendersAsync( + Guid actorUserId, IReadOnlyList rows, CancellationToken ct) + { + if (rows.Count == 0) + return new List(); + + var blockedSenderIds = new HashSet(); + foreach (var senderId in rows.Select(r => r.SenderId).Distinct()) + { + if (senderId != actorUserId && await _friends.IsBlockedInEitherDirectionAsync(actorUserId, senderId, ct)) + blockedSenderIds.Add(senderId); + } + + return blockedSenderIds.Count == 0 + ? rows.ToList() + : rows.Where(r => !blockedSenderIds.Contains(r.SenderId)).ToList(); + } + + private async Task IsAllowedAsync( + Guid actorUserId, Guid lobbyId, RealtimeAction action, CancellationToken ct) + { + if (!_authorizers.TryGetValue(RealtimeEnvelope.LobbyScope, out var authorizer)) + return false; + + var result = await authorizer.AuthorizeAsync(actorUserId, lobbyId, action, ct); + return result.IsAllowed; + } + + private async Task ToOwnMessageDtoAsync(ChatMessage message, CancellationToken ct) + { + var senders = await _chat.GetSendersAsync(new[] { message.SenderId }, ct); + return ToMessageDto(message, await ResolveSenderAsync(message.SenderId, senders, ct)); + } + + /// A sender absent from the batch lookup (deleted/unresolvable account) renders the safe + /// placeholder — IChatRepository.GetSendersAsync's documented cue. Mirrors + /// LobbiesService.ToIdentityAsync so a chat sender's avatar resolves through the same + /// presigned-URL path as every other surface that lists a . + private async Task ResolveSenderAsync( + Guid senderId, IReadOnlyDictionary senders, CancellationToken ct) + { + if (!senders.TryGetValue(senderId, out var user)) + return TombstoneSender; + + return new PublicIdentityDto( + user.Id, user.Username, user.DisplayName, user.Initials, user.Color, + await BuildAvatarUrlAsync(user.AvatarObjectKey, user.AvatarUrl, ct), + user.ProfileType.ToString()); + } + + private async Task BuildAvatarUrlAsync(string? objectKey, string? fallbackUrl, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(objectKey)) + { + var expiry = TimeSpan.FromMinutes(_storageOptions.ReadUrlExpiryMinutes); + return await _storage.CreatePresignedReadUrlAsync(objectKey, expiry, ct); + } + return fallbackUrl; + } + + private static ChatMessageDto ToMessageDto(ChatMessage message, PublicIdentityDto sender) => new( + message.Id, + message.ScopeId, + sender, + message.IsDeleted ? null : message.Body, + message.IsDeleted, + message.CreatedAt, + message.SchemaVersion); +} diff --git a/src/SimPle.Application/Chat/IChatProfanityFilter.cs b/src/SimPle.Application/Chat/IChatProfanityFilter.cs new file mode 100644 index 0000000..4296ebf --- /dev/null +++ b/src/SimPle.Application/Chat/IChatProfanityFilter.cs @@ -0,0 +1,11 @@ +namespace SimPle.Application.Chat; + +/// Automated deny-list/regex profanity screen (docs/module-requirements/module-07-realtime-presence- +/// chat.md, "CUSTOM" security requirement) run on every outgoing chat body before persistence, in addition to +/// M12's existing manual report/triage pipeline. A match is a normal validation error +/// (), never a security event — the message is simply not stored. +public interface IChatProfanityFilter +{ + /// Already -normalized text. + bool IsProfane(string normalizedBody); +} diff --git a/src/SimPle.Application/Chat/IChatRepository.cs b/src/SimPle.Application/Chat/IChatRepository.cs new file mode 100644 index 0000000..decc720 --- /dev/null +++ b/src/SimPle.Application/Chat/IChatRepository.cs @@ -0,0 +1,61 @@ +using SimPle.Domain.Chat; +using SimPle.Domain.Users; +using SimPle.Shared.Common; + +namespace SimPle.Application.Chat; + +/// Data access for chat persistence (docs/specs/module-07-realtime-presence-chat-spec.md, "Data Model"). +/// +public interface IChatRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct = default); + + /// The idempotency lookup: a duplicate send (client retry) with the same + /// (SenderId, ClientCommandId) resolves here instead of ever reaching a unique-constraint race, and the + /// caller returns the original message unchanged. + Task FindByClientCommandIdAsync(Guid senderId, Guid clientCommandId, CancellationToken ct = default); + + /// Idempotent insert against ux_chat_messages_sender_command: a concurrent duplicate send that + /// races past 's own check hits the unique index here instead of + /// throwing — the row that actually won the race is returned either way, so the caller never needs to catch + /// a persistence-specific exception. + Task AddAsync(ChatMessage message, CancellationToken ct = default); + + /// Keyset (cursor) page always returned in (CreatedAt, Id) ascending order, matching + /// ix_chat_messages_scope_created_id, regardless of . + /// (scrollback): no cursor => the latest page; with a cursor + /// => the page immediately older than it. (reconnect repair): no + /// cursor => from the start of the scope's history; with a cursor => the page immediately newer than + /// it. + Task> GetHistoryPageAsync( + ChatScope scope, + Guid scopeId, + ChatHistoryDirection direction, + DateTime? cursorCreatedAtUtc, + Guid? cursorId, + int limit, + CancellationToken ct = default); + + /// Batch sender identity resolution for DTO projection, keyed by user id. A sender absent from the + /// result (deleted/unresolvable account) is the caller's cue to render a deleted-sender placeholder. + Task> GetSendersAsync(IReadOnlyList senderIds, CancellationToken ct = default); + + Task SaveAsync(CancellationToken ct = default); + + /// The retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, "Cleanup vs moderation + /// hold"). Deletes up to rows whose RetainUntilUtc has passed and which + /// carry no active , in one bounded statement (CTE with LIMIT + + /// FOR UPDATE OF m SKIP LOCKED, then DELETE ... USING) so a row mid- + /// is skipped this pass rather than blocked on or deleted out from under it. Returns the number of rows + /// deleted. + Task DeleteExpiredAsync(DateTime nowUtc, int batchSize, CancellationToken ct = default); + + /// The seam Module 7 builds but never calls (docs/specs/module-07-realtime-presence-chat-spec.md, + /// "Data Model" and Risk #5) — Module 12 places a moderation hold through this before the retention sweep can + /// delete the message. Takes the same row lock the sweep takes (blocking, not SKIP LOCKED, since a + /// hold request must wait its turn rather than give up) so the two race honestly: if the sweep already + /// committed the delete, this returns instead of silently losing + /// evidence. + Task> PlaceHoldAsync( + Guid messageId, string reasonCode, DateTime nowUtc, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Chat/IChatService.cs b/src/SimPle.Application/Chat/IChatService.cs new file mode 100644 index 0000000..06d1b20 --- /dev/null +++ b/src/SimPle.Application/Chat/IChatService.cs @@ -0,0 +1,31 @@ +using SimPle.Application.Realtime.Contracts; +using SimPle.Shared.Common; + +namespace SimPle.Application.Chat; + +/// +/// The chat command/query surface (docs/specs/module-07-realtime-presence-chat-spec.md, "API Contract"). The sole +/// send/delete/history authorization path — both RealtimeHub.SendLobbyMessage and ChatController +/// call into this service rather than re-implementing scope authorization themselves. +/// +public interface IChatService +{ + /// Sends a lobby chat message. Idempotent on : a retried send with + /// the same (actorUserId, clientCommandId) pair returns the original message unchanged, before the + /// rate limiter or profanity filter ever runs again. + Task> SendAsync( + Guid actorUserId, Guid lobbyId, string? body, Guid clientCommandId, CancellationToken ct = default); + + /// Author-only tombstone delete. Idempotent — a retried delete on an already-deleted message is a + /// no-op that still succeeds. + Task DeleteAsync(Guid actorUserId, Guid messageId, CancellationToken ct = default); + + /// Keyset-paginated lobby chat history. defaults to 30, capped at 50. + Task>> GetHistoryAsync( + Guid actorUserId, + Guid lobbyId, + ChatHistoryDirection direction, + string? cursor, + int? limit, + CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Chat/ProfanityOptions.cs b/src/SimPle.Application/Chat/ProfanityOptions.cs new file mode 100644 index 0000000..e22bf70 --- /dev/null +++ b/src/SimPle.Application/Chat/ProfanityOptions.cs @@ -0,0 +1,16 @@ +namespace SimPle.Application.Chat; + +/// Backs . The deny list is a versioned server-side configuration +/// value (docs/module-requirements/module-07-realtime-presence-chat.md, "CUSTOM" security requirement) — +/// never a client-editable input. +public sealed class ProfanityOptions +{ + public const string SectionName = "Profanity"; + + /// Bumped whenever changes. Not surfaced to clients today; exists so this stays + /// a versioned configuration value rather than an untracked ad hoc list. + public int Version { get; set; } = 1; + + /// Case-insensitive whole-word deny-list terms. Environment/appsettings-supplied only. + public IReadOnlyList Terms { get; set; } = Array.Empty(); +} diff --git a/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs b/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs new file mode 100644 index 0000000..1e4de24 --- /dev/null +++ b/src/SimPle.Application/Common/Interfaces/IRealtimeConnectionCloser.cs @@ -0,0 +1,25 @@ +namespace SimPle.Application.Common.Interfaces; + +/// +/// Proactively closes a user's mapped realtime (SignalR) connections. This is one of three independent +/// mechanisms Module 7's authorization model requires (see docs/specs/module-07-realtime-presence-chat-spec.md, +/// "The load-bearing rule"): SignalR caches the authenticated principal for the connection's lifetime and does +/// not revalidate it automatically, so logout / logout-all / revoke-session / delete-account must proactively +/// close any live sockets rather than relying on token-expiry alone. +/// +/// The default DI registration is NullRealtimeConnectionCloser — a no-op — so that Auth flows never throw +/// when the realtime hub is disabled (e.g. as a rollback). The real SignalR-backed implementation lives in the +/// API layer (it needs the concrete hub type) and overrides this registration when the hub is wired up. +/// +public interface IRealtimeConnectionCloser +{ + /// + /// Closes every realtime connection currently mapped to , if any. Never throws for a + /// user with no connections. + /// + /// + /// One of: lobby.membership_removed, lobby.closed, auth.session_revoked, + /// auth.suspended, social.blocked — sent to the client as AccessRevoked before closing. + /// + Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/DependencyInjection.cs b/src/SimPle.Application/DependencyInjection.cs index 9911f37..0616cb2 100644 --- a/src/SimPle.Application/DependencyInjection.cs +++ b/src/SimPle.Application/DependencyInjection.cs @@ -10,6 +10,8 @@ using SimPle.Application.Outbox.Handlers; using SimPle.Application.People.Services; using SimPle.Application.Profiles.Services; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Presence; namespace SimPle.Application; @@ -42,6 +44,19 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + // Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1): transport, + // authorization, presence only — no chat, no migration. IPresenceRegistry is a process-lifetime singleton + // (in-memory only, no cross-instance sync; see spec Risk Register). Both scope authorizers are registered + // even though only "lobby" has a caller in B1 — NullMatchScopeAuthorizer exists so a request against a + // scope kind with no real authorizer yet fails closed with realtime.scope_not_available rather than 500ing. + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + // Scoped, matching its ILobbyRepository dependency (EF Core's DbContext is scoped) — resolves who should + // see a PresenceChanged broadcast (self + current lobby co-members, block-filtered). + services.AddScoped(); + return services; } } diff --git a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs index fd09138..57faeb8 100644 --- a/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs +++ b/src/SimPle.Application/Lobbies/Outbox/LobbyOutbox.cs @@ -41,6 +41,11 @@ public static class LobbyOutbox public const string LobbyCredentialRotated = "LobbyCredentialRotatedV1"; public const string MatchRequested = "MatchRequestedV1"; + /// Added for M07-B2: LobbyRealtimeHandler's one new consumed event type, so a readiness + /// toggle produces the same LobbyChanged hint every other Lobby-aggregate mutation does. Ids only, + /// matching every other event on this aggregate. + public const string LobbyReadinessChanged = "LobbyReadinessChangedV1"; + public static OutboxMessage LobbyCreatedEvent(Lobby lobby) => LobbyEvent(lobby, LobbyCreated, new { lobbyId = lobby.Id, hostUserId = lobby.HostUserId, gameSlug = lobby.GameSlug }); @@ -66,6 +71,9 @@ public static OutboxMessage LobbyClosedEvent(Lobby lobby) => public static OutboxMessage CredentialRotatedEvent(Lobby lobby, int generation) => LobbyEvent(lobby, LobbyCredentialRotated, new { lobbyId = lobby.Id, generation }); + public static OutboxMessage ReadinessChangedEvent(Lobby lobby, Guid userId, bool isReady) => + LobbyEvent(lobby, LobbyReadinessChanged, new { lobbyId = lobby.Id, userId, isReady }); + /// /// The event M8 consumes. Ids only: M8 re-reads the lobby it names rather than trusting a settings snapshot /// that could already be stale by the time it is delivered. diff --git a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs index 75d73fd..f31d448 100644 --- a/src/SimPle.Application/Lobbies/Services/LobbiesService.cs +++ b/src/SimPle.Application/Lobbies/Services/LobbiesService.cs @@ -349,7 +349,7 @@ public Task> SetReadinessAsync( var outcome = lobby.SetReadiness(actorUserId, request.IsReady, nowUtc); if (outcome != LobbyOutcome.Ok) return MapOutcome(outcome); - await _lobbies.SaveAsync(Array.Empty(), token); + await _lobbies.SaveAsync(new[] { LobbyOutbox.ReadinessChangedEvent(lobby, actorUserId, request.IsReady) }, token); return Result.Ok(await ProjectAsync(lobby, actorUserId, token)); }, ct); diff --git a/src/SimPle.Application/Outbox/IOutboxActivationStore.cs b/src/SimPle.Application/Outbox/IOutboxActivationStore.cs new file mode 100644 index 0000000..8d9f046 --- /dev/null +++ b/src/SimPle.Application/Outbox/IOutboxActivationStore.cs @@ -0,0 +1,26 @@ +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Outbox; + +/// Data access for the per-handler activation watermark (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Activation watermark"). See for why this exists. +public interface IOutboxActivationStore +{ + /// + /// Returns this handler's activation row, creating it on first call. The watermark captured on that first + /// call is MAX(OccurredAtUtc, Id) over in the outbox at that moment — + /// everything at or before it is pre-existing history the handler must never replay as live traffic. + /// + /// + /// Computed only on the creating call, inside the same idempotent-insert attempt — never on a call that finds + /// the row already present, so the watermark is always the activation moment, never a later re-check. + /// Concurrent first-activation callers race on the unique HandlerName primary key: the loser's insert + /// catches 23505 and simply re-reads the winner's row rather than computing its own. + /// + /// + Task GetOrActivateAsync( + string handlerName, + IReadOnlyList eventTypes, + DateTime nowUtc, + CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs new file mode 100644 index 0000000..58e9421 --- /dev/null +++ b/src/SimPle.Application/Realtime/Authorization/IRealtimeScopeAuthorizer.cs @@ -0,0 +1,16 @@ +namespace SimPle.Application.Realtime.Authorization; + +/// +/// Authorizes a user's action against a single realtime scope (a lobby, a match, ...). Every hub method that +/// touches a scope must call this — cached principal claims from handshake time are never trusted alone (see +/// "the load-bearing rule" in docs/specs/module-07-realtime-presence-chat-spec.md): membership, blocks, and +/// suspension can all change after a connection is already open, so every method rechecks current owner data. +/// +public interface IRealtimeScopeAuthorizer +{ + /// The scope kind this authorizer handles: "lobby" | "match" (see RealtimeEnvelope scope constants). + string ScopeKind { get; } + + Task AuthorizeAsync( + Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs new file mode 100644 index 0000000..b5d0fab --- /dev/null +++ b/src/SimPle.Application/Realtime/Authorization/LobbyScopeAuthorizer.cs @@ -0,0 +1,67 @@ +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Realtime.Contracts; +using SimPle.Domain.Lobbies; + +namespace SimPle.Application.Realtime.Authorization; + +/// +/// Authorizes lobby-scope realtime actions, mirroring LobbiesService.GetAsync's exact privacy rule +/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Authorization / Privacy rules"): existence is never +/// disclosed, so every denial collapses to the same code already used by the +/// REST API — a distinct "forbidden" code here would itself leak that the lobby exists. +/// +/// Called on every hub method that touches the scope, never only at connect/subscribe time — the "load-bearing +/// rule": a cached principal from handshake time is not trusted for authorization, only for identity. +/// +public sealed class LobbyScopeAuthorizer : IRealtimeScopeAuthorizer +{ + private readonly ILobbyRepository _lobbies; + private readonly IUserRepository _users; + private readonly IFriendRepository _friends; + + public LobbyScopeAuthorizer(ILobbyRepository lobbies, IUserRepository users, IFriendRepository friends) + { + _lobbies = lobbies; + _users = users; + _friends = friends; + } + + public string ScopeKind => RealtimeEnvelope.LobbyScope; + + public async Task AuthorizeAsync( + Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default) + { + var lobby = await _lobbies.GetByIdAsync(scopeId, ct); + if (lobby is null) + return Deny(); + + var isMember = lobby.FindJoinedMember(actorUserId) is not null; + var isPubliclyVisible = lobby.Privacy == LobbyPrivacy.Public && lobby.State == LobbyState.Open; + + // Subscribing (read-only) allows public-and-open lobbies even for non-members, matching the REST + // GetAsync visibility rule exactly. Send/Delete always require membership regardless of visibility — + // an anonymous observer of a public lobby is never allowed to act inside it. + var visibilityAllows = action == RealtimeAction.Subscribe ? isMember || isPubliclyVisible : isMember; + if (!visibilityAllows) + return Deny(); + + var actor = await _users.GetByIdAsync(actorUserId, ct); + if (actor is null || actor.IsAccountSuspended()) + return Deny(); + + // Block check: only meaningful against the host (the only other party guaranteed to matter for every + // lobby, public or private). Self-guard avoids a false positive when the actor is the host. + if (lobby.HostUserId != actorUserId) + { + var blocked = await _friends.IsBlockedInEitherDirectionAsync(actorUserId, lobby.HostUserId, ct); + if (blocked) + return Deny(); + } + + return RealtimeScopeAuthorizationResult.Allow(); + } + + private static RealtimeScopeAuthorizationResult Deny() => + RealtimeScopeAuthorizationResult.Deny(LobbyErrors.NotFound); +} diff --git a/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs b/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs new file mode 100644 index 0000000..50ff007 --- /dev/null +++ b/src/SimPle.Application/Realtime/Authorization/NullMatchScopeAuthorizer.cs @@ -0,0 +1,19 @@ +using SimPle.Application.Realtime.Contracts; + +namespace SimPle.Application.Realtime.Authorization; + +/// +/// Match-scope realtime does not exist yet — there is no Module 8 (matches) to authorize against. This +/// authorizer always denies with realtime.scope_not_available so the hub can declare the "match" scope +/// kind in its routing without special-casing "scope kind doesn't exist" as a distinct code path. +/// +public sealed class NullMatchScopeAuthorizer : IRealtimeScopeAuthorizer +{ + public const string ScopeNotAvailableCode = "realtime.scope_not_available"; + + public string ScopeKind => RealtimeEnvelope.MatchScope; + + public Task AuthorizeAsync( + Guid actorUserId, Guid scopeId, RealtimeAction action, CancellationToken ct = default) => + Task.FromResult(RealtimeScopeAuthorizationResult.Deny(ScopeNotAvailableCode)); +} diff --git a/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs b/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs new file mode 100644 index 0000000..6bb57ad --- /dev/null +++ b/src/SimPle.Application/Realtime/Authorization/RealtimeAction.cs @@ -0,0 +1,14 @@ +namespace SimPle.Application.Realtime.Authorization; + +/// +/// The action being authorized against a realtime scope (docs/specs/module-07-realtime-presence-chat-spec.md, +/// "Authorization / Privacy rules"). B1 only ever calls (via SubscribeLobby); the +/// authorizer must still implement / now because B2's chat commands will +/// call them and the authorization surface must not change shape between backend sessions. +/// +public enum RealtimeAction +{ + Subscribe, + Send, + Delete, +} diff --git a/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs b/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs new file mode 100644 index 0000000..a2c324f --- /dev/null +++ b/src/SimPle.Application/Realtime/Authorization/RealtimeScopeAuthorizationResult.cs @@ -0,0 +1,24 @@ +namespace SimPle.Application.Realtime.Authorization; + +/// +/// Allow/deny result for a realtime scope authorization check. Mirrors the Result/Result<T> Allow/Fail +/// static-factory convention already used across the Application layer (see SimPle.Shared.Common.Result), but is +/// intentionally its own type: authorization denials here always collapse to a single privacy-safe error code so +/// existence is never disclosed (docs/specs/module-07-realtime-presence-chat-spec.md, "privacy-safe: existence +/// never disclosed"). +/// +public sealed class RealtimeScopeAuthorizationResult +{ + public bool IsAllowed { get; } + public string? ErrorCode { get; } + + private RealtimeScopeAuthorizationResult(bool isAllowed, string? errorCode) + { + IsAllowed = isAllowed; + ErrorCode = errorCode; + } + + public static RealtimeScopeAuthorizationResult Allow() => new(true, null); + + public static RealtimeScopeAuthorizationResult Deny(string errorCode) => new(false, errorCode); +} diff --git a/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs b/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs new file mode 100644 index 0000000..cf732f5 --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/ChatMessageDto.cs @@ -0,0 +1,19 @@ +using SimPle.Shared.Common; + +namespace SimPle.Application.Realtime.Contracts; + +/// +/// Safe-fields-only chat message DTO (docs/specs/module-07-realtime-presence-chat-spec.md, "Response DTO"). +/// Sender is the shared M3 player-identity contract, — the same shape +/// LobbySeatDto.Identity/Host/Inviter already embed (see LobbyDtos.cs). Composing +/// modules must never clone an incompatible identity shape ('s own doc comment). +/// A deleted, blocked, or hidden sender renders a non-navigable safe tombstone — never the real profile fields. +/// +public sealed record ChatMessageDto( + Guid Id, + Guid LobbyId, + PublicIdentityDto Sender, + string? Body, + bool Deleted, + DateTime CreatedAt, + int SchemaVersion); diff --git a/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs b/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs new file mode 100644 index 0000000..c32aa29 --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/IRealtimeClient.cs @@ -0,0 +1,39 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// +/// The typed client contract for Hub<IRealtimeClient> +/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Server->client events"). All seven events — +/// including the two chat events — are declared and implemented in backend session A (M07-B1), even though +/// nothing sends / yet: this is the one place B1 +/// does work it does not consume, so that B2 (chat persistence) adds zero new hub/client contract surface. +/// +/// Every event carries a . Delivery is at-least-once, never exactly-once — callers +/// must dedupe (chat by message id, presence by (serverEpoch, userVersion), lobby hints by revision). +/// +public interface IRealtimeClient +{ + /// Sent from OnConnectedAsync. This is where the client learns the server epoch. + Task Connected(RealtimeEnvelope envelope, Guid serverEpoch); + + /// A thin hint carrying only a revision — never lobby state. Three events at one revision collapse + /// into exactly one of these. + Task LobbyChanged(RealtimeEnvelope envelope, int revision, string changeType); + + /// Dedupe by (serverEpoch, userVersion); a changed epoch clears all cached presence. + Task PresenceChanged(RealtimeEnvelope envelope, Guid userId, string status, Guid serverEpoch, long userVersion); + + /// Not sent by anything in B1 — chat does not exist yet. Declared for B2. + Task ChatMessageCreated(RealtimeEnvelope envelope, ChatMessageDto message); + + /// Not sent by anything in B1 — chat does not exist yet. Declared for B2. + Task ChatMessageDeleted(RealtimeEnvelope envelope, Guid messageId, DateTime deletedAtUtc); + + /// + /// Reasons: lobby.membership_removed | lobby.closed | auth.session_revoked | + /// auth.suspended | social.blocked. + /// + Task AccessRevoked(RealtimeEnvelope envelope, string reason); + + /// Exactly two reasons: gap, slow_consumer. + Task ResyncRequired(RealtimeEnvelope envelope, string reason, int? currentRevision); +} diff --git a/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs b/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs new file mode 100644 index 0000000..116f0e2 --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/IRealtimeNotifier.cs @@ -0,0 +1,38 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// +/// Server-side fan-out abstraction wrapping plus envelope construction and +/// group/user targeting. Declared and implemented in B1 (the concrete SignalR-backed implementation lives in the +/// API layer, since it needs the concrete hub type) but has no caller yet — B2's outbox handler +/// (LobbyRealtimeHandler) and chat commands are what will actually invoke these methods. Shipping this now +/// with its final shape is what keeps B2 from needing to touch the hub/client contract at all. +/// +public interface IRealtimeNotifier +{ + Task NotifyLobbyChangedAsync(Guid lobbyId, int revision, string changeType, CancellationToken ct = default); + + Task NotifyPresenceChangedAsync( + Guid subjectUserId, IReadOnlyCollection viewerUserIds, string status, Guid serverEpoch, + long userVersion, CancellationToken ct = default); + + /// Delivered only to , not the whole lobby group — the caller + /// (ChatService) has already excluded any member blocked (either direction) with the sender, so a + /// group broadcast here would bypass that filtering. The sender is always included, for their own other + /// connections. + Task NotifyChatMessageCreatedAsync( + Guid lobbyId, IReadOnlyCollection recipientUserIds, ChatMessageDto message, + CancellationToken ct = default); + + /// See — same block-aware recipient filtering + /// applies to delete/tombstone fan-out. + Task NotifyChatMessageDeletedAsync( + Guid lobbyId, IReadOnlyCollection recipientUserIds, Guid messageId, DateTime deletedAtUtc, + CancellationToken ct = default); + + /// Sends AccessRevoked to the user's connections. Does not close them — pair with + /// IRealtimeConnectionCloser for an actual proactive close. + Task NotifyAccessRevokedAsync(Guid userId, string reason, CancellationToken ct = default); + + Task NotifyResyncRequiredAsync( + Guid lobbyId, string reason, int? currentRevision, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs b/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs new file mode 100644 index 0000000..68aba0f --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/RealtimeEnvelope.cs @@ -0,0 +1,26 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// +/// Every server->client realtime event carries this envelope (docs/specs/module-07-realtime-presence-chat-spec.md, +/// API Contract). is one of , , +/// — never anything else. +/// +/// Forward-compatibility marker for the envelope/payload shape. +/// Unique per emitted event; used for client-side dedupe. +/// The server's clock at emission time. Never used for security decisions. +/// "lobby" | "match" | "user". +/// The lobby id, match id, or user id the event concerns. +public sealed record RealtimeEnvelope(int SchemaVersion, Guid EventId, DateTime ServerUtc, string Scope, Guid ScopeId) +{ + public const string LobbyScope = "lobby"; + public const string MatchScope = "match"; + public const string UserScope = "user"; + + public const int CurrentSchemaVersion = 1; + + public static RealtimeEnvelope ForLobby(Guid lobbyId, DateTime nowUtc) => + new(CurrentSchemaVersion, Guid.NewGuid(), nowUtc, LobbyScope, lobbyId); + + public static RealtimeEnvelope ForUser(Guid userId, DateTime nowUtc) => + new(CurrentSchemaVersion, Guid.NewGuid(), nowUtc, UserScope, userId); +} diff --git a/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs b/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs new file mode 100644 index 0000000..4708c5c --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/RealtimeGroups.cs @@ -0,0 +1,11 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// +/// SignalR group names are server-derived, never client-supplied — clients pass lobby ids only (never group +/// names, never lobby codes). Groups are a delivery optimization only; they are never checked for authorization +/// and never used as membership storage (docs/specs/module-07-realtime-presence-chat-spec.md). +/// +public static class RealtimeGroups +{ + public static string Lobby(Guid lobbyId) => $"lobby:{lobbyId:N}"; +} diff --git a/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs b/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs new file mode 100644 index 0000000..4d56693 --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/SendLobbyMessageResultDto.cs @@ -0,0 +1,5 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// Result of the hub's SendLobbyMessage method (docs/specs/module-07-realtime-presence-chat-spec. +/// md, API contract). Mirrors 's shape. +public sealed record SendLobbyMessageResultDto(ChatMessageDto Message); diff --git a/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs b/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs new file mode 100644 index 0000000..d6ac892 --- /dev/null +++ b/src/SimPle.Application/Realtime/Contracts/SubscribeLobbyResultDto.cs @@ -0,0 +1,8 @@ +namespace SimPle.Application.Realtime.Contracts; + +/// +/// Ack returned from RealtimeHub.SubscribeLobby. Carries the lobby's current revision so the client can +/// immediately fetch the authorized snapshot (GET /api/lobbies/{lobbyId}) without waiting for a +/// ResyncRequired — subscribing never needs one. +/// +public sealed record SubscribeLobbyResultDto(int Revision); diff --git a/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs b/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs new file mode 100644 index 0000000..0b88f44 --- /dev/null +++ b/src/SimPle.Application/Realtime/IRealtimeRateLimiter.cs @@ -0,0 +1,22 @@ +namespace SimPle.Application.Realtime; + +/// +/// Hub-invocation rate limiting. ASP.NET Core's built-in Microsoft.AspNetCore.RateLimiting middleware only +/// applies to HTTP endpoints, not SignalR hub method invocations, so this is a small hand-rolled abstraction over +/// System.Threading.RateLimiting.PartitionedRateLimiter<Guid> (docs/specs/module-07-realtime-presence-chat-spec.md). +/// Unused by any caller in B1 (no SendLobbyMessage exists yet) but declared now so B2's chat send path has +/// a stable rate-limiting contract to call into. +/// +public interface IRealtimeRateLimiter +{ + /// True if may open one more realtime connection (max 5 concurrent). + /// Does not reserve/consume anything by itself — pair with on disconnect. + bool TryAcquireConnection(Guid userId); + + /// Releases one connection slot previously acquired via . + void ReleaseConnection(Guid userId); + + /// True if may send one more message right now: 5 per 5s burst AND + /// 20 per 60s sustained — both must pass. + bool TryAcquireMessage(Guid userId); +} diff --git a/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs b/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs new file mode 100644 index 0000000..292ce55 --- /dev/null +++ b/src/SimPle.Application/Realtime/Outbox/LobbyRealtimeHandler.cs @@ -0,0 +1,224 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Application.Outbox; +using SimPle.Application.Realtime.Contracts; +using SimPle.Application.Realtime.Presence; +using SimPle.Domain.Outbox; + +namespace SimPle.Application.Realtime.Outbox; + +/// +/// Consumes every Lobby-aggregate integration event (docs/specs/module-07-realtime-presence-chat-spec.md, +/// "Activation watermark") and turns it into the thin LobbyChanged hint — never lobby state itself. Only +/// events built via 's Lobby-aggregate helper are consumed: LobbyInvite* and +/// MatchRequestedV1 key off a different aggregate id (the invite/request, not the lobby) and a different +/// domain-version counter, so mixing them into this handler's revision bookkeeping would be wrong, not just +/// unnecessary. +/// +/// +/// Backfill suppression. The first time this handler ever runs, OutboxRepository +/// .BackfillMissingDeliveriesAsync materializes a delivery row for every historical Lobby-aggregate event — +/// without a watermark, that first boot would replay the platform's entire lobby history as live realtime +/// traffic. captures MAX(OccurredAtUtc, Id) over this handler's own +/// event types at first activation; any message at or before that instant is pre-existing history and is +/// suppressed with no fan-out at all. +/// +/// +/// +/// Revision bookkeeping is deliberately in-memory and per-process (), the same tradeoff B1's presence registry already makes — OutboxDelivery +/// is the durable at-least-once record; this dictionary only exists to collapse same-revision fan-out and detect +/// gaps within one process's lifetime. Losing it on restart costs at most a redundant hint or two, never a +/// correctness problem, because LobbyChanged is a hint the client always resolves by re-fetching the +/// authoritative snapshot. +/// +/// +public sealed class LobbyRealtimeHandler : IOutboxHandler +{ + public const string Name = "lobby-realtime"; + + private static readonly IReadOnlyList ConsumedEventTypes = new[] + { + LobbyOutbox.LobbyCreated, + LobbyOutbox.LobbyMemberJoined, + LobbyOutbox.LobbyMemberLeft, + LobbyOutbox.LobbyMemberKicked, + LobbyOutbox.LobbyHostTransferred, + LobbyOutbox.LobbySettingsChanged, + LobbyOutbox.LobbyClosed, + LobbyOutbox.LobbyCredentialRotated, + LobbyOutbox.LobbyReadinessChanged, + }; + + private readonly IOutboxActivationStore _activation; + private readonly IRealtimeNotifier _notifier; + private readonly IPresenceRegistry _presence; + private readonly IPresenceViewerResolver _viewerResolver; + private readonly ILobbyRepository _lobbies; + private readonly TimeProvider _clock; + private readonly ILogger _logger; + + /// lobbyId -> last-notified Lobby.Revision (the event's AggregateDomainVersion). + private readonly ConcurrentDictionary _lastNotifiedRevision = new(); + + private static readonly JsonSerializerOptions PayloadOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public LobbyRealtimeHandler( + IOutboxActivationStore activation, + IRealtimeNotifier notifier, + IPresenceRegistry presence, + IPresenceViewerResolver viewerResolver, + ILobbyRepository lobbies, + TimeProvider clock, + ILogger logger) + { + _activation = activation; + _notifier = notifier; + _presence = presence; + _viewerResolver = viewerResolver; + _lobbies = lobbies; + _clock = clock; + _logger = logger; + } + + /// Persisted in every delivery row. Renaming it replays the entire lobby-realtime history. + public string HandlerName => Name; + + public IReadOnlyList EventTypes => ConsumedEventTypes; + + public async Task HandleAsync(OutboxMessage message, CancellationToken ct = default) + { + var nowUtc = _clock.GetUtcNow().UtcDateTime; + var activation = await _activation.GetOrActivateAsync(HandlerName, ConsumedEventTypes, nowUtc, ct); + + if (IsAtOrBeforeWatermark(message, activation)) + return; + + // Independent of the revision-collapse dedup below: that dedup only exists to collapse the LobbyChanged + // *hint* when several events share one Lobby.Revision (e.g. Leave + HostTransfer + Close), never to + // suppress a genuine membership-flag mutation. Presence must react to every one of those sibling events. + await ApplyPresenceSideEffectAsync(message, ct); + + // For every event built via LobbyOutbox's LobbyEvent() helper — every type in ConsumedEventTypes — the + // aggregate id *is* the lobby id and the domain version *is* Lobby.Revision. The handler never trusts the + // payload for anything (docs: "the handler never trusts the payload") — this is metadata on the envelope + // itself, not the JSON body. + var lobbyId = message.AggregateId; + var revision = message.AggregateDomainVersion; + + var lastSeen = _lastNotifiedRevision.GetOrAdd(lobbyId, 0L); + + if (revision <= lastSeen) + { + // At or behind the last-announced revision: a duplicate at-least-once redelivery of this exact + // message, a sibling event collapsed at the same Lobby.Revision (Leave legitimately emits + // MemberLeftV1 + HostTransferredV1 + LobbyClosedV1 at one revision — they must collapse into + // exactly one LobbyChanged), or a rare out-of-order older event arriving after a newer one already + // announced. Nothing new to tell clients either way. + return; + } + + // A first-ever post-watermark sighting of a lobby (lastSeen == 0, its GetOrAdd default) has no prior + // baseline to compare against, so it is accepted as the new baseline rather than flagged as a gap — the + // client's own GET /api/lobbies/{lobbyId} is the source of truth; this hint only tells it to fetch one. + if (lastSeen != 0 && revision > lastSeen + 1) + { + _lastNotifiedRevision[lobbyId] = revision; + await _notifier.NotifyResyncRequiredAsync(lobbyId, "gap", (int)revision, ct); + _logger.LogInformation( + "Realtime: lobby revision gap detected. LobbyId={LobbyId} LastSeen={LastSeen} Revision={Revision}", + lobbyId, lastSeen, revision); + return; + } + + _lastNotifiedRevision[lobbyId] = revision; + await _notifier.NotifyLobbyChangedAsync(lobbyId, (int)revision, message.EventType, ct); + } + + /// The outbox has no global sequence, so (OccurredAtUtc, Id) — the same key the watermark was + /// captured with — is the only ordering comparison available. + private static bool IsAtOrBeforeWatermark(OutboxMessage message, OutboxHandlerActivation activation) + { + if (message.OccurredAtUtc < activation.WatermarkOccurredAtUtc) return true; + if (message.OccurredAtUtc > activation.WatermarkOccurredAtUtc) return false; + + return activation.WatermarkEventId is Guid watermarkId && message.Id.CompareTo(watermarkId) <= 0; + } + + /// + /// Turns a membership-affecting Lobby event into the presence-registry mutation + /// never gets told about on its own — nothing previously called SetLobbyMembership or + /// NotifyPresenceChangedAsync for any of these transitions, which is why a lobby seat's presence dot + /// never lit up (the bug this handler exists to fix). + /// + private async Task ApplyPresenceSideEffectAsync(OutboxMessage message, CancellationToken ct) + { + switch (message.EventType) + { + case LobbyOutbox.LobbyCreated: + { + var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions); + if (payload is null || payload.HostUserId == Guid.Empty) { LogMalformedPayload(message); return; } + await SetMembershipAndBroadcastAsync(payload.HostUserId, payload.LobbyId, true, ct); + return; + } + case LobbyOutbox.LobbyMemberJoined: + { + var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions); + if (payload is null || payload.UserId == Guid.Empty) { LogMalformedPayload(message); return; } + await SetMembershipAndBroadcastAsync(payload.UserId, payload.LobbyId, true, ct); + return; + } + case LobbyOutbox.LobbyMemberLeft: + case LobbyOutbox.LobbyMemberKicked: + { + var payload = JsonSerializer.Deserialize(message.Payload, PayloadOptions); + if (payload is null || payload.UserId == Guid.Empty) { LogMalformedPayload(message); return; } + await SetMembershipAndBroadcastAsync(payload.UserId, payload.LobbyId, false, ct); + return; + } + case LobbyOutbox.LobbyClosed: + { + // The event payload carries only lobbyId/reason, so the roster to clear has to come from a + // re-read. Close/expiry now releases every seat (Lobby.CloseInternal/TryExpire call + // ReleaseAllJoinedMembers), so JoinedMembers is already empty by the time this handler runs — + // Members (every seat regardless of state) is the only list that still names who to clear. + var lobby = await _lobbies.GetByIdAsync(message.AggregateId, ct); + if (lobby is null) return; + + foreach (var member in lobby.Members) + await SetMembershipAndBroadcastAsync(member.UserId, lobby.Id, false, ct); + return; + } + } + } + + private async Task SetMembershipAndBroadcastAsync(Guid userId, Guid lobbyId, bool isMember, CancellationToken ct) + { + var result = _presence.SetLobbyMembership(userId, lobbyId, isMember); + if (!result.Changed) + return; + + var viewers = await _viewerResolver.ResolveAsync(userId, ct); + await _notifier.NotifyPresenceChangedAsync( + userId, viewers, result.Status.ToString(), result.ServerEpoch, result.UserVersion, ct); + } + + private void LogMalformedPayload(OutboxMessage message) => + _logger.LogWarning( + "Realtime: presence side-effect payload could not be read. EventType={EventType} EventId={EventId}", + message.EventType, message.Id); + + /// Matches LobbyOutbox.LobbyCreatedEvent's payload. + private sealed record LobbyCreatedPayload(Guid LobbyId, Guid HostUserId, string GameSlug); + + /// Matches LobbyOutbox.MemberJoinedEvent/MemberLeftEvent/MemberKickedEvent's + /// shared ids — the kicker's id is irrelevant to presence, so it is not modeled here. + private sealed record MemberPayload(Guid LobbyId, Guid UserId); +} diff --git a/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs b/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs new file mode 100644 index 0000000..7404c11 --- /dev/null +++ b/src/SimPle.Application/Realtime/Presence/IPresenceRegistry.cs @@ -0,0 +1,40 @@ +namespace SimPle.Application.Realtime.Presence; + +/// +/// A single presence change worth telling clients about. is false when the call was a no-op +/// (status didn't actually change) — callers must not fan out a PresenceChanged event when this is false, +/// or UserVersion would appear to move without a real change. +/// +public sealed record PresenceUpdateResult(bool Changed, PresenceStatus Status, Guid ServerEpoch, long UserVersion); + +/// +/// Tracks per-user, per-connection presence in memory only (no persistence, no cross-instance sync — single +/// instance for B1, see spec Risk Register). Every mutating/query method is lazy: status is computed from +/// timestamps against the injected at call time, there is no background timer. +/// +public interface IPresenceRegistry +{ + /// Stable per-instance identifier, regenerated on every process restart. Clients compare this + /// against their cached value and discard stale presence when it changes. + Guid ServerEpoch { get; } + + /// Registers a new connection for the user. Returns false (and registers nothing) if the user is + /// already at the connection cap — the hub must reject the connection with Realtime.ConnectionLimit. + bool TryConnect(Guid userId, string connectionId); + + /// Removes one connection. If it was the user's last live connection, the user enters the offline + /// debounce window rather than going Offline immediately. + void Disconnect(Guid userId, string connectionId); + + /// Records activity on one connection, resetting its away timer. Throttled to at most once per 60s + /// per connection — a rejected (throttled) signal returns false and mutates nothing. + bool TryReportActivity(Guid userId, string connectionId); + + /// Adds the lobby to the user's membership set (drives ). Distinct + /// from subscription — subscribing to a lobby's updates never by itself implies membership presence. + PresenceUpdateResult SetLobbyMembership(Guid userId, Guid lobbyId, bool isMember); + + /// Computes the user's current aggregated status as of now, evaluating away/offline debounce lazily. + /// Returns Offline with no user-version history if the user has never connected. + PresenceUpdateResult GetStatus(Guid userId); +} diff --git a/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs b/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs new file mode 100644 index 0000000..85ae4b7 --- /dev/null +++ b/src/SimPle.Application/Realtime/Presence/IPresenceViewerResolver.cs @@ -0,0 +1,17 @@ +namespace SimPle.Application.Realtime.Presence; + +/// +/// Resolves who is authorized to receive a PresenceChanged broadcast for a given subject right now +/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Presence visibility (approved)"). +/// +/// Only the lobby-co-member case has a real UI consumer today (LobbyPage.tsx's seat presence dot) — the +/// broader Public/FriendsOnly fan-out the approved policy describes has no caller yet (no friends-list or +/// profile-page presence indicator exists), so this resolver deliberately narrows to what is actually rendered: +/// the subject themself (their own sidebar/topbar/dashboard avatar updates live) plus their current lobby's +/// other joined members, minus anyone blocked in either direction. Extending this to the full policy is a new +/// UI feature, not a bug fix, and should be scoped and approved separately. +/// +public interface IPresenceViewerResolver +{ + Task> ResolveAsync(Guid subjectUserId, CancellationToken ct = default); +} diff --git a/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs b/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs new file mode 100644 index 0000000..258411d --- /dev/null +++ b/src/SimPle.Application/Realtime/Presence/PresenceRegistry.cs @@ -0,0 +1,192 @@ +namespace SimPle.Application.Realtime.Presence; + +/// +/// In-memory presence tracking (docs/specs/module-07-realtime-presence-chat-spec.md, "Domain Invariants"). +/// Single-instance only — no cross-instance sync (see spec Risk Register; a future multi-instance deployment +/// needs a backplane, out of scope for B1). Every computation is lazy: there is no background timer, status is +/// derived from timestamps against the injected at call time (test convention mirrors +/// tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs's FakeTimeProvider usage). +/// +public sealed class PresenceRegistry : IPresenceRegistry +{ + internal const int MaxConnectionsPerUser = 5; + internal static readonly TimeSpan AwayThreshold = TimeSpan.FromMinutes(5); + internal static readonly TimeSpan ActivityThrottle = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan OfflineDebounce = TimeSpan.FromSeconds(10); + + private readonly TimeProvider _timeProvider; + private readonly Guid _serverEpoch = Guid.NewGuid(); + private readonly object _lock = new(); + private readonly Dictionary _users = new(); + + public PresenceRegistry(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + + public Guid ServerEpoch => _serverEpoch; + + public bool TryConnect(Guid userId, string connectionId) + { + lock (_lock) + { + var state = GetOrCreate(userId); + if (!state.Connections.ContainsKey(connectionId) && state.Connections.Count >= MaxConnectionsPerUser) + return false; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + state.Connections[connectionId] = now; + state.DisconnectedAtUtc = null; + Recompute(state, now); + return true; + } + } + + public void Disconnect(Guid userId, string connectionId) + { + lock (_lock) + { + if (!_users.TryGetValue(userId, out var state)) + return; + + state.Connections.Remove(connectionId); + var now = _timeProvider.GetUtcNow().UtcDateTime; + if (state.Connections.Count == 0) + state.DisconnectedAtUtc = now; + + Recompute(state, now); + EvictIfExpired(userId, state, now); + } + } + + public bool TryReportActivity(Guid userId, string connectionId) + { + lock (_lock) + { + if (!_users.TryGetValue(userId, out var state) || + !state.Connections.TryGetValue(connectionId, out var lastActivity)) + return false; + + var now = _timeProvider.GetUtcNow().UtcDateTime; + if (now - lastActivity < ActivityThrottle) + return false; + + state.Connections[connectionId] = now; + Recompute(state, now); + return true; + } + } + + public PresenceUpdateResult SetLobbyMembership(Guid userId, Guid lobbyId, bool isMember) + { + lock (_lock) + { + var state = GetOrCreate(userId); + if (isMember) + state.MemberOfLobbyIds.Add(lobbyId); + else + state.MemberOfLobbyIds.Remove(lobbyId); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var result = Recompute(state, now); + EvictIfExpired(userId, state, now); + return result; + } + } + + public PresenceUpdateResult GetStatus(Guid userId) + { + lock (_lock) + { + if (!_users.TryGetValue(userId, out var state)) + return new PresenceUpdateResult(false, PresenceStatus.Offline, _serverEpoch, 0); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var result = Recompute(state, now); + EvictIfExpired(userId, state, now); + return result; + } + } + + private UserState GetOrCreate(Guid userId) + { + if (!_users.TryGetValue(userId, out var state)) + { + state = new UserState(); + _users[userId] = state; + } + return state; + } + + private PresenceUpdateResult Recompute(UserState state, DateTime now) + { + var status = ComputeStatus(state, now); + var changed = status != state.LastStatus; + if (changed) + { + state.LastStatus = status; + state.UserVersion++; + } + return new PresenceUpdateResult(changed, status, _serverEpoch, state.UserVersion); + } + + private static PresenceStatus ComputeStatus(UserState state, DateTime now) + { + PresenceStatus baseStatus; + if (state.Connections.Count > 0) + { + baseStatus = PresenceStatus.Offline; + foreach (var lastActivity in state.Connections.Values) + { + var connectionStatus = now - lastActivity >= AwayThreshold + ? PresenceStatus.Away + : PresenceStatus.Online; + if (connectionStatus > baseStatus) + baseStatus = connectionStatus; + } + + state.LastAliveBase = baseStatus; + } + else if (state.DisconnectedAtUtc is { } disconnectedAt && now - disconnectedAt < OfflineDebounce) + { + // Debounce grace: preserve the last known live status rather than flapping to Offline immediately + // (e.g. a page refresh reconnects within a second or two). + baseStatus = state.LastAliveBase; + } + else + { + baseStatus = PresenceStatus.Offline; + } + + // InLobby is a separate axis driven only by MemberOfLobbyIds (never by SubscribedLobbyIds, which drives + // fan-out only) — and only overlays a genuinely-connected/grace-period user, never a truly Offline one. + if (baseStatus != PresenceStatus.Offline && + state.MemberOfLobbyIds.Count > 0 && + baseStatus < PresenceStatus.InLobby) + { + baseStatus = PresenceStatus.InLobby; + } + + return baseStatus; + } + + private void EvictIfExpired(Guid userId, UserState state, DateTime now) + { + if (state.Connections.Count == 0 && + state.DisconnectedAtUtc is { } disconnectedAt && + now - disconnectedAt >= OfflineDebounce) + { + _users.Remove(userId); + } + } + + private sealed class UserState + { + public Dictionary Connections { get; } = new(); + public HashSet MemberOfLobbyIds { get; } = new(); + public DateTime? DisconnectedAtUtc { get; set; } + public PresenceStatus LastAliveBase { get; set; } = PresenceStatus.Offline; + public PresenceStatus LastStatus { get; set; } = PresenceStatus.Offline; + public long UserVersion { get; set; } + } +} diff --git a/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs b/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs new file mode 100644 index 0000000..d42c33c --- /dev/null +++ b/src/SimPle.Application/Realtime/Presence/PresenceStatus.cs @@ -0,0 +1,16 @@ +namespace SimPle.Application.Realtime.Presence; + +/// +/// Ephemeral, in-memory-only presence status (docs/specs/module-07-realtime-presence-chat-spec.md, "Domain +/// Invariants: presence precedence"). Ordinal value is precedence for Max-based aggregation across a user's +/// connections — never persisted, never confused with the pre-existing persisted User.Status +/// (SimPle.Domain.Users.UserStatus), which is a different field with different semantics. +/// +public enum PresenceStatus +{ + Offline = 0, + Away = 1, + Online = 2, + InLobby = 3, + Playing = 4, +} diff --git a/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs b/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs new file mode 100644 index 0000000..66263da --- /dev/null +++ b/src/SimPle.Application/Realtime/Presence/PresenceViewerResolver.cs @@ -0,0 +1,44 @@ +using SimPle.Application.Common.Interfaces; + +namespace SimPle.Application.Realtime.Presence; + +/// +/// Self + current lobby co-members, block-filtered in either direction. See +/// for why the broader Public/FriendsOnly policy is not implemented here. +/// +public sealed class PresenceViewerResolver : IPresenceViewerResolver +{ + private readonly ILobbyRepository _lobbies; + + public PresenceViewerResolver(ILobbyRepository lobbies) + { + _lobbies = lobbies; + } + + public async Task> ResolveAsync(Guid subjectUserId, CancellationToken ct = default) + { + var lobby = await _lobbies.GetActiveLobbyForUserAsync(subjectUserId, ct); + if (lobby is null) + return new[] { subjectUserId }; + + var coMemberIds = lobby.JoinedMembers + .Select(m => m.UserId) + .Where(id => id != subjectUserId) + .ToList(); + + if (coMemberIds.Count == 0) + return new[] { subjectUserId }; + + var blocked = await _lobbies.GetBlockedCounterpartsAsync(subjectUserId, coMemberIds, ct); + var blockedSet = blocked.Count == 0 ? null : new HashSet(blocked); + + var viewers = new List(coMemberIds.Count + 1) { subjectUserId }; + foreach (var id in coMemberIds) + { + if (blockedSet is null || !blockedSet.Contains(id)) + viewers.Add(id); + } + + return viewers; + } +} diff --git a/src/SimPle.Domain/Chat/ChatMessage.cs b/src/SimPle.Domain/Chat/ChatMessage.cs index 7e58a9d..e0b4428 100644 --- a/src/SimPle.Domain/Chat/ChatMessage.cs +++ b/src/SimPle.Domain/Chat/ChatMessage.cs @@ -2,27 +2,97 @@ namespace SimPle.Domain.Chat; +/// +/// Chat scope. Deliberately no DirectMessage member — direct messages are an explicit Module 7 non-goal +/// (docs/specs/module-07-realtime-presence-chat-spec.md, "Non-Goals"). Do not add one back. +/// +public enum ChatScope +{ + Lobby = 0, + Match = 1, +} + +/// +/// A persisted lobby/match chat message (docs/specs/module-07-realtime-presence-chat-spec.md, "Data Model"). +/// Replaces the M07-B1-era orphan stub outright (no DbSet, no EF config, no migration, no consumer ever existed +/// for it) rather than migrating it. +/// +/// +/// Author deletion does not clear . The spec states this explicitly: "a +/// deleted body persists on disk for up to 30 days, by design" so M12's evidence copy can still run after an +/// author deletes. The read path (ChatService/DTO projection) is what returns +/// body: null, deleted: true — the body itself never leaves the server again once deleted. Never project +/// directly for a deleted row. +/// +/// +/// +/// is overwritten from the caller's injected TimeProvider at construction +/// (Risk #7) — Entity's own default is a raw , which would silently defeat the +/// fake-clock retention/hold tests. +/// +/// public class ChatMessage : Entity { + /// Chat is retained 30 days. There is no separate M7 evidence-retention policy — M12 owns its own + /// two-year moderation-evidence domain in its own table via the seam. + public static readonly TimeSpan RetentionPeriod = TimeSpan.FromDays(30); + + public ChatScope Scope { get; private set; } + public Guid ScopeId { get; private set; } public Guid SenderId { get; private set; } - public ChatContext Context { get; private set; } - public Guid ContextId { get; private set; } // LobbyId or SessionId - public string Content { get; private set; } = default!; - public bool IsDeleted { get; private set; } - public bool IsFlagged { get; private set; } + + /// NFC-normalized, LF-only, 1-1000 Unicode scalars. Normalized by ChatBodyNormalizer BEFORE + /// this is constructed — this type does not re-validate it. + public string Body { get; private set; } = default!; + + public int SchemaVersion { get; private set; } = 1; + + /// Idempotency key supplied by the client. Unique with + /// (ux_chat_messages_sender_command) — a duplicate send catches 23505 and returns the original. + public Guid ClientCommandId { get; private set; } + + public DateTime? DeletedAtUtc { get; private set; } + public Guid? DeletedByUserId { get; private set; } + + /// + , fixed at creation. The cleanup + /// sweep's scan key (ix_chat_messages_retain). + public DateTime RetainUntilUtc { get; private set; } + + public bool IsDeleted => DeletedAtUtc is not null; private ChatMessage() { } - public static ChatMessage Create(Guid senderId, ChatContext context, Guid contextId, string content) => new() + public static ChatMessage Create( + ChatScope scope, Guid scopeId, Guid senderId, string normalizedBody, Guid clientCommandId, DateTime nowUtc) { - SenderId = senderId, - Context = context, - ContextId = contextId, - Content = content, - }; - - public void Delete() { IsDeleted = true; Touch(); } - public void Flag() { IsFlagged = true; Touch(); } -} + var message = new ChatMessage + { + Scope = scope, + ScopeId = scopeId, + SenderId = senderId, + Body = normalizedBody, + SchemaVersion = 1, + ClientCommandId = clientCommandId, + RetainUntilUtc = nowUtc + RetentionPeriod, + }; + + // Overwrite Entity's DateTime.UtcNow default with the injected clock (Risk #7) — accessible here because + // CreatedAt/UpdatedAt are `protected set` and this factory runs inside the derived type. + message.CreatedAt = nowUtc; + message.UpdatedAt = nowUtc; -public enum ChatContext { Lobby, Match, DirectMessage } + return message; + } + + /// Tombstones the message. Idempotent — a retried delete finds already true + /// and does nothing further, matching a chat command's UUID-idempotency convention elsewhere in this module. + /// The id is never reused and the body is never cleared (see class doc). + public void Delete(Guid deletedByUserId, DateTime nowUtc) + { + if (IsDeleted) return; + + DeletedAtUtc = nowUtc; + DeletedByUserId = deletedByUserId; + UpdatedAt = nowUtc; + } +} diff --git a/src/SimPle.Domain/Chat/ChatMessageHold.cs b/src/SimPle.Domain/Chat/ChatMessageHold.cs new file mode 100644 index 0000000..2a4bfe2 --- /dev/null +++ b/src/SimPle.Domain/Chat/ChatMessageHold.cs @@ -0,0 +1,61 @@ +using SimPle.Domain.Common; + +namespace SimPle.Domain.Chat; + +/// +/// A moderation hold against one row (docs/specs/module-07-realtime-presence-chat-spec.md, +/// "Data Model"). Created by Module 7, ships empty, written only by Module 12 through the +/// IChatRepository.PlaceHoldAsync seam this module builds but never calls. +/// +/// +/// A hold table, deliberately not a HoldCount column on chat_messages: a counter +/// column would make M12 write into an M7-owned aggregate — a boundary violation, and a field M7 could never +/// validate. This table lets M12 add its own evidence table keyed by with no schema break. +/// +/// +/// is M12's vocabulary; M7 never interprets it. +/// +public class ChatMessageHold : Entity +{ + public Guid MessageId { get; private set; } + public string ReasonCode { get; private set; } = default!; + public DateTime PlacedAtUtc { get; private set; } + + /// Null = active hold. The partial index (ix_chat_message_holds_active) that the cleanup + /// sweep's NOT EXISTS probe uses is built on exactly this predicate. + public DateTime? ReleasedAtUtc { get; private set; } + + /// Set once M12's evidence copy is durable. + public DateTime? AcknowledgedAtUtc { get; private set; } + + public bool IsActive => ReleasedAtUtc is null; + + private ChatMessageHold() { } + + public static ChatMessageHold Place(Guid messageId, string reasonCode, DateTime nowUtc) + { + var hold = new ChatMessageHold + { + MessageId = messageId, + ReasonCode = reasonCode, + PlacedAtUtc = nowUtc, + }; + + hold.CreatedAt = nowUtc; + hold.UpdatedAt = nowUtc; + + return hold; + } + + public void Release(DateTime nowUtc) + { + ReleasedAtUtc = nowUtc; + UpdatedAt = nowUtc; + } + + public void Acknowledge(DateTime nowUtc) + { + AcknowledgedAtUtc = nowUtc; + UpdatedAt = nowUtc; + } +} diff --git a/src/SimPle.Domain/Lobbies/Lobby.cs b/src/SimPle.Domain/Lobbies/Lobby.cs index 7eeea0e..521a6be 100644 --- a/src/SimPle.Domain/Lobbies/Lobby.cs +++ b/src/SimPle.Domain/Lobbies/Lobby.cs @@ -170,7 +170,7 @@ public LobbyLeaveResult Leave(Guid userId, DateTime nowUtc) var successor = JoinedMembers.FirstOrDefault(); if (successor is null) { - CloseInternal(LobbyClosedReason.NoEligibleHost); + CloseInternal(LobbyClosedReason.NoEligibleHost, nowUtc); Mutated(); return new LobbyLeaveResult(LobbyOutcome.Ok, null, LobbyClosedReason.NoEligibleHost); } @@ -279,7 +279,7 @@ public LobbyOutcome ReturnToOpen(bool resetReadiness, DateTime nowUtc) // A lobby that expired while M8 was working does not silently reopen. if (IsExpired(nowUtc)) { - CloseInternal(LobbyClosedReason.Expired); + CloseInternal(LobbyClosedReason.Expired, nowUtc); State = LobbyState.Expired; Mutated(); return LobbyOutcome.Expired; @@ -293,11 +293,11 @@ public LobbyOutcome ReturnToOpen(bool resetReadiness, DateTime nowUtc) return LobbyOutcome.Ok; } - public LobbyOutcome Close(LobbyClosedReason reason) + public LobbyOutcome Close(LobbyClosedReason reason, DateTime nowUtc) { if (IsTerminal) return LobbyOutcome.Closed; - CloseInternal(reason); + CloseInternal(reason, nowUtc); Mutated(); return LobbyOutcome.Ok; } @@ -312,6 +312,7 @@ public bool TryExpire(DateTime nowUtc) State = LobbyState.Expired; ClosedReason = LobbyClosedReason.Expired; + ReleaseAllJoinedMembers(nowUtc); Mutated(); return true; } @@ -347,10 +348,24 @@ private void ResetNonHostReadiness() member.SetReadiness(false); } - private void CloseInternal(LobbyClosedReason reason) + private void CloseInternal(LobbyClosedReason reason, DateTime nowUtc) { State = LobbyState.Closed; ClosedReason = reason; + ReleaseAllJoinedMembers(nowUtc); + } + + /// + /// Releases every still-joined seat when the lobby itself goes terminal. Without this, a member who was never + /// individually removed (e.g. the sole host of a lobby that expires) stays at LobbyMemberState.Joined + /// forever — invisible to GetActiveLobbyForUserAsync (which filters on the lobby's own terminal state) + /// but not to the DB's partial unique index on (UserId, Joined), permanently blocking that user from ever + /// joining or creating another lobby. + /// + private void ReleaseAllJoinedMembers(DateTime nowUtc) + { + foreach (var member in _members.Where(m => m.IsJoined)) + member.Leave(nowUtc); } private void Mutated() diff --git a/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs b/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs new file mode 100644 index 0000000..3d2e69e --- /dev/null +++ b/src/SimPle.Domain/Outbox/OutboxHandlerActivation.cs @@ -0,0 +1,39 @@ +namespace SimPle.Domain.Outbox; + +/// +/// Generic per-handler activation watermark (docs/specs/module-07-realtime-presence-chat-spec.md, "Activation +/// watermark"). Built by Module 7 for LobbyRealtimeHandler; M8/M11 reuse it for their own consumers. +/// +/// +/// OutboxRepository.BackfillMissingDeliveriesAsync materializes a delivery row for every historical +/// message the moment a new handler registers. Without a watermark, a handler's first boot would replay +/// every historical event of its subscribed types as if it were live traffic. This row is created exactly once per +/// : the first activation captures the outbox's own high-water mark +/// (MAX(OccurredAtUtc), tie-broken by ) at that moment, so pre-watermark +/// backfilled deliveries can be recognized and suppressed rather than acted on. +/// +/// +public sealed class OutboxHandlerActivation +{ + /// Same identifier as . Renaming a handler orphans its + /// activation row exactly the same way it orphans its delivery rows. + public string HandlerName { get; private set; } = default!; + + public DateTime ActivatedAtUtc { get; private set; } + public DateTime WatermarkOccurredAtUtc { get; private set; } + + /// Tie-break within the same instant. Null only when the outbox + /// held zero matching events at activation time (nothing to tie-break against). + public Guid? WatermarkEventId { get; private set; } + + private OutboxHandlerActivation() { } + + public static OutboxHandlerActivation Activate( + string handlerName, DateTime activatedAtUtc, DateTime watermarkOccurredAtUtc, Guid? watermarkEventId) => new() + { + HandlerName = handlerName, + ActivatedAtUtc = activatedAtUtc, + WatermarkOccurredAtUtc = watermarkOccurredAtUtc, + WatermarkEventId = watermarkEventId, + }; +} diff --git a/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs b/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs index 630d0a8..e0cdb89 100644 --- a/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs +++ b/src/SimPle.Infrastructure/Auth/TokenCleanupService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Auth; @@ -15,19 +16,24 @@ public sealed class TokenCleanupService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly TokenCleanupOptions _options; + private readonly IWorkerReadinessRegistry _readiness; public TokenCleanupService( IServiceScopeFactory scopeFactory, ILogger logger, - IOptions options) + IOptions options, + IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _logger = logger; _options = options.Value; + _readiness = readiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + _readiness.MarkStarted(RequiredWorkers.TokenCleanup); + // Stagger the first run so it doesn't run immediately on startup. await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); @@ -59,9 +65,12 @@ private async Task RunCleanupAsync(CancellationToken ct) _logger.LogInformation( "Token cleanup: deleted {Refresh} refresh, {EmailVerif} email-verification, {PasswordReset} password-reset rows (cutoff: {Cutoff:u})", refreshDeleted, emailVerifDeleted, passwordResetDeleted, cutoff); + + _readiness.MarkHealthy(RequiredWorkers.TokenCleanup); } catch (Exception ex) when (ex is not OperationCanceledException) { + _readiness.MarkUnhealthy(RequiredWorkers.TokenCleanup); _logger.LogError(ex, "Token cleanup failed. Will retry in {Interval}.", _options.Interval); } } diff --git a/src/SimPle.Infrastructure/Chat/ChatRepository.cs b/src/SimPle.Infrastructure/Chat/ChatRepository.cs new file mode 100644 index 0000000..520a585 --- /dev/null +++ b/src/SimPle.Infrastructure/Chat/ChatRepository.cs @@ -0,0 +1,221 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Chat; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Persistence; +using SimPle.Shared.Common; + +namespace SimPle.Infrastructure.Chat; + +public sealed class ChatRepository : IChatRepository +{ + private readonly AppDbContext _db; + + public ChatRepository(AppDbContext db) => _db = db; + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + _db.ChatMessages.FirstOrDefaultAsync(m => m.Id == id, ct); + + public Task FindByClientCommandIdAsync( + Guid senderId, Guid clientCommandId, CancellationToken ct = default) => + _db.ChatMessages.FirstOrDefaultAsync( + m => m.SenderId == senderId && m.ClientCommandId == clientCommandId, ct); + + public async Task AddAsync(ChatMessage message, CancellationToken ct = default) + { + await _db.ChatMessages.AddAsync(message, ct); + + try + { + await _db.SaveChangesAsync(ct); + return message; + } + catch (DbUpdateException ex) when (PostgresContention.IsContention(ex)) + { + _db.Entry(message).State = EntityState.Detached; + return await _db.ChatMessages.AsNoTracking().FirstAsync( + m => m.SenderId == message.SenderId && m.ClientCommandId == message.ClientCommandId, ct); + } + } + + public async Task> GetHistoryPageAsync( + ChatScope scope, + Guid scopeId, + ChatHistoryDirection direction, + DateTime? cursorCreatedAtUtc, + Guid? cursorId, + int limit, + CancellationToken ct = default) + { + var query = _db.ChatMessages + .AsNoTracking() + .Where(m => m.Scope == scope && m.ScopeId == scopeId); + + if (direction == ChatHistoryDirection.Before) + { + if (cursorCreatedAtUtc is DateTime beforeAt && cursorId is Guid beforeId) + { + query = query.Where(m => + m.CreatedAt < beforeAt || (m.CreatedAt == beforeAt && m.Id.CompareTo(beforeId) < 0)); + } + + var descPage = await query + .OrderByDescending(m => m.CreatedAt).ThenByDescending(m => m.Id) + .Take(limit) + .ToListAsync(ct); + + descPage.Reverse(); + return descPage; + } + + if (cursorCreatedAtUtc is DateTime afterAt && cursorId is Guid afterId) + { + query = query.Where(m => + m.CreatedAt > afterAt || (m.CreatedAt == afterAt && m.Id.CompareTo(afterId) > 0)); + } + + return await query + .OrderBy(m => m.CreatedAt).ThenBy(m => m.Id) + .Take(limit) + .ToListAsync(ct); + } + + public async Task> GetSendersAsync( + IReadOnlyList senderIds, CancellationToken ct = default) + { + if (senderIds.Count == 0) return new Dictionary(); + + var ids = senderIds.Distinct().ToArray(); + return await _db.Users + .AsNoTracking() + .Where(u => ids.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, ct); + } + + public Task SaveAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); + + // ── Retention sweep + hold seam (docs/specs/module-07-realtime-presence-chat-spec.md, "Cleanup vs moderation + // hold", Risk #5) ─────────────────────────────────────────────────────────── + + public async Task DeleteExpiredAsync(DateTime nowUtc, int batchSize, CancellationToken ct = default) + { + // InMemory (unit tests) has neither raw SQL nor row locks. The SKIP LOCKED race this method exists for is + // asserted where it can actually be proven — against real PostgreSQL (ChatRetentionHoldRaceTests). + if (!_db.Database.IsRelational()) + { + var heldIds = await _db.ChatMessageHolds + .AsNoTracking() + .Where(h => h.ReleasedAtUtc == null) + .Select(h => h.MessageId) + .ToListAsync(ct); + + var expired = await _db.ChatMessages + .Where(m => m.RetainUntilUtc <= nowUtc && !heldIds.Contains(m.Id)) + .OrderBy(m => m.RetainUntilUtc) + .Take(batchSize) + .ToListAsync(ct); + + // Every hold row still attached to a candidate id is, by construction, released (an active one would + // have excluded the message above via heldIds) — the FK is RESTRICT, so these must go with the + // message or the delete is orphaning evidence rows pointing at nothing. Real PostgreSQL enforces this + // as a 23503 (see the relational branch below); InMemory does not, but the cleanup responsibility is + // identical either way. + var candidateIds = expired.Select(m => m.Id).ToList(); + var releasedHolds = await _db.ChatMessageHolds + .Where(h => candidateIds.Contains(h.MessageId)) + .ToListAsync(ct); + + _db.ChatMessageHolds.RemoveRange(releasedHolds); + _db.ChatMessages.RemoveRange(expired); + await _db.SaveChangesAsync(ct); + return expired.Count; + } + + // One bounded statement per batch, not ExecuteDeleteAsync() (EF 8's bulk delete cannot express LIMIT + + // FOR UPDATE SKIP LOCKED) and not a select-then-delete pair (the window between those two statements is + // precisely the hold race Risk #5 exists to close). A single (multi-CTE) statement is still one atomic + // statement: the row locks "candidates" takes are held and consumed within that same statement, so there + // is no gap for PlaceHoldAsync to race into. SKIP LOCKED means a row PlaceHoldAsync is mid-transaction on + // is stepped over this pass, not blocked on and not deleted — it is picked up next cycle once uncontended. + // + // "candidates" already excludes any message with an ACTIVE hold (ReleasedAtUtc IS NULL) via the NOT EXISTS + // probe above — but the FK chat_message_holds.MessageId -> chat_messages.Id is ON DELETE RESTRICT + // unconditionally, so a RELEASED hold row (which the probe correctly ignores, per spec: "An active M12 + // hold prevents cleanup... until the copy/hold is acknowledged" — released is not active) still blocks the + // message delete with 23503 unless it is removed in the same statement. "released_holds" is a second, + // data-modifying CTE that does exactly that: Postgres always executes every data-modifying CTE in a WITH + // clause to completion regardless of whether the primary statement reads its output, so this stays one + // atomic, race-free operation rather than reopening a second select-then-delete window. + return await _db.Database.ExecuteSqlAsync( + $""" + WITH candidates AS ( + SELECT m."Id" + FROM chat_messages m + WHERE m."RetainUntilUtc" <= {nowUtc} + AND NOT EXISTS ( + SELECT 1 FROM chat_message_holds h + WHERE h."MessageId" = m."Id" AND h."ReleasedAtUtc" IS NULL + ) + ORDER BY m."RetainUntilUtc" + LIMIT {batchSize} + FOR UPDATE OF m SKIP LOCKED + ), + released_holds AS ( + DELETE FROM chat_message_holds h + USING candidates c + WHERE h."MessageId" = c."Id" + RETURNING h."Id" + ) + DELETE FROM chat_messages m + USING candidates c + WHERE m."Id" = c."Id" + """, ct); + } + + public async Task> PlaceHoldAsync( + Guid messageId, string reasonCode, DateTime nowUtc, CancellationToken ct = default) + { + if (!_db.Database.IsRelational()) + { + // No concurrent writers to race in a unit test against InMemory — the row lock this method takes on + // real PostgreSQL is asserted by ChatRetentionHoldRaceTests instead. + var exists = await _db.ChatMessages.AnyAsync(m => m.Id == messageId, ct); + if (!exists) + return Result.Fail(ChatErrors.MessageExpired, "The message has already aged out of retention."); + + var placeholderHold = ChatMessageHold.Place(messageId, reasonCode, nowUtc); + await _db.ChatMessageHolds.AddAsync(placeholderHold, ct); + await _db.SaveChangesAsync(ct); + return Result.Ok(placeholderHold); + } + + // Blocking FOR UPDATE — deliberately not SKIP LOCKED. A hold request must wait its turn on a row the + // sweep is mid-transaction on rather than give up, so the two race honestly (Risk #5): whichever side + // commits first wins, and the loser gets a truthful answer instead of a silently lost/duplicated hold. + // An explicit transaction is required here (unlike DeleteExpiredAsync's single statement) because the + // lock must be held across the existence check AND the subsequent insert. + await using var transaction = await _db.Database.BeginTransactionAsync(ct); + + var lockedIds = await _db.Database + .SqlQuery($""" + SELECT "Id" + FROM chat_messages + WHERE "Id" = {messageId} + FOR UPDATE + """) + .ToListAsync(ct); + + if (lockedIds.Count == 0) + { + await transaction.RollbackAsync(ct); + return Result.Fail(ChatErrors.MessageExpired, "The message has already aged out of retention."); + } + + var hold = ChatMessageHold.Place(messageId, reasonCode, nowUtc); + await _db.ChatMessageHolds.AddAsync(hold, ct); + await _db.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + + return Result.Ok(hold); + } +} diff --git a/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs b/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs new file mode 100644 index 0000000..9741d43 --- /dev/null +++ b/src/SimPle.Infrastructure/Chat/ChatRetentionSweeper.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SimPle.Application.Chat; +using SimPle.Infrastructure.Health; + +namespace SimPle.Infrastructure.Chat; + +/// +/// The retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, "Ownership, retention, deletion" and +/// Risk #5): periodically deletes chat messages whose RetainUntilUtc (30 days) has passed and which carry +/// no active , via . +/// Modeled directly on SimPle.Infrastructure.Auth.TokenCleanupService — same shape, same readiness +/// contract, same stagger-then-loop structure. +/// +public sealed class ChatRetentionSweeper : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly ChatRetentionOptions _options; + private readonly IWorkerReadinessRegistry _readiness; + private readonly TimeProvider _clock; + + public ChatRetentionSweeper( + IServiceScopeFactory scopeFactory, + ILogger logger, + IOptions options, + IWorkerReadinessRegistry readiness, + TimeProvider clock) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value; + _readiness = readiness; + _clock = clock; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _readiness.MarkStarted(RequiredWorkers.ChatRetention); + + // Stagger the first run so it doesn't run immediately on startup, matching TokenCleanupService. + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + await RunSweepAsync(stoppingToken); + await Task.Delay(_options.Interval, stoppingToken); + } + } + + private async Task RunSweepAsync(CancellationToken ct) + { + var nowUtc = _clock.GetUtcNow().UtcDateTime; + + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var chat = scope.ServiceProvider.GetRequiredService(); + + var deleted = await chat.DeleteExpiredAsync(nowUtc, _options.BatchSize, ct); + if (deleted > 0) + _logger.LogInformation( + "Chat retention sweep: deleted {Count} expired message(s) (cutoff: {Cutoff:u}).", + deleted, nowUtc); + + _readiness.MarkHealthy(RequiredWorkers.ChatRetention); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _readiness.MarkUnhealthy(RequiredWorkers.ChatRetention); + _logger.LogError(ex, "Chat retention sweep failed. Will retry in {Interval}.", _options.Interval); + } + } +} diff --git a/src/SimPle.Infrastructure/DependencyInjection.cs b/src/SimPle.Infrastructure/DependencyInjection.cs index 4b8229b..aeeeba5 100644 --- a/src/SimPle.Infrastructure/DependencyInjection.cs +++ b/src/SimPle.Infrastructure/DependencyInjection.cs @@ -2,16 +2,23 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using SimPle.Application.Chat; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; using SimPle.Application.Lobbies.Services; +using SimPle.Application.Outbox; +using SimPle.Application.Realtime; +using SimPle.Application.Realtime.Outbox; using SimPle.Infrastructure.Auth; +using SimPle.Infrastructure.Chat; using SimPle.Infrastructure.Email; +using SimPle.Infrastructure.Health; using SimPle.Infrastructure.Lobbies; using SimPle.Infrastructure.Matchmaking; using SimPle.Infrastructure.Outbox; using SimPle.Infrastructure.Persistence; using SimPle.Infrastructure.Persistence.Repositories; +using SimPle.Infrastructure.Realtime; using SimPle.Infrastructure.Storage; namespace SimPle.Infrastructure; @@ -64,7 +71,7 @@ public static IServiceCollection AddInfrastructureServices( // what makes Start a 503, `allowedActions` omit `start`, and the UI's disabled controls truthful rather // than decorative. Each is replaced, not rewritten, when its module lands. services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -76,6 +83,10 @@ public static IServiceCollection AddInfrastructureServices( services.AddScoped(); services.AddHttpClient(); + // Readiness is intentionally process-local: this application is deployed as a single backend instance, so + // these durable workers and the API must share one lifecycle until a later distributed design is approved. + services.AddSingleton(_ => new WorkerReadinessRegistry(RequiredWorkers.All)); + services.Configure( configuration.GetSection(TokenCleanupOptions.SectionName)); services.AddHostedService(); @@ -103,6 +114,36 @@ public static IServiceCollection AddInfrastructureServices( services.AddHostedService(); services.AddHostedService(); + // Module 7 (docs/specs/module-07-realtime-presence-chat-spec.md), backend session A (M07-B1). The default + // IRealtimeConnectionCloser is a no-op so Auth flows never throw when the realtime hub is disabled (e.g. a + // rollback); the API composition root overrides this with the real SignalR-backed implementation once the + // hub is mapped (it needs the concrete hub type, which this project cannot reference). The rate limiter is + // SignalR-independent (pure System.Threading.RateLimiting), so it lives here rather than in the API layer. + services.AddSingleton(); + services.AddSingleton(); + + // Module 7, backend session B (M07-B2). Registered now (ahead of the full step-8 DI pass) only so + // RealtimeHub's new IChatService constructor dependency and ChatController resolve — otherwise every + // existing hub-connection integration test would break the moment SendLobbyMessage was added to + // RealtimeHub. + services.Configure(configuration.GetSection(ProfanityOptions.SectionName)); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + + // Step 8: chat runtime is now live (LiveChatRuntimeProbe swap above), and LobbyRealtimeHandler joins the + // outbox dispatcher's handler set the same way LobbyBlockHandler does (OutboxDispatcherWorker resolves + // every IOutboxHandler via GetServices() — see SimPle.Application/DependencyInjection.cs). + // It is registered here rather than there because this session's ownership boundary scopes DI wiring to + // this file. + services.AddScoped(); + + // Step 9: the retention sweep (docs/specs/module-07-realtime-presence-chat-spec.md, Risk #5), modeled on + // TokenCleanupService above. + services.Configure(configuration.GetSection(ChatRetentionOptions.SectionName)); + services.AddHostedService(); + return services; } diff --git a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs index ca83325..c63d755 100644 --- a/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs +++ b/src/SimPle.Infrastructure/Games/GameCatalogSeeder.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -63,7 +64,7 @@ public async Task SeedAsync(CancellationToken ct = defaul if (validationError is not null) return Fail(validationError); - var checksum = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant(); + var checksum = ComputeManifestChecksum(manifestBytes); await using var transaction = await _db.Database.BeginTransactionAsync(ct); await _db.Database.ExecuteSqlRawAsync( @@ -178,6 +179,19 @@ private GameCatalogSeedResult Fail(string message) return new GameCatalogSeedResult(false, message, 0, 0); } + /// + /// Hashes the manifest with canonical LF line endings. Git's core.autocrlf must never make a + /// semantically identical checked-in manifest appear to be a different seed revision on Windows. + /// Formatting/content changes still change the checksum and therefore remain fail-closed. + /// + public static string ComputeManifestChecksum(ReadOnlySpan manifestBytes) + { + var normalized = Encoding.UTF8.GetString(manifestBytes) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); + } + /// /// Structural + domain-invariant validation of the whole manifest before any database access. /// Returns null when valid, or a message identifying the offending slug/field otherwise. diff --git a/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs new file mode 100644 index 0000000..e04aea0 --- /dev/null +++ b/src/SimPle.Infrastructure/Health/WorkerReadinessRegistry.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; + +namespace SimPle.Infrastructure.Health; + +/// +/// The worker names required for this single-process deployment. Keeping them central makes the readiness +/// contract explicit: adding a durable background worker means adding its heartbeat here too. +/// +public static class RequiredWorkers +{ + public const string TokenCleanup = "token-cleanup"; + public const string DismissedSuggestionCleanup = "dismissed-suggestion-cleanup"; + public const string Matchmaking = "matchmaking"; + public const string LobbyExpiry = "lobby-expiry"; + public const string OutboxDispatcher = "outbox-dispatcher"; + + /// Module 7, backend session B (M07-B2): the chat retention sweep + /// (). + public const string ChatRetention = "chat-retention"; + + public static readonly IReadOnlyCollection All = + [ + TokenCleanup, + DismissedSuggestionCleanup, + Matchmaking, + LobbyExpiry, + OutboxDispatcher, + ChatRetention, + ]; +} + +/// +/// Process-local worker readiness state. It deliberately stores no exception, configuration, or dependency detail, +/// because readiness is exposed to an unauthenticated infrastructure probe. +/// +public interface IWorkerReadinessRegistry +{ + bool AreRequiredWorkersReady { get; } + + void MarkStarted(string workerName); + void MarkHealthy(string workerName); + void MarkUnhealthy(string workerName); +} + +public sealed class WorkerReadinessRegistry : IWorkerReadinessRegistry +{ + private readonly ConcurrentDictionary _states; + + public WorkerReadinessRegistry(IEnumerable requiredWorkers) + { + _states = new ConcurrentDictionary( + requiredWorkers.Select(worker => new KeyValuePair(worker, WorkerState.NotStarted)), + StringComparer.Ordinal); + } + + public bool AreRequiredWorkersReady => _states.Count > 0 && _states.Values.All(state => state == WorkerState.Healthy); + + public void MarkStarted(string workerName) => SetState(workerName, WorkerState.Healthy); + + public void MarkHealthy(string workerName) => SetState(workerName, WorkerState.Healthy); + + public void MarkUnhealthy(string workerName) => SetState(workerName, WorkerState.Unhealthy); + + private void SetState(string workerName, WorkerState state) + { + if (!_states.ContainsKey(workerName)) + throw new ArgumentOutOfRangeException(nameof(workerName), "The worker is not part of the readiness contract."); + + _states[workerName] = state; + } + + private enum WorkerState + { + NotStarted, + Healthy, + Unhealthy, + } +} diff --git a/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs index 628b540..127164d 100644 --- a/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs +++ b/src/SimPle.Infrastructure/Lobbies/DependencyProbes.cs @@ -24,10 +24,15 @@ public Task IsInActiveMatchAsync(Guid userId, CancellationToken ct = defau Task.FromResult(false); } -/// Module 7 owns chat and live delivery. The lobby polls; there is no push here. -public sealed class NoChatRuntimeProbe : IChatRuntimeProbe +/// +/// Module 7, backend session B (M07-B2): chat persistence and lobby-event fan-out are live (docs/specs/ +/// module-07-realtime-presence-chat-spec.md) via IChatService/ChatController/hub +/// SendLobbyMessage — always true because chat runtime availability does not depend on any external process +/// being up (unlike the match runtime, it has no separate worker to poll). +/// +public sealed class LiveChatRuntimeProbe : IChatRuntimeProbe { - public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(false); + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(true); } /// diff --git a/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs index 7449244..9f8f36f 100644 --- a/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs +++ b/src/SimPle.Infrastructure/Matchmaking/LobbyExpiryWorker.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Options; using SimPle.Application.Expiry; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Matchmaking; @@ -23,15 +24,18 @@ public sealed class LobbyExpiryWorker : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ExpiryOptions _options; private readonly ILogger _logger; + private readonly IWorkerReadinessRegistry _readiness; public LobbyExpiryWorker( IServiceScopeFactory scopeFactory, IOptions options, - ILogger logger) + ILogger logger, + IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _options = options.Value; _logger = logger; + _readiness = readiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -39,9 +43,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (!_options.WorkerEnabled) { _logger.LogInformation("Lobby expiry worker is disabled by configuration; not starting."); + _readiness.MarkUnhealthy(RequiredWorkers.LobbyExpiry); return; } + _readiness.MarkStarted(RequiredWorkers.LobbyExpiry); + _logger.LogInformation( "Lobby expiry worker started. Interval={Interval} BatchSize={BatchSize}", _options.Interval, _options.BatchSize); @@ -79,9 +86,12 @@ private async Task SweepAsync(CancellationToken ct) "Expiry lag exceeded the 5s budget. MaxTicketLagMs={LagMs} Tickets={Tickets}", (long)result.MaxTicketLag.TotalMilliseconds, result.TicketsExpired); } + + _readiness.MarkHealthy(RequiredWorkers.LobbyExpiry); } catch (Exception ex) when (ex is not OperationCanceledException) { + _readiness.MarkUnhealthy(RequiredWorkers.LobbyExpiry); // Idempotent by construction: every transition it drives is a TryExpire/TryTimeOut that returns false // rather than transitioning twice, so a failed sweep leaves nothing half-done and the next tick simply // sees the same overdue rows. diff --git a/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs index aeaa08b..6bb57d2 100644 --- a/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs +++ b/src/SimPle.Infrastructure/Matchmaking/MatchmakingWorker.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Options; using SimPle.Application.Matchmaking.Services; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Matchmaking; @@ -28,16 +29,19 @@ public sealed class MatchmakingWorker : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly MatchmakingOptions _options; private readonly ILogger _logger; + private readonly IWorkerReadinessRegistry _readiness; private readonly string _workerId; public MatchmakingWorker( IServiceScopeFactory scopeFactory, IOptions options, - ILogger logger) + ILogger logger, + IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _options = options.Value; _logger = logger; + _readiness = readiness; // Machine name plus a random suffix: two instances on one host must not share an id, or their claims become // indistinguishable in exactly the situation the id exists to disambiguate. Budgeted to fit @@ -54,9 +58,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // The rollback plan calls for disabling the workers while preserving every lobby and ticket record. // Not hosting the loop is how that is done; nothing else changes. _logger.LogInformation("Matchmaking worker is disabled by configuration; not starting."); + _readiness.MarkUnhealthy(RequiredWorkers.Matchmaking); return; } + _readiness.MarkStarted(RequiredWorkers.Matchmaking); + _logger.LogInformation( "Matchmaking worker started. WorkerId={WorkerId} Interval={Interval} BatchSize={BatchSize}", _workerId, _options.Interval, _options.BatchSize); @@ -94,9 +101,12 @@ private async Task RunCycleAsync(CancellationToken ct) _workerId, result.TicketsClaimed, result.ProposalsFormed, result.TicketsMatched, (long?)result.OldestQueuedAge?.TotalMilliseconds); } + + _readiness.MarkHealthy(RequiredWorkers.Matchmaking); } catch (Exception ex) when (ex is not OperationCanceledException) { + _readiness.MarkUnhealthy(RequiredWorkers.Matchmaking); // A failed cycle is survivable by construction: the transaction rolled back, which released the // FOR UPDATE SKIP LOCKED row locks, which returned every claimed ticket to Queued. There is nothing to // compensate and nothing to clean up — the next cycle simply sees them again. diff --git a/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs new file mode 100644 index 0000000..f6730a5 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.Designer.cs @@ -0,0 +1,2045 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SimPle.Infrastructure.Persistence; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260717001316_AddChatAndRealtimeActivation")] + partial class AddChatAndRealtimeActivation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SimPle.Domain.Capabilities.CapabilitySeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("capability_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiFillEligible") + .HasColumnType("boolean"); + + b.Property>("AllowedModes") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ManifestVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("RatedEligible") + .HasColumnType("boolean"); + + b.Property>("SpectatorPolicies") + .IsRequired() + .HasColumnType("text[]"); + + b.Property>("TieBreakRules") + .IsRequired() + .HasColumnType("text[]"); + + b.Property>("TimeControls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameSlug") + .IsUnique() + .HasDatabaseName("ux_game_capability_profiles_one_active_per_game") + .HasFilter("\"IsActive\" = true"); + + b.HasIndex("GameSlug", "CapabilityVersion") + .IsUnique() + .HasDatabaseName("ux_game_capability_profiles_pin"); + + b.ToTable("game_capability_profiles", null, t => + { + t.HasCheckConstraint("ck_game_capability_profiles_modes_nonempty", "cardinality(\"AllowedModes\") > 0"); + + t.HasCheckConstraint("ck_game_capability_profiles_players", "\"MinPlayers\" >= 2 AND \"MinPlayers\" <= \"MaxPlayers\" AND \"MaxPlayers\" <= 8"); + + t.HasCheckConstraint("ck_game_capability_profiles_spectators_nonempty", "cardinality(\"SpectatorPolicies\") > 0"); + + t.HasCheckConstraint("ck_game_capability_profiles_tie_breaks_nonempty", "cardinality(\"TieBreakRules\") > 0"); + + t.HasCheckConstraint("ck_game_capability_profiles_time_controls_nonempty", "cardinality(\"TimeControls\") > 0"); + + t.HasCheckConstraint("ck_game_capability_profiles_version", "\"CapabilityVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientCommandId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedByUserId") + .HasColumnType("uuid"); + + b.Property("RetainUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SchemaVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RetainUntilUtc") + .HasDatabaseName("ix_chat_messages_retain"); + + b.HasIndex("SenderId", "ClientCommandId") + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + b.HasIndex("Scope", "ScopeId", "CreatedAt", "Id") + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcknowledgedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_message_holds_active") + .HasFilter("\"ReleasedAtUtc\" IS NULL"); + + b.ToTable("chat_message_holds", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlockedId") + .HasColumnType("uuid"); + + b.Property("BlockerId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("BlockedId"); + + b.HasIndex("BlockerId"); + + b.HasIndex("BlockerId", "BlockedId") + .IsUnique(); + + b.ToTable("blocks", null, t => + { + t.HasCheckConstraint("ck_no_self_block", "\"BlockerId\" != \"BlockedId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DismissedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SuggestedUserId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_dismissed_suggestions_expiresat"); + + b.HasIndex("SuggestedUserId"); + + b.HasIndex("UserId", "SuggestedUserId") + .IsUnique() + .HasDatabaseName("ix_dismissed_suggestions_user_suggested"); + + b.ToTable("dismissed_friend_suggestions", null, t => + { + t.HasCheckConstraint("ck_no_self_dismissal", "\"UserId\" != \"SuggestedUserId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddresseeId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainVersion") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("EndReason") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSenderId") + .HasColumnType("uuid"); + + b.Property("NextRequestAllowedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestCycleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RequesterId") + .HasColumnType("uuid"); + + b.Property("SendCountInWindow") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("SendWindowStartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TransitionActorId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_addressee_status_sentat_id"); + + b.HasIndex("RequesterId", "Status", "SentAt", "Id") + .IsDescending(false, false, true, true) + .HasDatabaseName("ix_friendships_requester_status_sentat_id"); + + b.ToTable("friendships", null, t => + { + t.HasCheckConstraint("ck_no_self_friendship", "\"RequesterId\" != \"AddresseeId\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendRequestPrivacy") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("FriendsListVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("PrivacyPolicyVersion") + .HasColumnType("bigint"); + + b.Property("SearchVisibility") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("user_friend_settings", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.CatalogSeedHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Checksum") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character(64)") + .IsFixedLength(); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ManifestVersion") + .IsUnique(); + + b.ToTable("catalog_seed_history", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArtAltText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorA") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtColorB") + .IsRequired() + .HasColumnType("text"); + + b.Property("ArtToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("EstimatedDurationMaxMinutes") + .HasColumnType("integer"); + + b.Property("EstimatedDurationMinMinutes") + .HasColumnType("integer"); + + b.Property("FeaturedRank") + .HasColumnType("integer"); + + b.Property("Lifecycle") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("LifecycleVersion") + .HasColumnType("integer"); + + b.Property("ManifestVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("MinPlayers") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RulesSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("Difficulty", "Slug") + .HasDatabaseName("ix_games_difficulty_slug"); + + b.HasIndex("EstimatedDurationMinMinutes", "Slug") + .HasDatabaseName("ix_games_duration_slug"); + + b.HasIndex("Name", "Slug") + .HasDatabaseName("ix_games_name_slug"); + + b.HasIndex("FeaturedRank", "SortOrder", "Slug") + .HasDatabaseName("ix_games_default_order"); + + b.ToTable("games", null, t => + { + t.HasCheckConstraint("ck_games_draft_retired_not_featured", "(\"Lifecycle\" <> 'Draft' AND \"Lifecycle\" <> 'Retired') OR \"FeaturedRank\" IS NULL"); + + t.HasCheckConstraint("ck_games_duration_bounds", "\"EstimatedDurationMinMinutes\" <= \"EstimatedDurationMaxMinutes\""); + + t.HasCheckConstraint("ck_games_min_players", "\"MinPlayers\" >= 1 AND \"MinPlayers\" <= \"MaxPlayers\""); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Mode") + .IsUnique(); + + b.ToTable("game_mode_capabilities", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId", "Value") + .IsUnique(); + + b.ToTable("game_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CycleId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("user_favorite_games", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiFillRequested") + .HasColumnType("boolean"); + + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("ClosedReason") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HostUserId") + .HasColumnType("uuid"); + + b.Property("MaxPlayers") + .HasColumnType("integer"); + + b.Property("Privacy") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Rated") + .HasColumnType("boolean"); + + b.Property("ResolvedRegion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Revision") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SpectatorPolicy") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TieBreakRuleId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TimeControlId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_lobbies_expiry_sweep") + .HasFilter("\"State\" IN ('Open', 'Starting')"); + + b.HasIndex("HostUserId") + .HasDatabaseName("ix_lobbies_host"); + + b.HasIndex("CreatedAt", "Id") + .HasDatabaseName("ix_lobbies_public_discovery") + .HasFilter("\"State\" = 'Open' AND \"Privacy\" = 'Public'"); + + b.ToTable("lobbies", null, t => + { + t.HasCheckConstraint("ck_lobbies_capability_version", "\"CapabilityVersion\" >= 1"); + + t.HasCheckConstraint("ck_lobbies_closed_reason_iff_terminal", "(\"State\" IN ('Closed', 'Expired')) = (\"ClosedReason\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobbies_max_players", "\"MaxPlayers\" >= 2 AND \"MaxPlayers\" <= 8"); + + t.HasCheckConstraint("ck_lobbies_revision", "\"Revision\" >= 1"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteeUserId") + .HasColumnType("uuid"); + + b.Property("InviterUserId") + .HasColumnType("uuid"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("RespondedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_lobby_invites_expiry_sweep") + .HasFilter("\"State\" = 'Pending'"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("LobbyId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("ux_lobby_invites_one_pending_per_invitee") + .HasFilter("\"State\" = 'Pending'"); + + b.HasIndex("InviteeUserId", "CreatedAt", "Id") + .HasDatabaseName("ix_lobby_invites_invitee_pending") + .HasFilter("\"State\" = 'Pending'"); + + b.ToTable("lobby_invites", null, t => + { + t.HasCheckConstraint("ck_lobby_invites_no_self_invite", "\"InviterUserId\" <> \"InviteeUserId\""); + + t.HasCheckConstraint("ck_lobby_invites_responded_iff_terminal", "(\"State\" <> 'Pending') = (\"RespondedAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Generation") + .HasColumnType("integer"); + + b.Property("LinkTokenDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SupersededAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CodeDigest") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_active_code") + .HasFilter("\"State\" = 'Active'"); + + b.HasIndex("LinkTokenDigest") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_active_link_token") + .HasFilter("\"State\" = 'Active'"); + + b.HasIndex("LobbyId") + .IsUnique() + .HasDatabaseName("ux_lobby_join_credentials_one_active_per_lobby") + .HasFilter("\"State\" = 'Active'"); + + b.ToTable("lobby_join_credentials", null, t => + { + t.HasCheckConstraint("ck_lobby_join_credentials_generation", "\"Generation\" >= 1"); + + t.HasCheckConstraint("ck_lobby_join_credentials_superseded_iff_terminal", "(\"State\" <> 'Active') = (\"SupersededAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReady") + .HasColumnType("boolean"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LeftAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("RemovedByUserId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_lobby_members_one_joined_per_user") + .HasFilter("\"State\" = 'Joined'"); + + b.HasIndex("LobbyId", "JoinedAtUtc", "UserId") + .HasDatabaseName("ix_lobby_members_lobby_tenure"); + + b.ToTable("lobby_members", null, t => + { + t.HasCheckConstraint("ck_lobby_members_left_at_iff_terminal", "(\"State\" IN ('Left', 'Kicked')) = (\"LeftAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobby_members_removed_by_only_on_kick", "\"RemovedByUserId\" IS NULL OR \"State\" = 'Kicked'"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LobbyId") + .HasColumnType("uuid"); + + b.Property("LobbyRevision") + .HasColumnType("integer"); + + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MatchRequestId") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_match_request"); + + b.HasIndex("LobbyId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_idempotency"); + + b.HasIndex("LobbyId", "LobbyRevision") + .IsUnique() + .HasDatabaseName("ux_lobby_start_requests_one_open_per_revision") + .HasFilter("\"State\" = 'Open'"); + + b.ToTable("lobby_start_requests", null, t => + { + t.HasCheckConstraint("ck_lobby_start_requests_failure_reason_only_on_failed", "\"FailureReason\" IS NULL OR \"State\" = 'Failed'"); + + t.HasCheckConstraint("ck_lobby_start_requests_resolved_iff_terminal", "(\"State\" <> 'Open') = (\"ResolvedAtUtc\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_lobby_start_requests_revision", "\"LobbyRevision\" >= 1"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("MatchRequestId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .HasDatabaseName("ix_matchmaking_assignments_group"); + + b.HasIndex("MatchRequestId") + .HasDatabaseName("ix_matchmaking_assignments_match_request"); + + b.HasIndex("TicketId") + .IsUnique() + .HasDatabaseName("ux_matchmaking_assignments_one_active_per_ticket") + .HasFilter("\"State\" = 'Active'"); + + b.ToTable("matchmaking_assignments", null, t => + { + t.HasCheckConstraint("ck_matchmaking_assignments_resolved_iff_terminal", "(\"State\" <> 'Active') = (\"ResolvedAtUtc\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CapabilityVersion") + .HasColumnType("integer"); + + b.Property("ClaimedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ClaimedByWorker") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeadlineAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EnqueuedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GameSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PlayerCount") + .HasColumnType("integer"); + + b.Property("Rated") + .HasColumnType("boolean"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("RatingSourceVersion") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedRegion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryBudget") + .HasColumnType("integer"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TimeControlId") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("DeadlineAtUtc") + .HasDatabaseName("ix_matchmaking_tickets_expiry_sweep") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ux_matchmaking_tickets_one_nonterminal_per_user") + .HasFilter("\"State\" IN ('Queued', 'Claimed', 'Requeued')"); + + b.HasIndex("GameSlug", "CapabilityVersion", "Mode", "PlayerCount", "TimeControlId", "Rated", "ResolvedRegion", "EnqueuedAtUtc", "Id") + .HasDatabaseName("ix_matchmaking_tickets_candidate_pool") + .HasFilter("\"State\" = 'Queued'"); + + b.ToTable("matchmaking_tickets", null, t => + { + t.HasCheckConstraint("ck_matchmaking_tickets_capability_version", "\"CapabilityVersion\" >= 1"); + + t.HasCheckConstraint("ck_matchmaking_tickets_deadline_after_enqueue", "\"DeadlineAtUtc\" > \"EnqueuedAtUtc\""); + + t.HasCheckConstraint("ck_matchmaking_tickets_no_worker_while_queued", "\"State\" <> 'Queued' OR \"ClaimedByWorker\" IS NULL"); + + t.HasCheckConstraint("ck_matchmaking_tickets_player_count", "\"PlayerCount\" >= 2 AND \"PlayerCount\" <= 8"); + + t.HasCheckConstraint("ck_matchmaking_tickets_retry_budget", "\"RetryBudget\" >= 0"); + }); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("DeadLettered") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("uuid"); + + b.Property("HandlerName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Lease") + .HasColumnType("timestamp with time zone"); + + b.Property("Processed") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("EventId", "HandlerName") + .IsUnique() + .HasDatabaseName("ix_outbox_deliveries_event_handler"); + + b.HasIndex("HandlerName", "Processed", "DeadLettered") + .HasDatabaseName("ix_outbox_deliveries_handler_processed_dead"); + + b.ToTable("outbox_deliveries", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxHandlerActivation", b => + { + b.Property("HandlerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ActivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WatermarkEventId") + .HasColumnType("uuid"); + + b.Property("WatermarkOccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HandlerName"); + + b.ToTable("outbox_handler_activations", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateDomainVersion") + .HasColumnType("bigint"); + + b.Property("AggregateId") + .HasColumnType("uuid"); + + b.Property("AggregateType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventVersion") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RequestCycleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OccurredAtUtc") + .HasDatabaseName("ix_outbox_messages_occurredat"); + + b.HasIndex("AggregateId", "EventType", "AggregateDomainVersion") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_aggregate_event_version"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayLabel") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("profile_external_links", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "NormalizedName") + .IsUnique(); + + b.ToTable("profile_interest_tags", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.RetiredUsername", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PriorOwnerUserId") + .HasColumnType("uuid"); + + b.Property("RetiredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("PriorOwnerUserId"); + + b.ToTable("retired_usernames", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedRequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RejectionReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RequestMonth") + .HasColumnType("integer"); + + b.Property("RequestYear") + .HasColumnType("integer"); + + b.Property("RequestedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Status"); + + b.HasIndex("UserId", "RequestYear", "RequestMonth"); + + b.ToTable("username_change_requests", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PendingEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("email_verification_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("uuid"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BannerFallbackColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("BannerObjectKey") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("BannerUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Elo") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Initials") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("IsEmailVerified") + .HasColumnType("boolean"); + + b.Property("IsSuspended") + .HasColumnType("boolean"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsernameAdminRequestMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameAdminRequestYear") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeMonth") + .HasColumnType("integer"); + + b.Property("LastUsernameImmediateChangeYear") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfileType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Player"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubscriptionTier") + .IsRequired() + .HasColumnType("text"); + + b.Property("SuspendedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("Visibility") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Public"); + + b.Property("Xp") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GoogleId") + .IsUnique() + .HasFilter("\"GoogleId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .IsUnique(); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Capabilities.GameCapabilityProfile", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameSlug") + .HasPrincipalKey("Slug") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.HasOne("SimPle.Domain.Chat.ChatMessage", null) + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("BlockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.DismissedFriendSuggestion", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SuggestedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.Friendship", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Friends.UserFriendSettings", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameModeCapability", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Capabilities") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.GameTag", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany("Tags") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.UserFavoriteGame", b => + { + b.HasOne("SimPle.Domain.Games.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("HostUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyInvite", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyJoinCredential", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyMember", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany("Members") + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.LobbyStartRequest", b => + { + b.HasOne("SimPle.Domain.Lobbies.Lobby", null) + .WithMany() + .HasForeignKey("LobbyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingAssignment", b => + { + b.HasOne("SimPle.Domain.Matchmaking.MatchmakingTicket", null) + .WithMany() + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Matchmaking.MatchmakingTicket", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxDelivery", b => + { + b.HasOne("SimPle.Domain.Outbox.OutboxMessage", null) + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileExternalLink", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.ProfileInterestTag", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Profiles.UsernameChangeRequest", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.EmailVerificationToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.PasswordResetToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Users.RefreshToken", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Games.Game", b => + { + b.Navigation("Capabilities"); + + b.Navigation("Tags"); + }); + + modelBuilder.Entity("SimPle.Domain.Lobbies.Lobby", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs new file mode 100644 index 0000000..7fef3b4 --- /dev/null +++ b/src/SimPle.Infrastructure/Migrations/20260717001316_AddChatAndRealtimeActivation.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SimPle.Infrastructure.Migrations +{ + /// + public partial class AddChatAndRealtimeActivation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Scope = table.Column(type: "integer", nullable: false), + ScopeId = table.Column(type: "uuid", nullable: false), + SenderId = table.Column(type: "uuid", nullable: false), + Body = table.Column(type: "text", nullable: false), + SchemaVersion = table.Column(type: "integer", nullable: false, defaultValue: 1), + ClientCommandId = table.Column(type: "uuid", nullable: false), + DeletedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + DeletedByUserId = table.Column(type: "uuid", nullable: true), + RetainUntilUtc = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.Id); + table.ForeignKey( + name: "FK_chat_messages_users_SenderId", + column: x => x.SenderId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "outbox_handler_activations", + columns: table => new + { + HandlerName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ActivatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + WatermarkOccurredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + WatermarkEventId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_handler_activations", x => x.HandlerName); + }); + + migrationBuilder.CreateTable( + name: "chat_message_holds", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MessageId = table.Column(type: "uuid", nullable: false), + ReasonCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PlacedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + ReleasedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AcknowledgedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_message_holds", x => x.Id); + table.ForeignKey( + name: "FK_chat_message_holds_chat_messages_MessageId", + column: x => x.MessageId, + principalTable: "chat_messages", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "ix_chat_message_holds_active", + table: "chat_message_holds", + column: "MessageId", + filter: "\"ReleasedAtUtc\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_retain", + table: "chat_messages", + column: "RetainUntilUtc"); + + migrationBuilder.CreateIndex( + name: "ix_chat_messages_scope_created_id", + table: "chat_messages", + columns: new[] { "Scope", "ScopeId", "CreatedAt", "Id" }); + + migrationBuilder.CreateIndex( + name: "ux_chat_messages_sender_command", + table: "chat_messages", + columns: new[] { "SenderId", "ClientCommandId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chat_message_holds"); + + migrationBuilder.DropTable( + name: "outbox_handler_activations"); + + migrationBuilder.DropTable( + name: "chat_messages"); + } + } +} diff --git a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 1b14a24..71877e0 100644 --- a/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/SimPle.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -139,6 +139,101 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ClientCommandId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedByUserId") + .HasColumnType("uuid"); + + b.Property("RetainUntilUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SchemaVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("Scope") + .HasColumnType("integer"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("RetainUntilUtc") + .HasDatabaseName("ix_chat_messages_retain"); + + b.HasIndex("SenderId", "ClientCommandId") + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + b.HasIndex("Scope", "ScopeId", "CreatedAt", "Id") + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcknowledgedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("PlacedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReleasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("MessageId") + .HasDatabaseName("ix_chat_message_holds_active") + .HasFilter("\"ReleasedAtUtc\" IS NULL"); + + b.ToTable("chat_message_holds", (string)null); + }); + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => { b.Property("Id") @@ -1130,6 +1225,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("outbox_deliveries", (string)null); }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxHandlerActivation", b => + { + b.Property("HandlerName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ActivatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WatermarkEventId") + .HasColumnType("uuid"); + + b.Property("WatermarkOccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HandlerName"); + + b.ToTable("outbox_handler_activations", (string)null); + }); + modelBuilder.Entity("SimPle.Domain.Outbox.OutboxMessage", b => { b.Property("Id") @@ -1661,6 +1776,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessage", b => + { + b.HasOne("SimPle.Domain.Users.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("SimPle.Domain.Chat.ChatMessageHold", b => + { + b.HasOne("SimPle.Domain.Chat.ChatMessage", null) + .WithMany() + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("SimPle.Domain.Friends.Block", b => { b.HasOne("SimPle.Domain.Users.User", null) diff --git a/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs b/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs new file mode 100644 index 0000000..fdd5ea1 --- /dev/null +++ b/src/SimPle.Infrastructure/Outbox/OutboxActivationStore.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using SimPle.Application.Outbox; +using SimPle.Domain.Outbox; +using SimPle.Infrastructure.Persistence; + +namespace SimPle.Infrastructure.Outbox; + +/// See . Idempotent try-insert/catch-23505/re-read, matching this +/// codebase's established idempotent-insert idiom (e.g. lobby join-credential code generation). +public sealed class OutboxActivationStore : IOutboxActivationStore +{ + private readonly AppDbContext _db; + + public OutboxActivationStore(AppDbContext db) => _db = db; + + public async Task GetOrActivateAsync( + string handlerName, + IReadOnlyList eventTypes, + DateTime nowUtc, + CancellationToken ct = default) + { + var existing = await _db.OutboxHandlerActivations + .AsNoTracking() + .FirstOrDefaultAsync(a => a.HandlerName == handlerName, ct); + if (existing is not null) return existing; + + // MAX(OccurredAtUtc, Id) over this handler's own event types, taken at the activation instant. The outbox + // has no global sequence, so (OccurredAtUtc, Id) is the only ordering key available — the same one the + // handler itself compares every later message against. + var latest = await _db.OutboxMessages + .AsNoTracking() + .Where(m => eventTypes.Contains(m.EventType)) + .OrderByDescending(m => m.OccurredAtUtc) + .ThenByDescending(m => m.Id) + .Select(m => new { m.OccurredAtUtc, m.Id }) + .FirstOrDefaultAsync(ct); + + var watermarkOccurredAtUtc = latest?.OccurredAtUtc ?? DateTime.MinValue; + var watermarkEventId = latest?.Id; + + var activation = OutboxHandlerActivation.Activate(handlerName, nowUtc, watermarkOccurredAtUtc, watermarkEventId); + + _db.OutboxHandlerActivations.Add(activation); + + try + { + await _db.SaveChangesAsync(ct); + return activation; + } + catch (DbUpdateException ex) when (PostgresContention.IsContention(ex)) + { + // Lost the race for this HandlerName's primary key: another instance activated first. Detach the + // failed insert and re-read the winner's row rather than retrying our own watermark computation — + // the whole point of the watermark is that it is captured exactly once, at first activation. + _db.Entry(activation).State = EntityState.Detached; + + return await _db.OutboxHandlerActivations + .AsNoTracking() + .FirstAsync(a => a.HandlerName == handlerName, ct); + } + } +} diff --git a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs index 92fe2ff..2b77c0b 100644 --- a/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs +++ b/src/SimPle.Infrastructure/Outbox/OutboxDispatcherWorker.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Options; using SimPle.Application.Outbox; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Outbox; @@ -27,16 +28,22 @@ public sealed class OutboxDispatcherWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly OutboxOptions _options; + private readonly TimeProvider _clock; private readonly ILogger _logger; + private readonly IWorkerReadinessRegistry _readiness; public OutboxDispatcherWorker( IServiceScopeFactory scopeFactory, IOptions options, - ILogger logger) + TimeProvider clock, + ILogger logger, + IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _options = options.Value; + _clock = clock; _logger = logger; + _readiness = readiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -44,9 +51,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (!_options.WorkerEnabled) { _logger.LogInformation("Outbox dispatcher is disabled by configuration; not starting."); + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); return; } + // Every handler's activation watermark (docs/specs/module-07-realtime-presence-chat-spec.md, "Activation + // watermark") must be captured here, before the loop below ever leases a message — not lazily, inside + // HandleAsync, on whichever pass first happens to find something leasable. GetOrActivateAsync's watermark + // is MAX(OccurredAtUtc, Id) over the outbox *at the moment its query runs*; if that query is deferred until + // a live event has already landed, the query sees its own event as the table's current maximum and + // classifies it as pre-existing history, silently dropping it rather than fanning it out. Activating here, + // before this process can have leased anything, keeps the watermark anchored to "before this process + // existed" rather than to an arbitrary later instant that a fast-moving live event can race into. + await ActivateAllHandlersAsync(stoppingToken); + + _readiness.MarkStarted(RequiredWorkers.OutboxDispatcher); + _logger.LogInformation( "Outbox dispatcher started. Interval={Interval} BatchSize={BatchSize} MaxAttempts={MaxAttempts}", _options.Interval, _options.BatchSize, _options.MaxAttempts); @@ -66,45 +86,82 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - private async Task DispatchAllAsync(CancellationToken ct) + private async Task ActivateAllHandlersAsync(CancellationToken ct) { - await using var scope = _scopeFactory.CreateAsyncScope(); + try + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var activation = scope.ServiceProvider.GetRequiredService(); + var handlers = scope.ServiceProvider.GetServices(); + var nowUtc = _clock.GetUtcNow().UtcDateTime; - var handlers = scope.ServiceProvider.GetServices().ToList(); - if (handlers.Count == 0) return; + foreach (var handler in handlers) + { + if (ct.IsCancellationRequested) break; + await activation.GetOrActivateAsync(handler.HandlerName, handler.EventTypes, nowUtc, ct); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Not fatal: an un-activated handler simply activates lazily on its first HandleAsync call instead, + // which is the pre-existing (racy) behavior this method exists to avoid — not a new failure mode. + _logger.LogError(ex, "Eager outbox handler activation failed; handlers will activate lazily instead."); + } + } - foreach (var handler in handlers) + private async Task DispatchAllAsync(CancellationToken ct) + { + try { - if (ct.IsCancellationRequested) break; + await using var scope = _scopeFactory.CreateAsyncScope(); - try + var handlers = scope.ServiceProvider.GetServices().ToList(); + var allHandlersSucceeded = true; + + foreach (var handler in handlers) { - // A fresh scope per handler: the processor and the handler share a scoped AppDbContext, and one - // handler's failed save must not leave a dirty change tracker for the next one to trip over. - await using var handlerScope = _scopeFactory.CreateAsyncScope(); - var processor = handlerScope.ServiceProvider.GetRequiredService(); - var scopedHandler = handlerScope.ServiceProvider - .GetServices() - .First(h => h.HandlerName == handler.HandlerName); + if (ct.IsCancellationRequested) break; - var result = await processor.DispatchAsync(scopedHandler, ct); + try + { + // A fresh scope per handler: the processor and the handler share a scoped AppDbContext, and one + // handler's failed save must not leave a dirty change tracker for the next one to trip over. + await using var handlerScope = _scopeFactory.CreateAsyncScope(); + var processor = handlerScope.ServiceProvider.GetRequiredService(); + var scopedHandler = handlerScope.ServiceProvider + .GetServices() + .First(h => h.HandlerName == handler.HandlerName); + + var result = await processor.DispatchAsync(scopedHandler, ct); - if (result.Processed > 0 || result.Failed > 0) + if (result.Processed > 0 || result.Failed > 0) + { + _logger.LogInformation( + "Outbox pass. Handler={Handler} Leased={Leased} Processed={Processed} Failed={Failed} DeadLettered={DeadLettered} OldestPendingMs={OldestMs}", + handler.HandlerName, result.Leased, result.Processed, result.Failed, result.DeadLettered, + (long?)result.OldestPendingAge?.TotalMilliseconds); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogInformation( - "Outbox pass. Handler={Handler} Leased={Leased} Processed={Processed} Failed={Failed} DeadLettered={DeadLettered} OldestPendingMs={OldestMs}", - handler.HandlerName, result.Leased, result.Processed, result.Failed, result.DeadLettered, - (long?)result.OldestPendingAge?.TotalMilliseconds); + allHandlersSucceeded = false; + // Nothing is lost: any delivery this pass leased keeps its lease only until it lapses, after which + // another pass reclaims it. That is precisely why the lease is a timestamp and not a boolean. + _logger.LogError( + ex, "Outbox dispatch pass failed. Handler={Handler}. Leases will lapse and be retried.", + handler.HandlerName); } } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Nothing is lost: any delivery this pass leased keeps its lease only until it lapses, after which - // another pass reclaims it. That is precisely why the lease is a timestamp and not a boolean. - _logger.LogError( - ex, "Outbox dispatch pass failed. Handler={Handler}. Leases will lapse and be retried.", - handler.HandlerName); - } + + if (allHandlersSucceeded) + _readiness.MarkHealthy(RequiredWorkers.OutboxDispatcher); + else + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _readiness.MarkUnhealthy(RequiredWorkers.OutboxDispatcher); + _logger.LogError(ex, "Outbox dispatcher health check failed. Will retry in {Interval}.", _options.Interval); } } } diff --git a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs index 94b0df2..390a1af 100644 --- a/src/SimPle.Infrastructure/Persistence/AppDbContext.cs +++ b/src/SimPle.Infrastructure/Persistence/AppDbContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using SimPle.Domain.Capabilities; +using SimPle.Domain.Chat; using SimPle.Domain.Friends; using SimPle.Domain.Games; using SimPle.Domain.Lobbies; @@ -56,6 +57,11 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet GameCapabilityProfiles => Set(); public DbSet CapabilitySeedHistory => Set(); + // Module 7 — chat persistence & outbox handler activation watermarks + public DbSet ChatMessages => Set(); + public DbSet ChatMessageHolds => Set(); + public DbSet OutboxHandlerActivations => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 0000000..40a3095 --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_messages"); + + builder.HasKey(m => m.Id); + + builder.Property(m => m.Scope).IsRequired(); + builder.Property(m => m.ScopeId).IsRequired(); + builder.Property(m => m.SenderId).IsRequired(); + builder.Property(m => m.Body).HasColumnType("text").IsRequired(); + builder.Property(m => m.SchemaVersion).HasDefaultValue(1).IsRequired(); + builder.Property(m => m.ClientCommandId).IsRequired(); + builder.Property(m => m.DeletedAtUtc); + builder.Property(m => m.DeletedByUserId); + builder.Property(m => m.RetainUntilUtc).IsRequired(); + + // A duplicate send (client retry) catches 23505 here and the caller re-reads the original message. + builder.HasIndex(m => new { m.SenderId, m.ClientCommandId }) + .IsUnique() + .HasDatabaseName("ux_chat_messages_sender_command"); + + // History cursor. Scope-prefixed so Module 8 (Match scope) reuses this same index shape. + builder.HasIndex(m => new { m.Scope, m.ScopeId, m.CreatedAt, m.Id }) + .HasDatabaseName("ix_chat_messages_scope_created_id"); + + // Retention cleanup scan. + builder.HasIndex(m => m.RetainUntilUtc).HasDatabaseName("ix_chat_messages_retain"); + + // RESTRICT is the backstop: even if a future cleanup bug forgets the NOT EXISTS active-hold probe, + // PostgreSQL refuses with 23503 rather than destroying moderation evidence tied to this sender. + builder.HasOne().WithMany().HasForeignKey(m => m.SenderId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs new file mode 100644 index 0000000..ea3ecbb --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/ChatMessageHoldConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Chat; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class ChatMessageHoldConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_message_holds"); + + builder.HasKey(h => h.Id); + + builder.Property(h => h.MessageId).IsRequired(); + builder.Property(h => h.ReasonCode).HasMaxLength(64).IsRequired(); + builder.Property(h => h.PlacedAtUtc).IsRequired(); + builder.Property(h => h.ReleasedAtUtc); + builder.Property(h => h.AcknowledgedAtUtc); + + // Partial index: the retention cleanup sweep's NOT EXISTS(active hold) probe. + builder.HasIndex(h => h.MessageId) + .HasFilter("\"ReleasedAtUtc\" IS NULL") + .HasDatabaseName("ix_chat_message_holds_active"); + + // RESTRICT: a held message can never be swept away by the retention cleanup while evidence exists. + builder.HasOne().WithMany().HasForeignKey(h => h.MessageId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs b/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs new file mode 100644 index 0000000..a0d298c --- /dev/null +++ b/src/SimPle.Infrastructure/Persistence/Configurations/OutboxHandlerActivationConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SimPle.Domain.Outbox; + +namespace SimPle.Infrastructure.Persistence.Configurations; + +public sealed class OutboxHandlerActivationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox_handler_activations"); + + builder.HasKey(a => a.HandlerName); + builder.Property(a => a.HandlerName).HasMaxLength(128).IsRequired(); + + builder.Property(a => a.ActivatedAtUtc).IsRequired(); + builder.Property(a => a.WatermarkOccurredAtUtc).IsRequired(); + builder.Property(a => a.WatermarkEventId); + } +} diff --git a/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs b/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs index 2ccdf21..9e2d10c 100644 --- a/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs +++ b/src/SimPle.Infrastructure/Persistence/DismissedSuggestionCleanupService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using SimPle.Application.Common.Interfaces; using SimPle.Application.Common.Options; +using SimPle.Infrastructure.Health; namespace SimPle.Infrastructure.Persistence; @@ -16,19 +17,24 @@ public sealed class DismissedSuggestionCleanupService : BackgroundService private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly DismissedSuggestionCleanupOptions _options; + private readonly IWorkerReadinessRegistry _readiness; public DismissedSuggestionCleanupService( IServiceScopeFactory scopeFactory, ILogger logger, - IOptions options) + IOptions options, + IWorkerReadinessRegistry readiness) { _scopeFactory = scopeFactory; _logger = logger; _options = options.Value; + _readiness = readiness; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + _readiness.MarkStarted(RequiredWorkers.DismissedSuggestionCleanup); + // Stagger the first run so it doesn't run immediately on startup. await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); @@ -62,9 +68,12 @@ private async Task RunCleanupAsync(CancellationToken ct) _logger.LogInformation( "Dismissed-suggestion cleanup: deleted {Count} expired rows (cutoff: {Cutoff:u})", total, now); + + _readiness.MarkHealthy(RequiredWorkers.DismissedSuggestionCleanup); } catch (Exception ex) when (ex is not OperationCanceledException) { + _readiness.MarkUnhealthy(RequiredWorkers.DismissedSuggestionCleanup); _logger.LogError(ex, "Dismissed-suggestion cleanup failed. Will retry in {Interval}.", _options.Interval); } } diff --git a/src/SimPle.Infrastructure/Realtime/IHubContext.cs b/src/SimPle.Infrastructure/Realtime/IHubContext.cs deleted file mode 100644 index 30bb5c4..0000000 --- a/src/SimPle.Infrastructure/Realtime/IHubContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace SimPle.Infrastructure.Realtime; - -// Placeholder interfaces for Module 7 (Real-Time Presence, Lobby, Chat). -// The Application layer depends on these abstractions; the concrete SignalR implementations -// will live in Infrastructure once Microsoft.AspNetCore.SignalR is wired in. - -public interface IPresenceNotifier -{ - Task UserOnlineAsync(Guid userId, CancellationToken ct = default); - Task UserOfflineAsync(Guid userId, CancellationToken ct = default); - Task FriendStatusChangedAsync(Guid friendId, string status, string activity, CancellationToken ct = default); -} - -public interface ILobbyNotifier -{ - Task LobbyUpdatedAsync(string lobbyCode, object lobbyState, CancellationToken ct = default); - Task PlayerJoinedAsync(string lobbyCode, object slot, CancellationToken ct = default); - Task PlayerLeftAsync(string lobbyCode, Guid userId, CancellationToken ct = default); - Task GameStartingAsync(string lobbyCode, Guid sessionId, CancellationToken ct = default); -} - -public interface IGameNotifier -{ - Task MatchStateUpdatedAsync(Guid sessionId, object state, CancellationToken ct = default); - Task MoveAcceptedAsync(Guid sessionId, object move, CancellationToken ct = default); - Task MoveRejectedAsync(Guid sessionId, Guid playerId, string reason, CancellationToken ct = default); - Task MatchEndedAsync(Guid sessionId, object result, CancellationToken ct = default); -} - -public interface IHardwareNotifier -{ - Task DeviceConnectedAsync(Guid deviceId, CancellationToken ct = default); - Task DeviceInputReceivedAsync(Guid deviceId, string eventType, string payload, CancellationToken ct = default); -} diff --git a/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs b/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs new file mode 100644 index 0000000..f48b7e4 --- /dev/null +++ b/src/SimPle.Infrastructure/Realtime/NullRealtimeConnectionCloser.cs @@ -0,0 +1,15 @@ +using SimPle.Application.Common.Interfaces; + +namespace SimPle.Infrastructure.Realtime; + +/// +/// No-op default DI registration for , so Auth flows (logout, logout-all, +/// revoke-session, delete-account) never throw when the realtime hub is disabled — e.g. as a rollback. The API +/// layer overrides this registration with a real SignalR-backed implementation when the hub is mapped (it needs +/// the concrete hub type, which Infrastructure cannot reference — see docs/specs/module-07-realtime-presence-chat-spec.md). +/// +public sealed class NullRealtimeConnectionCloser : IRealtimeConnectionCloser +{ + public Task CloseUserConnectionsAsync(Guid userId, string reason, CancellationToken ct = default) => + Task.CompletedTask; +} diff --git a/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs b/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs new file mode 100644 index 0000000..7fdc9e4 --- /dev/null +++ b/src/SimPle.Infrastructure/Realtime/RealtimeRateLimiter.cs @@ -0,0 +1,96 @@ +using System.Threading.RateLimiting; +using SimPle.Application.Realtime; + +namespace SimPle.Infrastructure.Realtime; + +/// +/// Hand-rolled hub-invocation rate limiter (docs/specs/module-07-realtime-presence-chat-spec.md): 5 connections +/// per user, 5 messages/5s burst chained with 20 messages/60s sustained (both must pass). Not SignalR-specific — +/// pure System.Threading.RateLimiting — so it lives in Infrastructure rather than the API layer. +/// +public sealed class RealtimeRateLimiter : IRealtimeRateLimiter, IDisposable +{ + private const int MaxConnectionsPerUser = 5; + private const int BurstPermits = 5; + private static readonly TimeSpan BurstWindow = TimeSpan.FromSeconds(5); + private const int SustainedPermits = 20; + private static readonly TimeSpan SustainedWindow = TimeSpan.FromMinutes(1); + + private readonly PartitionedRateLimiter _connectionLimiter; + private readonly PartitionedRateLimiter _burstLimiter; + private readonly PartitionedRateLimiter _sustainedLimiter; + + // One lease per acquired connection, per user — a ConcurrencyLimiter permit is only released by disposing the + // exact lease that acquired it, so a single "latest lease" field per user would leak permits from earlier + // concurrent connections. Push on acquire, pop on release (release order need not match acquire order; any + // held lease releases one permit). + private readonly System.Collections.Concurrent.ConcurrentDictionary> _connectionLeases = new(); + + public RealtimeRateLimiter() + { + _connectionLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetConcurrencyLimiter(userId, _ => new ConcurrencyLimiterOptions + { + PermitLimit = MaxConnectionsPerUser, + QueueLimit = 0, + })); + + _burstLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = BurstPermits, + Window = BurstWindow, + QueueLimit = 0, + })); + + _sustainedLimiter = PartitionedRateLimiter.Create(userId => + RateLimitPartition.GetFixedWindowLimiter(userId, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = SustainedPermits, + Window = SustainedWindow, + QueueLimit = 0, + })); + } + + public bool TryAcquireConnection(Guid userId) + { + var lease = _connectionLimiter.AttemptAcquire(userId); + if (!lease.IsAcquired) + { + lease.Dispose(); + return false; + } + + // Belt-and-suspenders redundant cap alongside the presence registry's own 5-connection enforcement + // (deliberate; see final report). + var stack = _connectionLeases.GetOrAdd(userId, _ => new System.Collections.Concurrent.ConcurrentStack()); + stack.Push(lease); + return true; + } + + public void ReleaseConnection(Guid userId) + { + if (_connectionLeases.TryGetValue(userId, out var stack) && stack.TryPop(out var lease)) + lease.Dispose(); + } + + public bool TryAcquireMessage(Guid userId) + { + using var burstLease = _burstLimiter.AttemptAcquire(userId); + if (!burstLease.IsAcquired) + return false; + + using var sustainedLease = _sustainedLimiter.AttemptAcquire(userId); + return sustainedLease.IsAcquired; + } + + public void Dispose() + { + _connectionLimiter.Dispose(); + _burstLimiter.Dispose(); + _sustainedLimiter.Dispose(); + foreach (var stack in _connectionLeases.Values) + while (stack.TryPop(out var lease)) + lease.Dispose(); + } +} diff --git a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj index c7ac31f..41d7574 100644 --- a/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj +++ b/src/SimPle.Infrastructure/SimPle.Infrastructure.csproj @@ -27,6 +27,7 @@ + diff --git a/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs b/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs new file mode 100644 index 0000000..afb0d53 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Auth/HealthEndpointsTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using FluentAssertions; +using SimPle.Api.Middleware; + +namespace SimPle.IntegrationTests.Auth; + +public sealed class HealthEndpointsTests : IDisposable +{ + private readonly TestWebApplicationFactory _factory = new(); + + [Fact] + public async Task LiveProbe_ReturnsHealthy_WhenTheProcessCanServeHttp() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/live"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync()).Should().Be("{\"status\":\"healthy\"}"); + } + + [Fact] + public async Task ReadyProbe_ReturnsGenericHealthyStatus_WithoutDependencyDetails() + { + using var client = _factory.CreateClient(); + + var response = await client.GetAsync("/health/ready"); + var body = await response.Content.ReadAsStringAsync(); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + body.Should().Be("{\"status\":\"healthy\"}") + .And.NotContain("database") + .And.NotContain("storage") + .And.NotContain("worker"); + } + + [Fact] + public async Task CorrelationId_EchoesSafeCallerValue_AndReplacesUnsafeInput() + { + using var client = _factory.CreateClient(); + using var safeRequest = new HttpRequestMessage(HttpMethod.Get, "/health/live"); + safeRequest.Headers.Add(CorrelationIdMiddleware.HeaderName, "release-42.trace_1"); + + var safeResponse = await client.SendAsync(safeRequest); + safeResponse.Headers.GetValues(CorrelationIdMiddleware.HeaderName).Should().ContainSingle() + .Which.Should().Be("release-42.trace_1"); + + using var unsafeRequest = new HttpRequestMessage(HttpMethod.Get, "/health/live"); + unsafeRequest.Headers.Add(CorrelationIdMiddleware.HeaderName, "bad _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs index 667caa4..224fd4b 100644 --- a/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs +++ b/tests/SimPle.IntegrationTests/Auth/TestWebApplicationFactory.cs @@ -54,6 +54,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["Email:Password"] = "test-password", ["Email:AppUrl"] = "http://localhost:3000", ["Google:ClientId"] = "integration-tests-google-client-id", + ["Storage:Provider"] = "S3Compatible", + ["Storage:BucketName"] = "integration-tests-profile-assets", + ["Storage:Region"] = "us-east-1", + ["Storage:ServiceUrl"] = "http://storage.invalid", + ["Storage:AccessKey"] = "integration-tests-storage-access-key", + ["Storage:SecretKey"] = "integration-tests-storage-secret-key", + ["Storage:ProfilePrefix"] = "profile-assets", ["ConnectionStrings:DefaultConnection"] = "unused-for-in-memory-tests" }); }); diff --git a/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs b/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs new file mode 100644 index 0000000..7dc59c9 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Chat/ChatEndpointsTests.cs @@ -0,0 +1,364 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Chat; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Chat; + +/// +/// REST surface tests for Module 7 backend session B (M07-B2): docs/specs/module-07-realtime-presence-chat-spec.md +/// Test Matrix -- history cursor paging, delete/tombstone, and Swagger documentation. Sending a message has no +/// REST route ('s class doc: hub-only), so the hub happy path +/// (SendLobbyMessage -> ChatMessageCreated) lives in RealtimeHubTests.cs instead. Messages here are seeded +/// directly via rather than sent, matching this file's REST-surface-only remit. +/// +public sealed class ChatEndpointsTests : IDisposable +{ + private const string TestPassword = "ValidPassword1"; + private readonly TestWebApplicationFactory _factory = new(); + + // ── History: default limit + ordering ─────────────────────────────────────── + + [Fact] + public async Task GetHistory_DefaultLimit_ReturnsThirtyMostRecentInAscendingOrder() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var response = await client.GetAsync($"/api/chat/lobbies/{lobbyId}/messages"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var page = await response.Content.ReadFromJsonAsync(); + var items = page.GetProperty("items").EnumerateArray().ToList(); + + items.Should().HaveCount(30); + items.Select(i => i.GetProperty("body").GetString()) + .Should().Equal(bodies.TakeLast(30)); + page.GetProperty("nextCursor").GetString().Should().NotBeNullOrEmpty(); + } + + // ── History: scrollback paging covers everything, no dupes/gaps ───────────── + + [Fact] + public async Task GetHistory_PagingBackward_CoversEveryMessageWithNoDuplicatesOrGaps() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var firstPage = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: null); + var firstItems = firstPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + var firstCursor = firstPage.GetProperty("nextCursor").GetString(); + firstCursor.Should().NotBeNullOrEmpty(); + + var secondPage = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: firstCursor, limit: null); + var secondItems = secondPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + secondPage.GetProperty("nextCursor").ValueKind.Should().Be(JsonValueKind.Null); + + var combined = secondItems.Concat(firstItems).ToList(); + combined.Should().Equal(bodies, "walking every scrollback page in chronological order must reconstruct " + + "the full seeded history exactly once each, with no duplicates and no gaps"); + } + + // ── History: reconnect repair (direction=after) ────────────────────────────── + + [Fact] + public async Task GetHistory_DirectionAfter_RepairsFromTheStartThenFromTheCursor() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var bodies = await SeedMessagesAsync(lobbyId, senderId, count: 35); + + var firstPage = await GetHistoryPageAsync(client, lobbyId, direction: "after", cursor: null, limit: null); + var firstItems = firstPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + firstItems.Should().Equal(bodies.Take(30), "no cursor + after means from the start of history, ascending"); + var firstCursor = firstPage.GetProperty("nextCursor").GetString(); + firstCursor.Should().NotBeNullOrEmpty(); + + var secondPage = await GetHistoryPageAsync(client, lobbyId, direction: "after", cursor: firstCursor, limit: null); + var secondItems = secondPage.GetProperty("items").EnumerateArray() + .Select(i => i.GetProperty("body").GetString()).ToList(); + secondItems.Should().Equal(bodies.Skip(30), "a cursor + after means the page immediately newer than it"); + secondPage.GetProperty("nextCursor").ValueKind.Should().Be(JsonValueKind.Null); + } + + // ── History: limit cap + rejection ─────────────────────────────────────────── + + [Fact] + public async Task GetHistory_LimitFifty_ReturnsAtMostTheCap() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + await SeedMessagesAsync(lobbyId, senderId, count: 56); + + var page = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: 50); + + page.GetProperty("items").GetArrayLength().Should().Be(50); + } + + [Fact] + public async Task GetHistory_LimitAboveCap_Returns400ValidationFailed() + { + // Validation runs before authorization/lookup (ChatService.GetHistoryAsync), so an arbitrary lobby id is + // sufficient to prove the cap is enforced -- no lobby needs to exist for this to reject. + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.GetAsync($"/api/chat/lobbies/{Guid.NewGuid()}/messages?limit=51"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Validation.Failed"); + } + + // ── Delete / tombstone ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteMessage_ByAuthor_TombstonesTheMessageAndPersistsAcrossHistory() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var messageId = await SeedMessageAsync(lobbyId, senderId, "delete me"); + + var delete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + delete.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var page = await GetHistoryPageAsync(client, lobbyId, direction: "before", cursor: null, limit: null); + var item = page.GetProperty("items").EnumerateArray().Single(); + item.GetProperty("id").GetGuid().Should().Be(messageId, "the id is never reused by a delete"); + item.GetProperty("body").ValueKind.Should().Be(JsonValueKind.Null); + item.GetProperty("deleted").GetBoolean().Should().BeTrue(); + + // Idempotent retry: a second delete on an already-deleted message still succeeds. + var retriedDelete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + retriedDelete.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task DeleteMessage_ByNonAuthorMember_Returns403Forbidden() + { + using var author = CreateClient(); + await SignInAsync(author); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(author); + var authorId = await GetMyUserIdAsync(author); + var messageId = await SeedMessageAsync(lobbyId, authorId, "not yours to delete"); + + using var otherMember = CreateClient(); + await SignInAsync(otherMember); + var join = await otherMember.PostAsJsonAsync("/api/lobbies/join", new { LobbyId = lobbyId }); + join.EnsureSuccessStatusCode(); + + var delete = await otherMember.DeleteAsync($"/api/chat/messages/{messageId}"); + + delete.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var body = await delete.Content.ReadAsStringAsync(); + body.Should().Contain("Chat.Forbidden"); + } + + [Fact] + public async Task DeleteMessage_MissingCsrfHeader_Returns400() + { + using var client = CreateClient(); + await SignInAsync(client); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(client); + var senderId = await GetMyUserIdAsync(client); + var messageId = await SeedMessageAsync(lobbyId, senderId, "csrf guard"); + client.DefaultRequestHeaders.Remove("X-Requested-With"); + + var delete = await client.DeleteAsync($"/api/chat/messages/{messageId}"); + + delete.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await delete.Content.ReadAsStringAsync(); + body.Should().Contain("Auth.CsrfHeaderRequired"); + } + + // ── Swagger ─────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Swagger_DescribesChatHistoryAndDeleteRoutes() + { + using var client = CreateClient(); + + var document = await client.GetStringAsync("/swagger/v1/swagger.json"); + + document.Should().Contain("\"/api/chat/lobbies/{lobbyId}/messages\"") + .And.Contain("\"/api/chat/messages/{messageId}\"") + .And.Contain("Chat_GetHistory") + .And.Contain("Chat_DeleteMessage"); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private HttpClient CreateClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }); + client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + return client; + } + + private static async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"chat-{suffix}@example.com"; + var username = $"chat{suffix}"; + + var register = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token" + }); + register.EnsureSuccessStatusCode(); + + var login = await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token" + }); + login.EnsureSuccessStatusCode(); + } + + private static async Task GetMyUserIdAsync(HttpClient client) + { + var response = await client.GetAsync("/api/profile/me"); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadFromJsonAsync(); + return json.GetProperty("userId").GetGuid(); + } + + private static async Task CreatePublicLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Public", + MaxPlayers = 8, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + private async Task SeedCatalogAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Games.AnyAsync(g => g.Slug == "chess-lite")) + { + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 8, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + await db.SaveChangesAsync(); + } + + if (!await db.GameCapabilityProfiles.AnyAsync(p => p.GameSlug == "chess-lite" && p.CapabilityVersion == 1)) + { + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 8, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + await db.SaveChangesAsync(); + } + } + + /// Seeds messages directly, one second apart starting from a fixed instant, + /// so (CreatedAt, Id) ordering is deterministic across the whole batch. Returns each message's body in + /// creation (chronological) order. + private async Task> SeedMessagesAsync(Guid lobbyId, Guid senderId, int count) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var baseTime = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + var bodies = new List(count); + for (var i = 0; i < count; i++) + { + var body = $"message-{i:D3}"; + bodies.Add(body); + db.ChatMessages.Add(ChatMessage.Create( + ChatScope.Lobby, lobbyId, senderId, body, Guid.NewGuid(), baseTime.AddSeconds(i))); + } + await db.SaveChangesAsync(); + return bodies; + } + + private async Task SeedMessageAsync(Guid lobbyId, Guid senderId, string body) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var message = ChatMessage.Create(ChatScope.Lobby, lobbyId, senderId, body, Guid.NewGuid(), DateTime.UtcNow); + db.ChatMessages.Add(message); + await db.SaveChangesAsync(); + return message.Id; + } + + private static async Task GetHistoryPageAsync( + HttpClient client, Guid lobbyId, string direction, string? cursor, int? limit) + { + var query = $"?direction={direction}"; + if (cursor is not null) query += $"&cursor={Uri.EscapeDataString(cursor)}"; + if (limit is not null) query += $"&limit={limit}"; + + var response = await client.GetAsync($"/api/chat/lobbies/{lobbyId}/messages{query}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + return await response.Content.ReadFromJsonAsync(); + } + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs b/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs new file mode 100644 index 0000000..894ffae --- /dev/null +++ b/tests/SimPle.IntegrationTests/Chat/ChatRetentionHoldRaceTests.cs @@ -0,0 +1,244 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using SimPle.Application.Chat; +using SimPle.Domain.Chat; +using SimPle.Domain.Users; +using SimPle.Infrastructure.Chat; +using SimPle.Infrastructure.Persistence; +using Xunit; + +namespace SimPle.IntegrationTests.Chat; + +/// +/// Real-PostgreSQL tests for the moderation-hold-vs-retention race (docs/specs/module-07-realtime-presence-chat- +/// spec.md, Risk #5, "mandatory real-PostgreSQL test"). +/// +/// +/// InMemory cannot prove any of this: it has no row locks and no FOR UPDATE ... SKIP LOCKED, so a race +/// asserted against it would pass regardless of whether and +/// actually serialize on the message row. +/// +/// +/// Skipped unless MIGRATION_TEST_CONNECTION_STRING points at a running PostgreSQL instance. +/// +public sealed class ChatRetentionHoldRaceTests : IAsyncLifetime +{ + private readonly string? _masterConn = Environment.GetEnvironmentVariable("MIGRATION_TEST_CONNECTION_STRING"); + private readonly string _dbName = $"simple_m7b_{Guid.NewGuid():N}"; + private string? _testConn; + + private static readonly DateTime T0 = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + public async Task InitializeAsync() + { + if (_masterConn is null) return; + + _testConn = new NpgsqlConnectionStringBuilder(_masterConn) { Database = _dbName }.ToString(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + await using var createCmd = masterConn.CreateCommand(); + createCmd.CommandText = $"CREATE DATABASE \"{_dbName}\""; + await createCmd.ExecuteNonQueryAsync(); + + await using var db = CreateDb(); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + if (_masterConn is null || _testConn is null) return; + + NpgsqlConnection.ClearAllPools(); + + await using var masterConn = new NpgsqlConnection(_masterConn); + await masterConn.OpenAsync(); + + await using var terminateCmd = masterConn.CreateCommand(); + terminateCmd.CommandText = $@" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{_dbName}' + AND pid <> pg_backend_pid() + -- Only this role's own backends; see LobbiesPostgresConcurrencyTests for why the unfiltered form + -- fails intermittently under a least-privilege test role. + AND usename = current_user;"; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = masterConn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{_dbName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + // ── The Risk #5 races ──────────────────────────────────────────────────── + + [SkippableFact] + public async Task AnExpiredMessageWithAnActiveHold_SurvivesTheSweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(0, "an active hold must exclude the row from the sweep's candidate set entirely"); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeTrue(); + } + + [SkippableFact] + public async Task AnExpiredMessageWithNoHold_IsDeletedBySweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(1); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeFalse(); + } + + [SkippableFact] + public async Task AnExpiredMessageWhoseHoldWasReleased_IsDeletedBySweep() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + + var hold = await holdDb.ChatMessageHolds.SingleAsync(h => h.MessageId == messageId); + hold.Release(T0); + await holdDb.SaveChangesAsync(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(1, "a released hold no longer satisfies the partial index's ReleasedAtUtc IS NULL filter"); + } + + /// + /// The mandatory race itself. The sweep takes its row lock and deletes the row within one uncommitted + /// statement/transaction; a concurrent on the same row must + /// block rather than race past it — proving the two transactions serialize on the message + /// row (spec: "Row-lock serialization"). Once the sweep commits, the blocked hold unblocks and receives a + /// truthful , never a silently "successful" hold on a row that no + /// longer exists. + /// + [SkippableFact] + public async Task SweepWinningTheRace_UnblocksAPendingHold_WithATypedMessageExpired_NeverSilentEvidenceLoss() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using var sweepDb = CreateDb(); + await using var sweepTx = await sweepDb.Database.BeginTransactionAsync(); + + // Runs the sweep's single CTE+DELETE statement inside the still-open sweepTx — the row is deleted but not + // yet committed, so its lock is still held. + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + deleted.Should().Be(1); + + await using var holdDb = CreateDb(); + var holdTask = new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + + // The hold must still be blocked a short while later — proving serialization, not a lost race. + var finishedEarly = await Task.WhenAny(holdTask, Task.Delay(TimeSpan.FromMilliseconds(500))); + finishedEarly.Should().NotBe(holdTask, "PlaceHoldAsync must block on the sweep's uncommitted row lock rather than race past it"); + + await sweepTx.CommitAsync(); + + var holdResult = await holdTask.WaitAsync(TimeSpan.FromSeconds(10)); + + holdResult.IsSuccess.Should().BeFalse("the sweep already committed the delete by the time the hold's lock was granted"); + holdResult.Error!.Code.Should().Be(ChatErrors.MessageExpired); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeFalse(); + (await verify.ChatMessageHolds.AnyAsync(h => h.MessageId == messageId)).Should().BeFalse( + "a hold must never be persisted for a message the sweep already deleted"); + } + + /// Reverse interleaving of the same race: the hold commits first, so the message survives and the + /// sweep's own NOT EXISTS probe (not SKIP LOCKED) is what excludes it. + [SkippableFact] + public async Task HoldWinningTheRace_LeavesTheMessageSurviving_AndTheSweepReportsItSkipped() + { + SkipIfNoPg(); + + var sender = await SeedUserAsync(); + var messageId = await SeedExpiredMessageAsync(sender.Id); + + await using (var holdDb = CreateDb()) + { + var holdResult = await new ChatRepository(holdDb) + .PlaceHoldAsync(messageId, "m12-evidence", T0, CancellationToken.None); + holdResult.IsSuccess.Should().BeTrue(); + } + + await using var sweepDb = CreateDb(); + var deleted = await new ChatRepository(sweepDb).DeleteExpiredAsync(T0, 100, CancellationToken.None); + + deleted.Should().Be(0, "the held message must never even enter the sweep's candidate set"); + + await using var verify = CreateDb(); + (await verify.ChatMessages.AnyAsync(m => m.Id == messageId)).Should().BeTrue(); + (await verify.ChatMessageHolds.AnyAsync(h => h.MessageId == messageId && h.ReleasedAtUtc == null)).Should().BeTrue(); + } + + // ── Fixtures ───────────────────────────────────────────────────────────── + + private void SkipIfNoPg() => Skip.If(_masterConn is null, + "Set MIGRATION_TEST_CONNECTION_STRING to a PostgreSQL connection string to run Module 7B retention race tests."); + + private AppDbContext CreateDb() => + new(new DbContextOptionsBuilder().UseNpgsql(_testConn).Options); + + private async Task SeedUserAsync() + { + await using var db = CreateDb(); + var g = Guid.NewGuid(); + var user = User.Create($"m7b{g:N}"[..20], $"m7b{g:N}@test.io", "hash", "M7B Race User"); + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } + + /// A message whose RetainUntilUtc has already passed relative to — created 31 days + /// before T0, so its 30-day RetainUntilUtc is one day in T0's past. + private async Task SeedExpiredMessageAsync(Guid senderId) + { + await using var db = CreateDb(); + var createdAtUtc = T0 - ChatMessage.RetentionPeriod - TimeSpan.FromDays(1); + var message = ChatMessage.Create( + ChatScope.Lobby, Guid.NewGuid(), senderId, "an expired chat message", Guid.NewGuid(), createdAtUtc); + db.ChatMessages.Add(message); + await db.SaveChangesAsync(); + return message.Id; + } +} diff --git a/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs b/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs new file mode 100644 index 0000000..0f31954 --- /dev/null +++ b/tests/SimPle.IntegrationTests/Realtime/RealtimeHubTests.cs @@ -0,0 +1,473 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimPle.Domain.Capabilities; +using SimPle.Domain.Games; +using SimPle.Infrastructure.Persistence; +using SimPle.IntegrationTests.Auth; + +namespace SimPle.IntegrationTests.Realtime; + +/// +/// Hub-level (not just unit-level) verification for Module 7 backend session A (M07-B1): +/// docs/specs/module-07-realtime-presence-chat-spec.md. Connects real s against the +/// in-memory host over LongPolling (a real HTTP transport the TestServer's +/// fake handler supports end to end, unlike raw WebSockets) so that authentication, the origin allowlist +/// middleware, the connection cap, per-method scope authorization, proactive close, and the message-size cap are +/// all exercised through the actual wire path rather than by calling internal classes directly. +/// +/// NOT covered here: a live "match" scope request. The B1 hub surface never exposes a method that routes to the +/// "match" scope kind (no SubscribeMatch exists — see RealtimeHub.cs) so realtime.scope_not_available is +/// only reachable at the unit level today (NullMatchScopeAuthorizerTests). Calling that out rather than inventing +/// a hub method to force the path, which would be a production-code change outside this file's remit. +/// +public sealed class RealtimeHubTests : IDisposable +{ + private const string TestPassword = "ValidPassword1"; + private const string HubPath = "/hubs/realtime"; + private const string AllowedOrigin = "http://localhost:3000"; + + private readonly TestWebApplicationFactory _factory = new(); + + // ── 1. Authenticated cookie connect + origin mismatch ─────────────────────── + + [Fact] + public async Task Connect_WithValidCookieAndAllowedOrigin_Succeeds() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + + await connection.StartAsync(); + + connection.State.Should().Be(HubConnectionState.Connected); + } + + [Fact] + public async Task Connect_WithMismatchedOrigin_IsRefused() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, "http://evil.example.com"); + + var act = () => connection.StartAsync(); + + await act.Should().ThrowAsync(); + connection.State.Should().Be(HubConnectionState.Disconnected); + } + + /// M07-003 regression: a request to the hub with no Origin header at all must be rejected the same + /// as a present-but-unlisted one — a missing header is not an implicit pass. Uses a raw + /// negotiate POST rather than , since the SignalR client always sets Origin itself + /// and offers no way to omit it. + [Fact] + public async Task Connect_WithMissingOriginHeader_IsRefusedWith403() + { + using var client = CreateClient(); + await SignInAsync(client); + + var response = await client.PostAsync(HubPath + "/negotiate?negotiateVersion=1", new StringContent("")); + + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + // ── 2. Connection cap ──────────────────────────────────────────────────────── + + [Fact] + public async Task SixthConcurrentConnection_ForSameUser_IsRejectedWithConnectionLimit() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + var connections = new List(); + try + { + for (var i = 0; i < 5; i++) + { + var connection = CreateHubConnection(cookies, AllowedOrigin); + await connection.StartAsync(); + connections.Add(connection); + } + + await using var sixth = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sixth.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + + // A HubException thrown from OnConnectedAsync does not necessarily fault StartAsync itself: the + // SignalR protocol handshake response can already have been sent to the client before the hub's + // OnConnectedAsync override runs server-side (confirmed via server-side logs: "Realtime connection + // rejected: connection limit reached" followed by "HubException: Realtime.ConnectionLimit" from + // OnConnectedAsync, then the connection is torn down). Both a synchronous throw here and a + // just-connected-then-immediately-closed connection are accepted proof of rejection. + try + { + await sixth.StartAsync(); + } + catch + { + // Rejected synchronously during the handshake (e.g. via the origin/auth path) -- also acceptable. + } + + // KNOWN FLAKE under full-solution runs (documented, not a production defect): this project has no + // [CollectionDefinition(DisableParallelization = true)], so xUnit runs every integration test + // collection concurrently (default max degree of parallelism == CPU core count). A full `dotnet test + // SimPle.sln` run competes dozens of WebApplicationFactory/Postgres-backed hosts plus 898 unit tests + // for the same cores, which can starve the thread pool badly enough that a LongPolling client fails + // to observe a server-initiated abort within even a generous window — this is an observation-latency + // artifact of full-suite parallel contention, not evidence the connection limit stopped being + // enforced (the rate limiter and presence registry are per-host singletons, unaffected by other + // hosts' load; see RealtimeRateLimiter/PresenceRegistry). Reproduced failures at 10s, 25s, and 60s + // under full-suite load; passes reliably in isolation and under capped parallelism + // (`dotnet test -- xUnit.MaxParallelThreads=4`). Not fixed here because the real fix (capping + // parallelism suite-wide) is a shared test-infrastructure change outside M07-B1's scope — see the + // M07-B1 checkpoint report for the accepted tradeoff. + if (sixth.State == HubConnectionState.Connected) + await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(60))); + + sixth.State.Should().Be(HubConnectionState.Disconnected, + "the 6th concurrent connection for the same user must be rejected (Realtime.ConnectionLimit), " + + "surfacing here as the connection closing shortly after connecting rather than staying live"); + } + finally + { + foreach (var connection in connections) + await connection.DisposeAsync(); + } + } + + // ── 3. Privacy-safe not-found via SubscribeLobby ──────────────────────────── + + [Fact] + public async Task SubscribeLobby_ToANonexistentLobby_ReturnsPrivacySafeNotFound() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + await connection.StartAsync(); + + var act = () => connection.InvokeAsync("SubscribeLobby", Guid.NewGuid()); + + var exception = await act.Should().ThrowAsync(); + exception.And.Message.Should().Contain("Lobbies.NotFound"); + } + + [Fact] + public async Task SubscribeLobby_ToAPrivateLobbyBelongingToAnotherUser_ReturnsTheSamePrivacySafeNotFound() + { + using var host = CreateClient(); + await SignInAsync(host); + await SeedCatalogAsync(); + var lobbyId = await CreatePrivateLobbyAsync(host); + + using var outsider = CreateClient(); + var outsiderCookies = await SignInAsync(outsider); + + await using var connection = CreateHubConnection(outsiderCookies, AllowedOrigin); + await connection.StartAsync(); + + var act = () => connection.InvokeAsync("SubscribeLobby", lobbyId); + + var exception = await act.Should().ThrowAsync(); + exception.And.Message.Should().Contain("Lobbies.NotFound"); + } + + // ── 4. Proactive close on logout ──────────────────────────────────────────── + + [Fact] + public async Task Logout_ProactivelyClosesTheLiveHubConnection() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + await connection.StartAsync(); + connection.State.Should().Be(HubConnectionState.Connected); + + var logout = await client.PostAsync("/api/auth/logout", null); + logout.StatusCode.Should().Be(HttpStatusCode.NoContent); + + var completed = await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + + completed.Should().Be(closedTcs.Task, "logout must proactively abort the live realtime connection " + + "rather than waiting for token expiry or the next per-method recheck"); + } + + // ── 5. Oversized message rejected without disabling the buffer limit ─────── + + [Fact] + public async Task OversizedMessage_IsRejected_AndTheCapIsNeverGloballyDisabled() + { + using var client = CreateClient(); + var cookies = await SignInAsync(client); + + await using var connection = CreateHubConnection(cookies, AllowedOrigin); + var closedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.Closed += ex => + { + closedTcs.TrySetResult(ex); + return Task.CompletedTask; + }; + await connection.StartAsync(); + + // 20 KiB of payload in a single argument is well over the 16 KiB cap (MaximumReceiveMessageSize / + // ApplicationMaxBufferSize, both set in Program.cs) — sent against the existing ReportActivity method + // (which normally takes no arguments) purely to force an oversized wire frame; the cap is enforced by + // the hub protocol parser before argument binding, so the extra argument never needs to be consumed. + var oversizedPayload = new string('a', 20 * 1024); + + // SendAsync is fire-and-forget (no completion is expected for an over-cap frame the server can't even + // parse), so the assertion is on the connection being torn down, not on an exception from this call. + try + { + await connection.SendAsync("ReportActivity", oversizedPayload); + } + catch + { + // Some transports surface the rejection synchronously as a thrown exception instead of only via + // Closed — either outcome proves the cap is enforced, so this is deliberately swallowed here. + } + + var completed = await Task.WhenAny(closedTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + + completed.Should().Be(closedTcs.Task, + "an over-cap message must cause the connection to be torn down, proving MaximumReceiveMessageSize " + + "is enforced rather than disabled"); + + // Proof the cap was never globally disabled elsewhere: a fresh, well-formed connection against the same + // running host still connects and calls a real method successfully right after the oversized rejection. + using var otherClient = CreateClient(); + var otherCookies = await SignInAsync(otherClient); + await using var otherConnection = CreateHubConnection(otherCookies, AllowedOrigin); + await otherConnection.StartAsync(); + await otherConnection.InvokeAsync("ReportActivity"); + otherConnection.State.Should().Be(HubConnectionState.Connected); + } + + // ── 6. Chat hub happy path (M07-B2) ───────────────────────────────────────── + + /// docs/specs/module-07-realtime-presence-chat-spec.md Test Matrix: "Hub happy path: connect with + /// cookie → SubscribeLobby → receive LobbyChanged → SendLobbyMessage → ChatMessageCreated." The lobby-create + /// itself is what fires the LobbyChanged the spec asks for (see LobbyRealtimeHandler); SendLobbyMessage is + /// invoked only after that first hint has actually been observed, so the ordering in the assertion mirrors the + /// spec's own phrasing rather than just proving the two events arrive at some point. + [Fact] + public async Task SendLobbyMessage_FansOutChatMessageCreated_ToSubscribedConnections() + { + using var host = CreateClient(); + await SignInAsync(host); + await SeedCatalogAsync(); + var lobbyId = await CreatePublicLobbyAsync(host); + + using var listenerClient = CreateClient(); + var listenerCookies = await SignInAsync(listenerClient); + + await using var connection = CreateHubConnection(listenerCookies, AllowedOrigin); + + var lobbyChangedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.On("LobbyChanged", (_, revision, _) => lobbyChangedTcs.TrySetResult(revision)); + + var chatMessageTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.On("ChatMessageCreated", (_, message) => chatMessageTcs.TrySetResult(message)); + + await connection.StartAsync(); + await connection.InvokeAsync("SubscribeLobby", lobbyId); + + // The listener joining the lobby is what fires the LobbyChanged hint this connection must observe before + // SendLobbyMessage is invoked, proving SubscribeLobby actually took effect first (Lobbies_Join: a + // Public+Open lobby can be joined by naming its id directly, no code/link token needed). + var join = await listenerClient.PostAsJsonAsync("/api/lobbies/join", new { LobbyId = lobbyId }); + join.EnsureSuccessStatusCode(); + + // The outbox dispatcher (D3) polls on a 5s default interval (OutboxOptions.Interval) rather than pushing + // synchronously from the join request, so this window must comfortably clear a full cycle plus dispatch. + var lobbyChangedCompleted = await Task.WhenAny(lobbyChangedTcs.Task, Task.Delay(TimeSpan.FromSeconds(15))); + lobbyChangedCompleted.Should().Be(lobbyChangedTcs.Task, + "SubscribeLobby must have taken effect before SendLobbyMessage is invoked"); + + var clientCommandId = Guid.NewGuid(); + var sendResult = await connection.InvokeAsync( + "SendLobbyMessage", lobbyId, "hello from the hub happy path", clientCommandId); + + var sentMessage = sendResult.GetProperty("message"); + sentMessage.GetProperty("body").GetString().Should().Be("hello from the hub happy path"); + sentMessage.GetProperty("deleted").GetBoolean().Should().BeFalse(); + var sentMessageId = sentMessage.GetProperty("id").GetGuid(); + + var chatCompleted = await Task.WhenAny(chatMessageTcs.Task, Task.Delay(TimeSpan.FromSeconds(10))); + chatCompleted.Should().Be(chatMessageTcs.Task, "the subscribed connection must receive ChatMessageCreated"); + + var broadcastMessage = await chatMessageTcs.Task; + broadcastMessage.GetProperty("id").GetGuid().Should().Be(sentMessageId); + broadcastMessage.GetProperty("lobbyId").GetGuid().Should().Be(lobbyId); + broadcastMessage.GetProperty("body").GetString().Should().Be("hello from the hub happy path"); + broadcastMessage.GetProperty("deleted").GetBoolean().Should().BeFalse(); + broadcastMessage.GetProperty("sender").GetProperty("userId").GetGuid().Should().NotBeEmpty(); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private HttpClient CreateClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + HandleCookies = true + }); + client.DefaultRequestHeaders.Add("X-Requested-With", "XMLHttpRequest"); + return client; + } + + /// + /// Registers + logs in a fresh account on the given client and returns a raw Cookie request-header + /// value (e.g. "access_token=...; refresh_token=...") built directly from the login response's + /// Set-Cookie headers, for use as ["Cookie"] on the hub connection. + /// + /// Deliberately NOT : SignalR's client only applies that + /// to the internal it constructs and passes + /// *into* — a factory that (as ours does here, + /// to route onto ) returns a different handler entirely + /// discards that CookieContainer silently (confirmed by a first run of this file: every connect attempt got a + /// 401 on negotiate despite a populated CookieContainer). Setting the header directly is honored by every + /// outgoing request the transport makes (negotiate, poll, send) — the same mechanism already proven to work + /// for the Origin header below. Cookie values are JWTs (base64url), so no escaping is needed. + /// + private static async Task SignInAsync(HttpClient client) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var email = $"rt-{suffix}@example.com"; + var username = $"rt{suffix}"; + + var register = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = username, + Email = email, + Password = TestPassword, + ConfirmPassword = TestPassword, + CaptchaToken = "test-captcha-token" + }); + register.EnsureSuccessStatusCode(); + + var login = await client.PostAsJsonAsync("/api/auth/login", new + { + EmailOrUsername = email, + Password = TestPassword, + CaptchaToken = "test-captcha-token" + }); + login.EnsureSuccessStatusCode(); + + var pairs = login.Headers.GetValues("Set-Cookie") + .Select(setCookie => setCookie.Split(';')[0]) + .Where(nameValue => nameValue.IndexOf('=') > 0); + return string.Join("; ", pairs); + } + + private static async Task CreatePrivateLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Private", + MaxPlayers = 4, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + /// Unlike , produces a lobby a non-member can actually join via + /// the REST join endpoint (LobbiesService only allows joining a lobby that is + /// Privacy == Public && State == Open) — needed so a second connection can legitimately become a + /// member and observe its own LobbyChanged hint before SendLobbyMessage. + private static async Task CreatePublicLobbyAsync(HttpClient host) + { + var response = await host.PostAsJsonAsync("/api/lobbies", new + { + GameSlug = "chess-lite", + CapabilityVersion = 1, + Privacy = "Public", + MaxPlayers = 4, + TimeControlId = "blitz-3-2", + Rated = false, + Region = "eu-west", + SpectatorPolicy = "Anyone", + TieBreakRuleId = "none", + AiFillRequested = false + }); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("lobby").GetProperty("lobbyId").GetGuid(); + } + + private async Task SeedCatalogAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!await db.Games.AnyAsync(g => g.Slug == "chess-lite")) + { + db.Games.Add(Game.Create( + slug: "chess-lite", name: "Chess Lite", summary: "A streamlined chess experience.", + rulesSummary: "Chess Lite wins by checkmate.", difficulty: GameDifficulty.Medium, + estimatedDurationMinMinutes: 10, estimatedDurationMaxMinutes: 20, + minPlayers: 2, maxPlayers: 4, initialLifecycle: GameLifecycle.Available, + featuredRank: null, sortOrder: 1, artToken: "chess-lite", + artColorA: "#9B51E0", artColorB: "#2D9CDB", artAltText: "Chess Lite abstract game artwork", + manifestVersion: "2026.1", category: "strategy", + tags: new[] { "classic" }, + modes: new[] { "multiplayer", "cooperative" })); + await db.SaveChangesAsync(); + } + + if (!await db.GameCapabilityProfiles.AnyAsync(p => p.GameSlug == "chess-lite" && p.CapabilityVersion == 1)) + { + db.GameCapabilityProfiles.Add(GameCapabilityProfile.Create( + "chess-lite", 1, minPlayers: 2, maxPlayers: 4, + allowedModes: new[] { "multiplayer", "cooperative" }, + timeControls: new[] { "blitz-3-2", "rapid-10-0", "untimed" }, + tieBreakRules: new[] { "none", "sudden-death" }, + spectatorPolicies: new[] { "Anyone", "FriendsOnly", "Disabled" }, + ratedEligible: false, aiFillEligible: false, + manifestVersion: "2026.1")); + await db.SaveChangesAsync(); + } + } + + private HubConnection CreateHubConnection(string cookieHeader, string origin) => + new HubConnectionBuilder() + .WithUrl(new Uri("https://localhost" + HubPath), options => + { + options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler(); + options.Transports = HttpTransportType.LongPolling; + options.Headers["Origin"] = origin; + options.Headers["Cookie"] = cookieHeader; + }) + .Build(); + + public void Dispose() => _factory.Dispose(); +} diff --git a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj index 306040b..9c80c33 100644 --- a/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj +++ b/tests/SimPle.IntegrationTests/SimPle.IntegrationTests.csproj @@ -13,10 +13,14 @@ + + + + diff --git a/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs b/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs index b14f086..87ccf87 100644 --- a/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs +++ b/tests/SimPle.UnitTests/Auth/AccountSecurityTests.cs @@ -22,6 +22,7 @@ public sealed class AccountSecurityTests private readonly IEmailService _emailService = Substitute.For(); private readonly IGoogleTokenValidationService _googleValidator = Substitute.For(); private readonly IRevokedJtiStore _revokedJtis = Substitute.For(); + private readonly IRealtimeConnectionCloser _realtimeCloser = Substitute.For(); private readonly AuthService _service; public AccountSecurityTests() @@ -35,6 +36,7 @@ public AccountSecurityTests() _users, _tokens, _verificationTokens, _resetTokens, _hasher, _tokenService, _emailService, _googleValidator, _revokedJtis, + _realtimeCloser, Options.Create(new AuthOptions { RefreshTokenExpiryDays = 7, MaxFailedLoginAttempts = 10, LockoutDurationMinutes = 15 }), Options.Create(new EmailOptions { AppUrl = "http://localhost:3000", SmtpHost = "smtp.test", Password = "x" }), NullLogger.Instance); diff --git a/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs b/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs index 69c31e4..254bdf6 100644 --- a/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs +++ b/tests/SimPle.UnitTests/Auth/AuthServiceTests.cs @@ -23,6 +23,7 @@ public sealed class AuthServiceTests private readonly IEmailService _emailService = Substitute.For(); private readonly IGoogleTokenValidationService _googleValidator = Substitute.For(); private readonly IRevokedJtiStore _revokedJtis = Substitute.For(); + private readonly IRealtimeConnectionCloser _realtimeCloser = Substitute.For(); private readonly AuthService _service; public AuthServiceTests() @@ -44,6 +45,7 @@ public AuthServiceTests() _emailService, _googleValidator, _revokedJtis, + _realtimeCloser, Options.Create(new AuthOptions { RefreshTokenExpiryDays = 7, @@ -295,6 +297,30 @@ await _tokens.Received(1).RevokeAllByUserIdAsync( user.Id, "Logout all", Arg.Any()); } + /// + /// Regression test for the Module 7 fix (docs/specs/module-07-realtime-presence-chat-spec.md): before this + /// fix, LogoutAsync revoked only the refresh-token row, never the session family — so a session logged out via + /// this path could still authenticate with a not-yet-expired access token cookie until it expired naturally + /// (LogoutAllAsync/RevokeSessionAsync already revoked the family; LogoutAsync did not). This also closes any + /// live realtime connections proactively, since a realtime hub connection would otherwise survive "logout" + /// indefinitely (see "the load-bearing rule" in the spec). + /// + [Fact] + public async Task LogoutAsync_RevokesSessionFamilyAndClosesRealtimeConnections() + { + var user = ExistingUser(); + var token = RefreshToken.Create(user.Id, "hash-old-token", Guid.NewGuid(), + DateTime.UtcNow.AddDays(1), "", null); + _tokens.GetByHashAsync("hash-old-token", Arg.Any()).Returns(token); + + await _service.LogoutAsync("old-token"); + + token.IsRevoked.Should().BeTrue(); + _revokedJtis.Received(1).Revoke(token.FamilyId.ToString(), Arg.Any()); + await _realtimeCloser.Received(1).CloseUserConnectionsAsync( + user.Id, "auth.session_revoked", Arg.Any()); + } + // ── GetCurrentUser ──────────────────────────────────────────────────────── [Fact] diff --git a/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs b/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs new file mode 100644 index 0000000..6a6ef33 --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatBodyNormalizerTests.cs @@ -0,0 +1,159 @@ +using FluentAssertions; +using SimPle.Application.Chat; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// Test matrix source: docs/specs/module-07-realtime-presence-chat-spec.md, "Test Matrix" — +/// "NFC normalization; CRLF/CR to LF; outer Unicode whitespace trimmed; LF allowed; other C0/C1 controls rejected; +/// 0 scalars rejected, 1 accepted, 1000 accepted, 1001 rejected; astral-plane scalars counted correctly." +public sealed class ChatBodyNormalizerTests +{ + [Fact] + public void Normalize_CollapsesCrLfToLf() + { + var result = ChatBodyNormalizer.Normalize("line one\r\nline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_CollapsesLoneCrToLf() + { + var result = ChatBodyNormalizer.Normalize("line one\rline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_KeepsInternalLf() + { + var result = ChatBodyNormalizer.Normalize("line one\nline two"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("line one\nline two"); + } + + [Fact] + public void Normalize_TrimsOuterUnicodeWhitespaceOnly() + { + // U+00A0 (NBSP) and U+2003 (EM SPACE) are Unicode whitespace but not ASCII space. + var result = ChatBodyNormalizer.Normalize("   hello world \t\n"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("hello world"); + } + + [Fact] + public void Normalize_AppliesNfcNormalization() + { + // "e" + combining acute accent (U+0065 U+0301) normalizes to precomposed U+00E9 ("é"). + var decomposed = "é"; + var result = ChatBodyNormalizer.Normalize(decomposed); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("é"); + } + + [Fact] + public void Normalize_RejectsNull() + { + var result = ChatBodyNormalizer.Normalize(null); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_RejectsZeroScalarsAfterTrim() + { + var result = ChatBodyNormalizer.Normalize(" \t "); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_AcceptsSingleScalar() + { + var result = ChatBodyNormalizer.Normalize("a"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("a"); + } + + [Fact] + public void Normalize_Accepts1000Scalars() + { + var body = new string('a', 1000); + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveLength(1000); + } + + [Fact] + public void Normalize_Rejects1001Scalars() + { + var body = new string('a', 1001); + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_CountsAstralPlaneScalarsCorrectly_NotUtf16Units() + { + // U+1F600 (grinning face emoji) is one Unicode scalar but two UTF-16 code units (a surrogate pair). + // 999 'a' chars (999 scalars) + one astral emoji (1 scalar) = 1000 scalars, which must be accepted even + // though the raw string.Length (UTF-16 code unit count) is 1001. + var body = new string('a', 999) + "\U0001F600"; + body.Length.Should().Be(1001); // sanity: confirms the surrogate pair inflates UTF-16 length + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(body); + } + + [Fact] + public void Normalize_RejectsAstralPlaneScalarOverflow() + { + // 1000 'a' chars (1000 scalars) + one astral emoji (1 more scalar) = 1001 scalars: must reject. + var body = new string('a', 1000) + "\U0001F600"; + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Theory] + [InlineData((char)0x00)] // NUL, C0 + [InlineData((char)0x01)] // C0 + [InlineData((char)0x1F)] // C0, last before LF's own 0x0A + [InlineData((char)0x7F)] // DEL + [InlineData((char)0x80)] // C1 + [InlineData((char)0x9F)] // C1, last C1 control + public void Normalize_RejectsC0AndC1Controls(char control) + { + var body = $"hello{control}world"; + + var result = ChatBodyNormalizer.Normalize(body); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + } + + [Fact] + public void Normalize_AllowsInternalLfControlCharacter() + { + var result = ChatBodyNormalizer.Normalize("hello\nworld"); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("hello\nworld"); + } +} diff --git a/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs b/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs new file mode 100644 index 0000000..64075a5 --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatProfanityFilterTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using Microsoft.Extensions.Options; +using SimPle.Application.Chat; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// Test matrix source: docs/specs/module-07-realtime-presence-chat-spec.md, "Test Matrix" — "Profanity +/// filter: deny-list match rejects and does not persist; versioned config is read server-side only." +public sealed class ChatProfanityFilterTests +{ + private static ChatProfanityFilter CreateFilter(params string[] terms) => + new(Options.Create(new ProfanityOptions { Terms = terms })); + + [Fact] + public void IsProfane_MatchesConfiguredTerm_CaseInsensitive() + { + var filter = CreateFilter("badword"); + + filter.IsProfane("this is a BadWord in a sentence").Should().BeTrue(); + } + + [Fact] + public void IsProfane_DoesNotMatchSubstringOfAnotherWord() + { + var filter = CreateFilter("ass"); + + // "class" contains "ass" as a substring but not as a whole word. + filter.IsProfane("this is my class assignment").Should().BeFalse(); + } + + [Fact] + public void IsProfane_MatchesWholeWordWithPunctuationBoundary() + { + var filter = CreateFilter("badword"); + + filter.IsProfane("badword!").Should().BeTrue(); + } + + [Fact] + public void IsProfane_ReturnsFalseWhenNoTermsConfigured() + { + var filter = CreateFilter(); + + filter.IsProfane("anything at all").Should().BeFalse(); + } + + [Fact] + public void IsProfane_ReturnsFalseForCleanMessage() + { + var filter = CreateFilter("badword", "worseword"); + + filter.IsProfane("hello, how are you today?").Should().BeFalse(); + } + + [Fact] + public void IsProfane_IgnoresBlankConfiguredTerms() + { + var filter = CreateFilter("", " ", "badword"); + + filter.IsProfane("this has a badword in it").Should().BeTrue(); + filter.IsProfane("this is clean").Should().BeFalse(); + } +} diff --git a/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs b/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs new file mode 100644 index 0000000..7f9edea --- /dev/null +++ b/tests/SimPle.UnitTests/Chat/ChatServiceTests.cs @@ -0,0 +1,467 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Chat; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Common.Options; +using SimPle.Application.Common.Pagination; +using SimPle.Application.Realtime; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Contracts; +using SimPle.Domain.Chat; +using SimPle.Domain.Common; +using SimPle.Domain.Users; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Chat; + +/// +/// Command/query-layer tests for : idempotent send, author-only tombstone delete, and +/// keyset history pagination (docs/specs/module-07-realtime-presence-chat-spec.md). +/// +public sealed class ChatServiceTests +{ + private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly IChatRepository _chat = Substitute.For(); + private readonly IRealtimeScopeAuthorizer _authorizer = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly IRealtimeRateLimiter _rateLimiter = Substitute.For(); + private readonly IChatProfanityFilter _profanity = Substitute.For(); + private readonly IFileStorageService _storage = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly IFriendRepository _friends = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _lobbyId = Guid.NewGuid(); + private readonly Guid _actorId = Guid.NewGuid(); + private readonly Guid _clientCommandId = Guid.NewGuid(); + + private readonly ChatService _sut; + + public ChatServiceTests() + { + _authorizer.ScopeKind.Returns(RealtimeEnvelope.LobbyScope); + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Allow()); + + _rateLimiter.TryAcquireMessage(Arg.Any()).Returns(true); + _profanity.IsProfane(Arg.Any()).Returns(false); + + _chat.FindByClientCommandIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ChatMessage?)null); + _chat.AddAsync(Arg.Any(), Arg.Any()) + .Returns(call => call.Arg()); + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(call => Roster(call.Arg>())); + + // The actor is the lobby's host (and only joined member) by default, so every recipient-filtering call + // resolves to just the sender unless a test overrides this to add other members/blocks. + _lobbies.GetByIdAsync(_lobbyId, Arg.Any()) + .Returns(LobbyTestFactory.Open(_actorId, T0)); + + _sut = new ChatService( + _chat, new[] { _authorizer }, _notifier, _rateLimiter, _profanity, _storage, + Options.Create(new StorageOptions()), _lobbies, _friends, _clock, + NullLogger.Instance); + } + + // ── Send ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task Send_Authorized_PersistsAndNotifies() + { + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello world", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Body.Should().Be("hello world"); + result.Value.Deleted.Should().BeFalse(); + result.Value.LobbyId.Should().Be(_lobbyId); + result.Value.Sender.UserId.Should().Be(_actorId); + + await _chat.Received(1).AddAsync( + Arg.Is(m => m.SenderId == _actorId && m.ScopeId == _lobbyId && m.Body == "hello world"), + Arg.Any()); + await _notifier.Received(1).NotifyChatMessageCreatedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + Arg.Is(dto => dto.Body == "hello world"), Arg.Any()); + } + + [Fact] + public async Task Send_NotAuthorized_ReturnsChatNotFoundAndNeverChecksRateLimitOrProfanity() + { + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + _rateLimiter.DidNotReceive().TryAcquireMessage(Arg.Any()); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_DuplicateClientCommandId_ReturnsOriginalWithoutRateLimitOrProfanityCheck() + { + var original = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "already sent", _clientCommandId, T0); + _chat.FindByClientCommandIdAsync(_actorId, _clientCommandId, Arg.Any()) + .Returns(original); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "a different body this time", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Body.Should().Be("already sent"); + result.Value.Id.Should().Be(original.Id); + + _rateLimiter.DidNotReceive().TryAcquireMessage(Arg.Any()); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + await _notifier.DidNotReceive().NotifyChatMessageCreatedAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_RateLimited_ReturnsRateLimitExceededWithRetryAfter() + { + _rateLimiter.TryAcquireMessage(_actorId).Returns(false); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.RateLimitExceeded); + result.Error.RetryAfterUtc.Should().Be(T0.AddSeconds(5)); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_InvalidBody_ReturnsInvalidBodyAndNeverChecksProfanity() + { + var result = await _sut.SendAsync(_actorId, _lobbyId, " ", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidBody); + _profanity.DidNotReceive().IsProfane(Arg.Any()); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_ProfaneBody_ReturnsProfanityRejected() + { + _profanity.IsProfane(Arg.Any()).Returns(true); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "bad word", _clientCommandId); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.ProfanityRejected); + await _chat.DidNotReceive().AddAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Send_SenderMissingFromLookup_RendersTombstone() + { + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Sender.UserId.Should().Be(Guid.Empty); + result.Value.Sender.DisplayName.Should().Be("Chat participant"); + result.Value.Sender.AvatarUrl.Should().BeNull(); + } + + /// M07-001 regression: a co-member the sender has blocked (or who has blocked the sender) must + /// never appear in the delivery list, even though they are still a joined lobby member — a plain group + /// broadcast would otherwise leak the sender's chat activity to them. + [Fact] + public async Task Send_LobbyHasBlockedCoMember_ExcludesBlockedMemberFromRecipients() + { + var blockedMemberId = Guid.NewGuid(); + var unrelatedMemberId = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(_actorId, T0); + lobby.Join(blockedMemberId, T0); + lobby.Join(unrelatedMemberId, T0); + _lobbies.GetByIdAsync(_lobbyId, Arg.Any()).Returns(lobby); + + _friends.IsBlockedInEitherDirectionAsync(_actorId, blockedMemberId, Arg.Any()) + .Returns(true); + _friends.IsBlockedInEitherDirectionAsync(_actorId, unrelatedMemberId, Arg.Any()) + .Returns(false); + + var result = await _sut.SendAsync(_actorId, _lobbyId, "hello world", _clientCommandId); + + result.IsSuccess.Should().BeTrue(); + await _notifier.Received(1).NotifyChatMessageCreatedAsync( + _lobbyId, + Arg.Is>(r => + r.Contains(_actorId) && r.Contains(unrelatedMemberId) && !r.Contains(blockedMemberId)), + Arg.Any(), Arg.Any()); + } + + // ── Delete ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task Delete_AuthorDeletesOwnMessage_TombstonesAndNotifies() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "goodbye", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + _clock.Advance(TimeSpan.FromMinutes(1)); + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeTrue(); + message.IsDeleted.Should().BeTrue(); + await _chat.Received(1).SaveAsync(Arg.Any()); + await _notifier.Received(1).NotifyChatMessageDeletedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + message.Id, message.DeletedAtUtc!.Value, Arg.Any()); + } + + [Fact] + public async Task Delete_NonAuthor_ReturnsForbiddenAndNeverSavesOrNotifies() + { + var author = Guid.NewGuid(); + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, author, "not yours", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.Forbidden); + message.IsDeleted.Should().BeFalse(); + await _chat.DidNotReceive().SaveAsync(Arg.Any()); + await _notifier.DidNotReceive().NotifyChatMessageDeletedAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task Delete_MessageNotFound_ReturnsChatNotFound() + { + _chat.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((ChatMessage?)null); + + var result = await _sut.DeleteAsync(_actorId, Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + } + + [Fact] + public async Task Delete_NotAuthorizedForScope_ReturnsChatNotFoundEvenForTheAuthor() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "hi", Guid.NewGuid(), T0); + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + await _chat.DidNotReceive().SaveAsync(Arg.Any()); + } + + [Fact] + public async Task Delete_RetriedDeleteOnAlreadyDeletedMessage_IsIdempotentAndReplaysOriginalTimestamp() + { + var message = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "bye", Guid.NewGuid(), T0); + message.Delete(_actorId, T0.AddMinutes(1)); + var originalDeletedAt = message.DeletedAtUtc!.Value; + _chat.GetByIdAsync(message.Id, Arg.Any()).Returns(message); + + _clock.Advance(TimeSpan.FromHours(1)); + var result = await _sut.DeleteAsync(_actorId, message.Id); + + result.IsSuccess.Should().BeTrue(); + message.DeletedAtUtc.Should().Be(originalDeletedAt); + await _notifier.Received(1).NotifyChatMessageDeletedAsync( + _lobbyId, Arg.Is>(r => r.Contains(_actorId)), + message.Id, originalDeletedAt, Arg.Any()); + } + + // ── History ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetHistory_DefaultLimitAppliedWhenNull() + { + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List()); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, null); + + result.IsSuccess.Should().BeTrue(); + await _chat.Received(1).GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()); + } + + [Theory] + [InlineData(0)] + [InlineData(51)] + public async Task GetHistory_LimitOutOfRange_ReturnsValidationFailedWithoutQueryingRepository(int limit) + { + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, limit); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.ValidationFailed); + await _chat.DidNotReceive().GetHistoryPageAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetHistory_NotAuthorized_ReturnsChatNotFound() + { + _authorizer.AuthorizeAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RealtimeScopeAuthorizationResult.Deny("Lobbies.NotFound")); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.NotFound); + } + + [Fact] + public async Task GetHistory_InvalidCursor_ReturnsInvalidCursor() + { + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, "not-a-cursor", 30); + + result.IsSuccess.Should().BeFalse(); + result.Error!.Code.Should().Be(ChatErrors.InvalidCursor); + } + + [Fact] + public async Task GetHistory_BeforeDirection_FullPage_NextCursorDerivedFromOldestRow() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 3, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 3); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Items.Should().HaveCount(3); + result.Value.NextCursor.Should().Be(Cursor.EncodeTimeId(rows[0].CreatedAt, rows[0].Id)); + } + + [Fact] + public async Task GetHistory_AfterDirection_FullPage_NextCursorDerivedFromNewestRow() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.After, null, null, 3, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.After, null, 3); + + result.IsSuccess.Should().BeTrue(); + result.Value!.NextCursor.Should().Be(Cursor.EncodeTimeId(rows[^1].CreatedAt, rows[^1].Id)); + } + + [Fact] + public async Task GetHistory_PartialPage_NoNextCursor() + { + var rows = ThreeMessagesAscending(); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(rows); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + result.Value!.NextCursor.Should().BeNull(); + } + + [Fact] + public async Task GetHistory_DeletedRow_ProjectsNullBodyAndDeletedTrue() + { + var deleted = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "secret", Guid.NewGuid(), T0); + deleted.Delete(_actorId, T0.AddMinutes(1)); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { deleted }); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Items.Single().Body.Should().BeNull(); + result.Value.Items.Single().Deleted.Should().BeTrue(); + } + + [Fact] + public async Task GetHistory_SenderMissingFromLookup_RendersTombstone() + { + var row = ChatMessage.Create(ChatScope.Lobby, _lobbyId, Guid.NewGuid(), "hi", Guid.NewGuid(), T0); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { row }); + _chat.GetSendersAsync(Arg.Any>(), Arg.Any()) + .Returns(new Dictionary()); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + var sender = result.Value!.Items.Single().Sender; + sender.UserId.Should().Be(Guid.Empty); + sender.DisplayName.Should().Be("Chat participant"); + } + + /// Post-frontend security review regression: GetHistoryAsync had no block filtering at all + /// (unlike SendAsync's GetDeliverableRecipientsAsync path fixed for M07-001), so a blocked + /// co-member's messages remained visible via REST history on every page load/resync. A message from a + /// blocked sender must be excluded; the actor's own message and an unrelated sender's message stay + /// visible. + [Fact] + public async Task GetHistory_RowFromBlockedSender_IsExcludedFromResults() + { + var blockedSenderId = Guid.NewGuid(); + var unrelatedSenderId = Guid.NewGuid(); + var ownMessage = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "mine", Guid.NewGuid(), T0); + var blockedMessage = ChatMessage.Create( + ChatScope.Lobby, _lobbyId, blockedSenderId, "from blocked user", Guid.NewGuid(), T0.AddSeconds(1)); + var unrelatedMessage = ChatMessage.Create( + ChatScope.Lobby, _lobbyId, unrelatedSenderId, "from unrelated user", Guid.NewGuid(), T0.AddSeconds(2)); + _chat.GetHistoryPageAsync( + ChatScope.Lobby, _lobbyId, ChatHistoryDirection.Before, null, null, 30, Arg.Any()) + .Returns(new List { ownMessage, blockedMessage, unrelatedMessage }); + + _friends.IsBlockedInEitherDirectionAsync(_actorId, blockedSenderId, Arg.Any()) + .Returns(true); + _friends.IsBlockedInEitherDirectionAsync(_actorId, unrelatedSenderId, Arg.Any()) + .Returns(false); + + var result = await _sut.GetHistoryAsync(_actorId, _lobbyId, ChatHistoryDirection.Before, null, 30); + + result.IsSuccess.Should().BeTrue(); + var senderIds = result.Value!.Items.Select(i => i.Sender.UserId).ToList(); + senderIds.Should().Contain(_actorId); + senderIds.Should().Contain(unrelatedSenderId); + senderIds.Should().NotContain(blockedSenderId); + result.Value.Items.Should().HaveCount(2); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private List ThreeMessagesAscending() + { + var m1 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "one", Guid.NewGuid(), T0); + var m2 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "two", Guid.NewGuid(), T0.AddSeconds(1)); + var m3 = ChatMessage.Create(ChatScope.Lobby, _lobbyId, _actorId, "three", Guid.NewGuid(), T0.AddSeconds(2)); + return new List { m1, m2, m3 }; + } + + private static User MakeUser(Guid id) + { + var user = User.Create($"user{id:N}"[..12], $"{id:N}@example.com", "hash", "Test Player"); + typeof(Entity).GetProperty(nameof(Entity.Id))!.SetValue(user, id); + return user; + } + + private static IReadOnlyDictionary Roster(IReadOnlyList ids) => + ids.ToDictionary(id => id, MakeUser); +} diff --git a/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs b/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs new file mode 100644 index 0000000..3b54f07 --- /dev/null +++ b/tests/SimPle.UnitTests/Games/GameCatalogSeedChecksumTests.cs @@ -0,0 +1,19 @@ +using System.Text; +using SimPle.Infrastructure.Games; + +namespace SimPle.UnitTests.Games; + +public sealed class GameCatalogSeedChecksumTests +{ + [Fact] + public void ComputeManifestChecksum_IgnoresGitLineEndingNormalization() + { + const string lf = "{\n \"manifestVersion\": \"2026.1\"\n}\n"; + var crlf = lf.Replace("\n", "\r\n", StringComparison.Ordinal); + + var lfChecksum = GameCatalogSeeder.ComputeManifestChecksum(Encoding.UTF8.GetBytes(lf)); + var crlfChecksum = GameCatalogSeeder.ComputeManifestChecksum(Encoding.UTF8.GetBytes(crlf)); + + Assert.Equal(lfChecksum, crlfChecksum); + } +} diff --git a/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs index da2a7f6..691d950 100644 --- a/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs +++ b/tests/SimPle.UnitTests/Lobbies/LobbiesServiceTests.cs @@ -280,7 +280,7 @@ public async Task Join_WrongExpiredRotatedAndClosed_AllReturnTheIdenticalError() // 3. Correct, live credential — but the lobby behind it has closed. var closedLobby = LobbyTestFactory.Open(_host, T0); - closedLobby.Close(LobbyClosedReason.HostClosed); + closedLobby.Close(LobbyClosedReason.HostClosed, T0); var closedCred = IssuedCredential(closedLobby.Id, "CLOSED"); _repo.FindActiveByCodeDigestAsync("hash:CLOSED", Arg.Any()).Returns(closedCred); _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); @@ -425,7 +425,7 @@ public async Task JoinByLobbyId_ForAPrivateClosedOrUnknownLobby_IsTheIdenticalNo var private_ = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, privateLobby.Id)); var closedLobby = LobbyTestFactory.Open(_host, T0, LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); - closedLobby.Close(LobbyClosedReason.HostClosed); + closedLobby.Close(LobbyClosedReason.HostClosed, T0); _repo.GetForUpdateAsync(closedLobby.Id, Arg.Any()).Returns(closedLobby); var closed = await _sut.JoinByCredentialAsync(_joiner, new JoinLobbyRequestDto(null, null, closedLobby.Id)); diff --git a/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs index b6b17e4..970bbdc 100644 --- a/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs +++ b/tests/SimPle.UnitTests/Lobbies/LobbyLifecycleTests.cs @@ -75,6 +75,40 @@ public void TheExpirySweepDoesNotTouchALobbyThatIsNotYetDue() lobby.State.Should().Be(LobbyState.Open); } + /// + /// Regression: expiry used to only flip Lobby.State, leaving every still-seated member's own + /// LobbyMemberState stuck at Joined. That row survives forever against the DB's partial unique index on + /// (UserId, Joined) — even though the lobby itself is terminal and invisible to GetActiveLobbyForUserAsync — + /// permanently blocking that user from ever joining or creating another lobby. + /// + [Fact] + public void ExpiryReleasesEveryRemainingSeatedMember() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); + _clock.Advance(TimeSpan.FromHours(2)); + + lobby.TryExpire(Now).Should().BeTrue(); + + lobby.JoinedCount.Should().Be(0); + lobby.FindJoinedMember(_host).Should().BeNull(); + lobby.FindJoinedMember(_alice).Should().BeNull(); + } + + /// Same guarantee for an explicit Close, not just expiry. + [Fact] + public void ClosingALobbyReleasesEveryRemainingSeatedMember() + { + var lobby = LobbyTestFactory.Open(_host, Now); + lobby.Join(_alice, Now).Should().Be(LobbyOutcome.Ok); + + lobby.Close(LobbyClosedReason.HostClosed, Now).Should().Be(LobbyOutcome.Ok); + + lobby.JoinedCount.Should().Be(0); + lobby.FindJoinedMember(_host).Should().BeNull(); + lobby.FindJoinedMember(_alice).Should().BeNull(); + } + // ── Capacity ───────────────────────────────────────────────────────────── [Fact] diff --git a/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs index c397f06..491c7d1 100644 --- a/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs +++ b/tests/SimPle.UnitTests/Matchmaking/LobbyBlockHandlerTests.cs @@ -130,7 +130,7 @@ await _lobbies.DidNotReceive().SaveAsync( public async Task ATerminalLobbyIsLeftAlone() { var lobby = GivenSharedLobby(); - lobby.Close(LobbyClosedReason.HostLeft); + lobby.Close(LobbyClosedReason.HostLeft, T0); await _sut.HandleAsync(BlockEvent(blocker: _host, blocked: _member)); diff --git a/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs b/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs new file mode 100644 index 0000000..5e8c80b --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/LobbyScopeAuthorizerTests.cs @@ -0,0 +1,202 @@ +using FluentAssertions; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Services; +using SimPle.Application.Realtime.Authorization; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Users; +using SimPle.UnitTests.Lobbies; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Authorization matrix for (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Authorization / Privacy rules"). Every denial must collapse to the exact same +/// code the REST API already uses — existence is never disclosed, so a +/// distinct "forbidden" would itself be the leak. +/// +public sealed class LobbyScopeAuthorizerTests +{ + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly IUserRepository _users = Substitute.For(); + private readonly IFriendRepository _friends = Substitute.For(); + private readonly LobbyScopeAuthorizer _authorizer; + + public LobbyScopeAuthorizerTests() + { + _authorizer = new LobbyScopeAuthorizer(_lobbies, _users, _friends); + } + + private static User ActiveUser() => User.Create("user1", "user1@example.com", "hash", "User One"); + + [Fact] + public async Task Subscribe_Member_Allowed() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_NonMemberOfPublicOpenLobby_Allowed() + { + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Subscribe_NonMemberOfPrivateLobby_DeniedWithPrivacySafeNotFound() + { + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Subscribe_UnknownLobby_DeniedWithSamePrivacySafeNotFound() + { + var actor = Guid.NewGuid(); + _lobbies.GetByIdAsync(Arg.Any(), Arg.Any()).Returns((Lobby?)null); + SetUpActor(actor); + + var result = await _authorizer.AuthorizeAsync(actor, Guid.NewGuid(), RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Send_NonMemberOfPublicOpenLobby_Denied() + { + // Unlike Subscribe, Send/Delete always require membership regardless of public visibility — an + // anonymous observer of a public lobby is never allowed to act inside it. + var host = Guid.NewGuid(); + var outsider = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Public)); + SetUpLobby(lobby); + SetUpActor(outsider); + + var result = await _authorizer.AuthorizeAsync(outsider, lobby.Id, RealtimeAction.Send); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Delete_Member_Allowed() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Delete); + + result.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task Member_BlockedByHost_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + SetUpActor(member); + _friends.IsBlockedInEitherDirectionAsync(member, host, Arg.Any()).Returns(true); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task Host_NeverBlockChecksSelf_Allowed() + { + var host = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + SetUpLobby(lobby); + SetUpActor(host); + + var result = await _authorizer.AuthorizeAsync(host, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeTrue(); + await _friends.DidNotReceive().IsBlockedInEitherDirectionAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task SuspendedActor_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + + var suspendedUser = ActiveUser(); + suspendedUser.Suspend(null); + _users.GetByIdAsync(member, Arg.Any()).Returns(suspendedUser); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + [Fact] + public async Task DeletedActor_Denied() + { + var host = Guid.NewGuid(); + var member = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(host, LobbyTestFactory.T0, + LobbyTestFactory.Settings(privacy: LobbyPrivacy.Private)); + lobby.Join(member, LobbyTestFactory.T0); + SetUpLobby(lobby); + _users.GetByIdAsync(member, Arg.Any()).Returns((User?)null); + + var result = await _authorizer.AuthorizeAsync(member, lobby.Id, RealtimeAction.Subscribe); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(LobbyErrors.NotFound); + } + + private void SetUpLobby(Lobby lobby) => + _lobbies.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + + private void SetUpActor(Guid actorUserId) => + _users.GetByIdAsync(actorUserId, Arg.Any()).Returns(ActiveUser()); +} diff --git a/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs b/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs new file mode 100644 index 0000000..7280046 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/NullMatchScopeAuthorizerTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using SimPle.Application.Realtime.Authorization; +using SimPle.Application.Realtime.Contracts; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Match-scope realtime does not exist yet (no Module 8) — every action against it must fail closed with +/// realtime.scope_not_available rather than 500ing or silently succeeding. +/// +public sealed class NullMatchScopeAuthorizerTests +{ + private readonly NullMatchScopeAuthorizer _authorizer = new(); + + [Theory] + [InlineData(RealtimeAction.Subscribe)] + [InlineData(RealtimeAction.Send)] + [InlineData(RealtimeAction.Delete)] + public async Task AlwaysDeniesWithScopeNotAvailable(RealtimeAction action) + { + var result = await _authorizer.AuthorizeAsync(Guid.NewGuid(), Guid.NewGuid(), action); + + result.IsAllowed.Should().BeFalse(); + result.ErrorCode.Should().Be(NullMatchScopeAuthorizer.ScopeNotAvailableCode); + } + + [Fact] + public void ScopeKind_IsMatch() + { + _authorizer.ScopeKind.Should().Be(RealtimeEnvelope.MatchScope); + } +} diff --git a/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs b/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs new file mode 100644 index 0000000..9e96712 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/Outbox/LobbyRealtimeHandlerTests.cs @@ -0,0 +1,265 @@ +using System.Reflection; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using SimPle.Application.Common.Interfaces; +using SimPle.Application.Lobbies.Outbox; +using SimPle.Application.Outbox; +using SimPle.Application.Realtime.Contracts; +using SimPle.Application.Realtime.Outbox; +using SimPle.Application.Realtime.Presence; +using SimPle.Domain.Lobbies; +using SimPle.Domain.Outbox; +using SimPle.UnitTests.Lobbies; +using Xunit; + +namespace SimPle.UnitTests.Realtime.Outbox; + +/// +/// : activation-watermark backfill suppression, duplicate-delivery no-op, +/// same-revision sibling-event collapse, revision-gap detection, and the presence side effects (docs/specs/ +/// module-07-realtime-presence-chat-spec.md, "Activation watermark" / Test Matrix). +/// +public sealed class LobbyRealtimeHandlerTests +{ + private static readonly DateTime T0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly IOutboxActivationStore _activation = Substitute.For(); + private readonly IRealtimeNotifier _notifier = Substitute.For(); + private readonly IPresenceRegistry _presence = Substitute.For(); + private readonly IPresenceViewerResolver _viewerResolver = Substitute.For(); + private readonly ILobbyRepository _lobbies = Substitute.For(); + private readonly FakeTimeProvider _clock = new(T0); + + private readonly Guid _lobbyId = Guid.NewGuid(); + + private readonly LobbyRealtimeHandler _sut; + + public LobbyRealtimeHandlerTests() + { + // No activation row yet at T0 for any of these tests: watermark = T0, tie-break = null (an empty + // outbox at first activation, per OutboxHandlerActivation's doc comment). + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0, null)); + + _sut = new LobbyRealtimeHandler( + _activation, _notifier, _presence, _viewerResolver, _lobbies, _clock, NullLogger.Instance); + } + + // ── Watermark suppression ─────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_MessageBeforeWatermark_IsSuppressedWithNoFanOut() + { + var watermarkId = Guid.NewGuid(); + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0.AddMinutes(5), watermarkId)); + + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyLobbyChangedAsync(default, default, default!); + await _notifier.DidNotReceiveWithAnyArgs().NotifyResyncRequiredAsync(default, default!, default); + } + + [Fact] + public async Task HandleAsync_MessageAtExactWatermarkInstantAndId_IsSuppressed() + { + var watermarkId = Guid.NewGuid(); + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0.AddMinutes(5), watermarkId)); + + // Same instant as the watermark and an Id that sorts at-or-before it (itself) must also suppress — + // "at or before", not strictly before. + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(5), id: watermarkId); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyLobbyChangedAsync(default, default, default!); + } + + [Fact] + public async Task HandleAsync_MessageAfterWatermark_FansOut() + { + _activation + .GetOrActivateAsync(LobbyRealtimeHandler.Name, Arg.Any>(), Arg.Any(), Arg.Any()) + .Returns(OutboxHandlerActivation.Activate(LobbyRealtimeHandler.Name, T0, T0, null)); + + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 1, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 1, LobbyOutbox.LobbyMemberJoined, Arg.Any()); + } + + // ── Duplicate delivery ────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_SameMessageDeliveredTwice_SecondDeliveryIsNoOp() + { + var message = Message(LobbyOutbox.LobbyMemberJoined, revision: 4, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + await _sut.HandleAsync(message); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 4, LobbyOutbox.LobbyMemberJoined, Arg.Any()); + } + + // ── Revision collapse ──────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_ThreeEventsAtSameRevision_CollapseToExactlyOneLobbyChanged() + { + // Lobby.Leave legitimately stages MemberLeftV1 + HostTransferredV1 + LobbyClosedV1 at one Revision. + var memberLeft = Message(LobbyOutbox.LobbyMemberLeft, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var hostTransferred = Message(LobbyOutbox.LobbyHostTransferred, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var lobbyClosed = Message(LobbyOutbox.LobbyClosed, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(memberLeft); + await _sut.HandleAsync(hostTransferred); + await _sut.HandleAsync(lobbyClosed); + + await _notifier.ReceivedWithAnyArgs(1).NotifyLobbyChangedAsync(default, default, default!); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 7, LobbyOutbox.LobbyMemberLeft, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_NextRevisionAfterCollapsedBatch_StillFansOut() + { + var atRevision7 = Message(LobbyOutbox.LobbyMemberLeft, revision: 7, occurredAtUtc: T0.AddMinutes(1)); + var atRevision8 = Message(LobbyOutbox.LobbySettingsChanged, revision: 8, occurredAtUtc: T0.AddMinutes(2)); + + await _sut.HandleAsync(atRevision7); + await _sut.HandleAsync(atRevision8); + + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 8, LobbyOutbox.LobbySettingsChanged, Arg.Any()); + } + + // ── Gap detection ──────────────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_RevisionGapAfterBaseline_TriggersResyncRequiredNotLobbyChanged() + { + var baseline = Message(LobbyOutbox.LobbyMemberJoined, revision: 1, occurredAtUtc: T0.AddMinutes(1)); + var gapped = Message(LobbyOutbox.LobbyMemberJoined, revision: 3, occurredAtUtc: T0.AddMinutes(2)); + + await _sut.HandleAsync(baseline); + await _sut.HandleAsync(gapped); + + await _notifier.Received(1).NotifyResyncRequiredAsync(_lobbyId, "gap", 3, Arg.Any()); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 1, Arg.Any(), Arg.Any()); + await _notifier.DidNotReceive().NotifyLobbyChangedAsync(_lobbyId, 3, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_FirstSightingOfLobbyAtNonOneRevision_IsNotTreatedAsGap() + { + // No prior baseline for this lobby (e.g. it was created before this process's activation watermark and + // this is simply its next post-watermark mutation) — nothing to gap-detect against. + var message = Message(LobbyOutbox.LobbySettingsChanged, revision: 9, occurredAtUtc: T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyResyncRequiredAsync(default, default!, default); + await _notifier.Received(1).NotifyLobbyChangedAsync(_lobbyId, 9, LobbyOutbox.LobbySettingsChanged, Arg.Any()); + } + + // ── Presence side effects ──────────────────────────────────────────────── + + [Fact] + public async Task HandleAsync_LobbyCreated_SetsHostMembershipAndBroadcastsWhenChanged() + { + var hostUserId = Guid.NewGuid(); + var viewers = new[] { hostUserId }; + _presence.SetLobbyMembership(hostUserId, _lobbyId, true) + .Returns(new PresenceUpdateResult(Changed: true, PresenceStatus.InLobby, Guid.NewGuid(), UserVersion: 1)); + _viewerResolver.ResolveAsync(hostUserId, Arg.Any()).Returns(viewers); + + var payload = $$"""{"lobbyId":"{{_lobbyId}}","hostUserId":"{{hostUserId}}","gameSlug":"chess-lite"}"""; + var message = Message(LobbyOutbox.LobbyCreated, revision: 1, occurredAtUtc: T0.AddMinutes(1), payload: payload); + + await _sut.HandleAsync(message); + + _presence.Received(1).SetLobbyMembership(hostUserId, _lobbyId, true); + await _notifier.Received(1).NotifyPresenceChangedAsync( + hostUserId, Arg.Is>(v => v.SequenceEqual(viewers)), + "InLobby", Arg.Any(), 1, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_LobbyCreated_DoesNotBroadcastWhenMembershipDidNotChange() + { + var hostUserId = Guid.NewGuid(); + // Already recorded as in this lobby (e.g. a redelivery) — SetLobbyMembership is a no-op. + _presence.SetLobbyMembership(hostUserId, _lobbyId, true) + .Returns(new PresenceUpdateResult(Changed: false, PresenceStatus.InLobby, Guid.NewGuid(), UserVersion: 1)); + + var payload = $$"""{"lobbyId":"{{_lobbyId}}","hostUserId":"{{hostUserId}}","gameSlug":"chess-lite"}"""; + var message = Message(LobbyOutbox.LobbyCreated, revision: 1, occurredAtUtc: T0.AddMinutes(1), payload: payload); + + await _sut.HandleAsync(message); + + await _notifier.DidNotReceiveWithAnyArgs().NotifyPresenceChangedAsync( + default, default!, default!, default, default, default); + } + + /// + /// Regression guard for the fix alongside Lobby.CloseInternal/TryExpire now releasing every seat: this handler + /// must clear presence membership from the full roster (Members), not JoinedMembers — by the time this handler + /// re-reads the lobby, JoinedMembers is already empty because closure released every seat. + /// + [Fact] + public async Task HandleAsync_LobbyClosed_ClearsMembershipForEveryMemberOnTheFullRoster() + { + var hostId = Guid.NewGuid(); + var otherId = Guid.NewGuid(); + var lobby = LobbyTestFactory.Open(hostId, T0); + lobby.Join(otherId, T0); + lobby.TryExpire(T0.AddHours(2)); // closure already released every seat, matching production behavior + + _lobbies.GetByIdAsync(lobby.Id, Arg.Any()).Returns(lobby); + _presence.SetLobbyMembership(Arg.Any(), lobby.Id, false) + .Returns(new PresenceUpdateResult(Changed: true, PresenceStatus.Online, Guid.NewGuid(), UserVersion: 1)); + _viewerResolver.ResolveAsync(Arg.Any(), Arg.Any()).Returns(Array.Empty()); + + var message = OutboxMessage.Create( + "Lobby", lobby.Id, LobbyOutbox.LobbyClosed, LobbyOutbox.EventVersion, + aggregateDomainVersion: 2, requestCycleId: 2, payload: $$"""{"lobbyId":"{{lobby.Id}}","reason":"Expired"}"""); + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.OccurredAtUtc))!.SetValue(message, T0.AddMinutes(1)); + + await _sut.HandleAsync(message); + + _presence.Received(1).SetLobbyMembership(hostId, lobby.Id, false); + _presence.Received(1).SetLobbyMembership(otherId, lobby.Id, false); + } + + // ── EventTypes scoping ────────────────────────────────────────────────── + + [Fact] + public void EventTypes_DoesNotIncludeInviteOrMatchRequestEvents() + { + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteCreated); + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteAccepted); + _sut.EventTypes.Should().NotContain(LobbyOutbox.LobbyInviteRevoked); + _sut.EventTypes.Should().NotContain(LobbyOutbox.MatchRequested); + _sut.EventTypes.Should().Contain(LobbyOutbox.LobbyReadinessChanged); + } + + private OutboxMessage Message(string eventType, long revision, DateTime occurredAtUtc, Guid? id = null, string payload = "{}") + { + var message = OutboxMessage.Create( + "Lobby", _lobbyId, eventType, LobbyOutbox.EventVersion, + aggregateDomainVersion: revision, requestCycleId: (int)revision, payload: payload); + + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.OccurredAtUtc))!.SetValue(message, occurredAtUtc); + if (id is Guid explicitId) + typeof(OutboxMessage).GetProperty(nameof(OutboxMessage.Id))!.SetValue(message, explicitId); + + return message; + } +} diff --git a/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs b/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs new file mode 100644 index 0000000..1c190f8 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/PresenceRegistryTests.cs @@ -0,0 +1,186 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using SimPle.Application.Realtime.Presence; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Fake-clock boundary tests for (docs/specs/module-07-realtime-presence-chat- +/// spec.md, "Domain Invariants: presence precedence"). Mirrors the FakeTimeProvider convention already used in +/// tests/SimPle.UnitTests/Matchmaking/ExpirySweeperTests.cs. +/// +public sealed class PresenceRegistryTests +{ + private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly FakeTimeProvider _clock = new(T0); + private readonly PresenceRegistry _registry; + + public PresenceRegistryTests() + { + _registry = new PresenceRegistry(_clock); + } + + [Fact] + public void Connect_FirstConnection_ReportsOnlineAndBumpsVersion() + { + var userId = Guid.NewGuid(); + + var connected = _registry.TryConnect(userId, "conn-1"); + var status = _registry.GetStatus(userId); + + connected.Should().BeTrue(); + status.Status.Should().Be(PresenceStatus.Online); + status.UserVersion.Should().Be(1); + } + + [Fact] + public void Away_At4Minutes59Seconds_StillOnline() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(59)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + status.Changed.Should().BeFalse(); + } + + [Fact] + public void Away_At5Minutes1Second_BecomesAway() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromMinutes(5) + TimeSpan.FromSeconds(1)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Away); + status.Changed.Should().BeTrue(); + } + + [Fact] + public void Offline_At9SecondsAfterAllDisconnected_NotYetOffline() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _registry.Disconnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromSeconds(9)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void Offline_At11SecondsAfterAllDisconnected_BecomesOfflineAndEvictsBucket() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _registry.Disconnect(userId, "conn-1"); + + _clock.Advance(TimeSpan.FromSeconds(11)); + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Offline); + status.Changed.Should().BeTrue(); + + // Bucket evicted: a subsequent query for the same (still-disconnected) user starts fresh at version 0 + // rather than continuing to increment a retained entry. + var again = _registry.GetStatus(userId); + again.UserVersion.Should().Be(0); + again.Changed.Should().BeFalse(); + } + + [Fact] + public void ReportActivity_ThrottledWithinSixtySeconds_RejectedAndMutatesNothing() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); // sets last-activity at T0 + _clock.Advance(TimeSpan.FromSeconds(30)); // within the 60s throttle window of that same stamp + + var accepted = _registry.TryReportActivity(userId, "conn-1"); + accepted.Should().BeFalse(); + + // Prove the rejected signal truly mutated nothing: the away-timer base is still T0, so at T0+4:59 total + // (30s already elapsed + 4:29 more) the connection is still within its original 5-minute Online window. + _clock.Advance(TimeSpan.FromMinutes(4) + TimeSpan.FromSeconds(29)); + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void ReportActivity_AfterThrottleWindow_AcceptedAndResetsAwayTimer() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _clock.Advance(TimeSpan.FromSeconds(61)); // past the 60s throttle window + + var accepted = _registry.TryReportActivity(userId, "conn-1"); + accepted.Should().BeTrue(); + + // Away-timer reset by the accepted activity: 4 more minutes from here is still well within Online. + _clock.Advance(TimeSpan.FromMinutes(4)); + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void MaxAggregation_OneConnectionActive_KeepsUserOnlineDespiteAnotherIdleConnection() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + _clock.Advance(TimeSpan.FromMinutes(3)); + _registry.TryConnect(userId, "conn-2"); + _clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(1)); // conn-1 now 5:01 idle, conn-2 only 2:01 + + _registry.GetStatus(userId).Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void SixthConnection_ForSameUser_Rejected() + { + var userId = Guid.NewGuid(); + for (var i = 0; i < 5; i++) + _registry.TryConnect(userId, $"conn-{i}").Should().BeTrue(); + + _registry.TryConnect(userId, "conn-6").Should().BeFalse(); + } + + [Fact] + public void MemberOfLobby_WhileConnected_DrivesInLobbyStatus() + { + var userId = Guid.NewGuid(); + var lobbyId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var result = _registry.SetLobbyMembership(userId, lobbyId, isMember: true); + + result.Status.Should().Be(PresenceStatus.InLobby); + } + + [Fact] + public void SubscribedLobby_DoesNotDriveInLobbyStatus() + { + // SubscribedLobbyIds (fan-out) is a hub/group concern entirely separate from MemberOfLobbyIds (presence). + // Presence never learns about a subscription that isn't also reported as membership. + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var status = _registry.GetStatus(userId); + + status.Status.Should().Be(PresenceStatus.Online); + } + + [Fact] + public void ServerEpoch_IsStableAcrossCalls() + { + var userId = Guid.NewGuid(); + _registry.TryConnect(userId, "conn-1"); + + var first = _registry.GetStatus(userId).ServerEpoch; + var second = _registry.GetStatus(userId).ServerEpoch; + + first.Should().Be(second); + first.Should().Be(_registry.ServerEpoch); + } +} diff --git a/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs b/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs new file mode 100644 index 0000000..0fb8da6 --- /dev/null +++ b/tests/SimPle.UnitTests/Realtime/RealtimeDeadCodeRegressionTests.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using FluentAssertions; +using SimPle.Domain.Chat; + +namespace SimPle.UnitTests.Realtime; + +/// +/// Regression tests for the Module 7 dead-code removal (docs/specs/module-07-realtime-presence-chat-spec.md, +/// backend sessions A/B, M07-B1/M07-B2): the orphan ChatContext.DirectMessage-era stub (zero DbSet, zero +/// EF config, zero migration, zero repository, zero consumer) was replaced outright by in +/// M07-B2 — never migrated, never given a DirectMessage member — and the four unused placeholder notifier +/// interfaces that routed by a non-existent lobbyCode were deleted outright in M07-B1. These tests fail +/// loudly if any of it ever comes back. +/// +public sealed class RealtimeDeadCodeRegressionTests +{ + [Fact] + public void ChatScope_HasNoDirectMessageValue() + { + Enum.GetNames().Should().NotContain("DirectMessage"); + } + + [Fact] + public void ChatScope_OnlyHasLobbyAndMatch() + { + Enum.GetNames().Should().BeEquivalentTo("Lobby", "Match"); + } + + [Fact] + public void NoChatContextTypeSurvivesAnywhereInTheLoadedAssemblies() + { + // ChatContext was the orphan stub's enum name (pre-M07-B2). Replaced by ChatScope, not renamed in place — + // if a type literally named ChatContext ever reappears, something resurrected the old shape. + typeof(ChatMessage).Assembly.GetTypes().Select(t => t.Name).Should().NotContain("ChatContext"); + } + + [Fact] + public void NoLobbyCodeRoutedNotifierInterfaceSurvivesAnywhereInTheLoadedAssemblies() + { + // The dead placeholder file (src/SimPle.Infrastructure/Realtime/IHubContext.cs) declared + // IPresenceNotifier/ILobbyNotifier/IGameNotifier/IHardwareNotifier, none of which had a single + // implementation or caller. If any of these type names ever reappear anywhere in the solution's own + // assemblies, this test fails — the fix was to delete them, not resurrect them under the same name. + var deadTypeNames = new[] { "IPresenceNotifier", "ILobbyNotifier", "IGameNotifier", "IHardwareNotifier" }; + + var assemblies = new[] + { + typeof(SimPle.Application.Realtime.Contracts.IRealtimeClient).Assembly, + typeof(SimPle.Domain.Chat.ChatMessage).Assembly, + }; + + foreach (var assembly in assemblies) + { + var typeNames = assembly.GetTypes().Select(t => t.Name).ToList(); + var resurrected = typeNames.Intersect(deadTypeNames).ToList(); + resurrected.Should().BeEmpty( + $"assembly {assembly.GetName().Name} should not declare any resurrected dead placeholder type"); + } + } +} diff --git a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj index bd44798..eada308 100644 --- a/tests/SimPle.UnitTests/SimPle.UnitTests.csproj +++ b/tests/SimPle.UnitTests/SimPle.UnitTests.csproj @@ -18,6 +18,9 @@ + + +