Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Modgud.Api.Features.Admin.Jobs;
using Modgud.Api.Tests.Infrastructure;
using Modgud.Application.DTOs.OAuth;
Expand Down Expand Up @@ -236,9 +237,11 @@ public async Task Reaper_erases_only_legacy_ghosts_and_only_when_not_dry_run()
{
var ct = TestContext.Current.CancellationToken;
const string ghostEmail = "rp-ghost@example.test";
const string youngEmail = "rp-young-ghost@example.test";
const string keepEmail = "rp-keep-password@example.test";
const int olderThanDays = 7;

Guid ghostId, keepId;
Guid ghostId, youngId, keepId;
using (var scope = CreateTenantScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
Expand All @@ -254,6 +257,23 @@ public async Task Reaper_erases_only_legacy_ghosts_and_only_when_not_dry_run()
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10), ExpiresAt = DateTimeOffset.UtcNow.AddDays(-10),
});
await session.SaveChangesAsync(ct);
// The reaper ages a candidate by its STREAM's creation time, and Postgres stamps
// that with its own now() — not this process's clock. Backdate it in the database
// so the ghost is old by any clock; the assertion must never ride on the DB host
// and the test host agreeing to the second (a Docker VM clock running ahead of
// Windows made a zero-day window miss the ghost in streaks).
await BackdateStreamAsync(session, ghost.Id, TimeSpan.FromDays(10), ct);

// Same signature, but created just now → inside the window, must survive.
var young = new ApplicationUser(youngEmail, youngEmail) { IsActive = true };
Assert.True((await userManager.CreateAsync(young)).Succeeded);
youngId = young.Id;
session.Store(new EmailOtpChallenge
{
Id = young.Id, CodeHash = "dead", Email = youngEmail,
CreatedAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10),
});
await session.SaveChangesAsync(ct);

// Unconfirmed but WITH a password → outside the signature, must survive.
var keep = new ApplicationUser("rp-keep-password", keepEmail) { IsActive = true };
Expand All @@ -264,7 +284,7 @@ public async Task Reaper_erases_only_legacy_ghosts_and_only_when_not_dry_run()
using (var scope = CreateTenantScope())
{
var job = ActivatorUtilities.CreateInstance<UnconfirmedRegistrationReaperJob>(scope.ServiceProvider);
var dry = await job.RunAsync(dryRun: true, olderThanDays: 0, ct);
var dry = await job.RunAsync(dryRun: true, olderThanDays, ct);
Assert.Equal(1, dry.Matched);
Assert.Equal(0, dry.Erased);
}
Expand All @@ -273,7 +293,7 @@ public async Task Reaper_erases_only_legacy_ghosts_and_only_when_not_dry_run()
using (var scope = CreateTenantScope())
{
var job = ActivatorUtilities.CreateInstance<UnconfirmedRegistrationReaperJob>(scope.ServiceProvider);
var run = await job.RunAsync(dryRun: false, olderThanDays: 0, ct);
var run = await job.RunAsync(dryRun: false, olderThanDays, ct);
Assert.Equal((1, 1), run);
}

Expand All @@ -283,13 +303,34 @@ public async Task Reaper_erases_only_legacy_ghosts_and_only_when_not_dry_run()
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
var erased = await session.LoadAsync<ApplicationUser>(ghostId, ct);
Assert.True(erased is null || erased.IsDeleted);
var young = await session.LoadAsync<ApplicationUser>(youngId, ct);
Assert.NotNull(young);
Assert.False(young!.IsDeleted);
var kept = await session.LoadAsync<ApplicationUser>(keepId, ct);
Assert.NotNull(kept);
Assert.False(kept!.IsDeleted);
Assert.Equal(keepEmail, kept.Email);
}
}

/// <summary>
/// Moves a stream's <c>created</c> stamp into the past. Marten leaves that column to the
/// database default (<c>now()</c>) and offers no API to set it, so age-based tests have to
/// reach into <c>mt_streams</c> directly.
/// </summary>
private static async Task BackdateStreamAsync(IDocumentSession session, Guid streamId, TimeSpan by, CancellationToken ct)
{
var schema = session.DocumentStore.Options.Events.DatabaseSchemaName;
// CA2100: the schema name is Marten's own configuration, not input; the values are bound.
#pragma warning disable CA2100
await using var cmd = new NpgsqlCommand($"update {schema}.mt_streams set created = created - @by where id = @id");
#pragma warning restore CA2100
cmd.Parameters.AddWithValue("by", by);
cmd.Parameters.AddWithValue("id", streamId);
var rows = await session.ExecuteAsync(cmd, ct);
Assert.Equal(1, rows);
}

// ── Helpers ──────────────────────────────────────────────────────────────

private async Task SetUpJitAppAsync(string slug, string host)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private static TestConfigurationContext BuildContext(string connectionString) =>
CertPath = null, // Disable certificate loading
DbSettings =
{
ConnectionString = connectionString
ConnectionString = TestPostgres.WithGenerousConnectTimeout(connectionString)
}
}),
rule.For<AppSettings>().FromStatic(_ => new AppSettings { AuthenticationMinimumLevel = 0 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public class SharedPostgresFixture : IAsyncLifetime
.WithCommand("-c", "max_connections=500")
.Build();

public string ConnectionString => Container.GetConnectionString();
public string ConnectionString => TestPostgres.WithGenerousConnectTimeout(Container.GetConnectionString());

/// <summary>
/// Test configuration context - created after container starts.
Expand Down
19 changes: 19 additions & 0 deletions src/dotnet/Modgud.Api.Tests/Infrastructure/TestPostgres.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Npgsql;

namespace Modgud.Api.Tests.Infrastructure;

/// <summary>Connection-string adjustments shared by the Testcontainers fixtures.</summary>
public static class TestPostgres
{
/// <summary>
/// Raises Npgsql's connect timeout from its 15 s default. The suites boot a host per
/// test, and each boot opens fresh connections through Docker Desktop's published-port
/// proxy while other containers may be starting or stopping. A TCP connect that stalls
/// past 15 s surfaces as "Timeout during connection attempt" at
/// <c>ApplyAllConfiguredChangesToDatabaseAsync</c> and fails an unrelated test once in
/// a long run. Realm databases derive their connection strings from this one, so the
/// setting reaches them too. The value also bounds the wait for a pooled connection.
/// </summary>
public static string WithGenerousConnectTimeout(string connectionString) =>
new NpgsqlConnectionStringBuilder(connectionString) { Timeout = 60 }.ConnectionString;
}
Loading