Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api-tests
diff options
context:
space:
mode:
authorLeitwolf112026-10-06 22:53:46 +0300
committerLeitwolf112026-10-06 22:53:46 +0300
commit1c986ff99b3c8f349e4a63c0e922276ddf7dc836 (patch)
tree33c88e9cbeaa7758365a62c4c12ca9726dad607d /apps/api-tests
parent933244cc0fa149427d22d06be4d226c0a67e8b80 (diff)
downloadprogmaweb-1c986ff99b3c8f349e4a63c0e922276ddf7dc836.tar.gz
Keep accounts and sessions in PostgreSQL
The account API kept everything in the memory of its process, so every restart and every deployment of the API deleted all accounts and signed everyone out. PostgresAccountStore implements IAccountStore on PostgreSQL through Npgsql. The three uniqueness rules are unique constraints, so two requests that race for one name, address or Google identity are decided by the database. A refused account is an ordinary answer, not an exception, and the rules are reported in the order the in-memory store reports them. A session refers to its account, and a session of an account that is not stored is refused; the in-memory store now refuses it as well. The schema is a list of numbered steps. The service takes the missing ones when it starts, in one transaction under an advisory lock, and does not start when the database cannot be reached or is newer than the release. /health answers 503 while the database does not answer. ConnectionStrings:Accounts selects PostgreSQL. A production service that is given no database does not start; keeping accounts in memory there has to be asked for with Accounts:Store=Memory. Development and the tests keep the in-memory store by default. The store gains RemoveExpiredSessionsAsync, and a background service calls it at the start and once an hour: a session whose token is never presented again no longer keeps its row for ever. PostgreSQL keeps a timestamp to the microsecond. The store cuts a moment to that precision and returns what it stored, and the service uses the returned account, so the creation time in the registration response equals the one every later read returns. Tests. AccountStoreContractTests states the rules once and runs them on both stores. Further tests cover the schema steps applied concurrently and a database that is too new, the choice of the store from the configuration, the removal of expired sessions, and an account with its session surviving a restart of the service over HTTP. The PostgreSQL tests read their database from PROGMAWEB_TEST_POSTGRES and work in a schema of their own each. Without the variable they are skipped; CI starts PostgreSQL 16 for them, and a test fails the run when the variable is missing there. Operations. ops/README.md describes the database: the Unix socket with peer authentication, so that no password exists, the one-time preparation, and rollback across a schema step. The service unit starts after PostgreSQL. docs/LIMITATIONS.md no longer lists the in-memory store and now lists the missing backup and the missing deletion of accounts.
Diffstat (limited to 'apps/api-tests')
-rw-r--r--apps/api-tests/AccountApiFactory.cs18
-rw-r--r--apps/api-tests/AccountServiceTests.cs3
-rw-r--r--apps/api-tests/AccountStoreContractTests.cs345
-rw-r--r--apps/api-tests/AccountStoreSelectionTests.cs273
-rw-r--r--apps/api-tests/PostgresTestDatabase.cs87
-rw-r--r--apps/api-tests/packages.lock.json293
6 files changed, 730 insertions, 289 deletions
diff --git a/apps/api-tests/AccountApiFactory.cs b/apps/api-tests/AccountApiFactory.cs
index 89faa3e..4692f0c 100644
--- a/apps/api-tests/AccountApiFactory.cs
+++ b/apps/api-tests/AccountApiFactory.cs
@@ -11,11 +11,25 @@ namespace Progmasoft.Progmaweb.Api.Tests;
/// <param name="configureGoogle">
/// Whether the Google client is configured. Without it the service has to run and refuse only Google sign-in.
/// </param>
-internal sealed class AccountApiFactory(bool configureGoogle = true) : WebApplicationFactory<Program>
+/// <param name="accountDatabase">
+/// Connection string of the PostgreSQL database that keeps the accounts; <see langword="null"/> keeps them in memory.
+/// </param>
+/// <param name="environment">Name of the hosting environment.</param>
+/// <param name="accountStore">Value of <c>Accounts:Store</c>; <see langword="null"/> leaves it unset.</param>
+internal sealed class AccountApiFactory(
+ bool configureGoogle = true,
+ string? accountDatabase = null,
+ string environment = "Testing",
+ string? accountStore = null) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- builder.UseEnvironment("Testing");
+ builder.UseEnvironment(environment);
+
+ // The program chooses the store while it registers services, so these are host settings as well. An empty
+ // value overrides a database the environment of the test run names.
+ builder.UseSetting("ConnectionStrings:Accounts", accountDatabase ?? string.Empty);
+ builder.UseSetting("Accounts:Store", accountStore ?? string.Empty);
// The program decides while it registers services whether the Google scheme exists, so these two values
// have to be host settings: configuration sources added below are applied only when the host is built,
diff --git a/apps/api-tests/AccountServiceTests.cs b/apps/api-tests/AccountServiceTests.cs
index c2a9a46..28fd44f 100644
--- a/apps/api-tests/AccountServiceTests.cs
+++ b/apps/api-tests/AccountServiceTests.cs
@@ -169,5 +169,8 @@ public sealed class AccountServiceTests
public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) =>
ValueTask.CompletedTask;
+
+ public ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
+ ValueTask.FromResult(0);
}
}
diff --git a/apps/api-tests/AccountStoreContractTests.cs b/apps/api-tests/AccountStoreContractTests.cs
new file mode 100644
index 0000000..2266273
--- /dev/null
+++ b/apps/api-tests/AccountStoreContractTests.cs
@@ -0,0 +1,345 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Npgsql;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+/// <summary>The rules every <see cref="IAccountStore"/> keeps, run against each implementation.</summary>
+/// <remarks>
+/// The in-memory store and the PostgreSQL store have to behave alike, because the service tests run on the first
+/// and production runs on the second. One set of tests states the rules; a subclass supplies the store.
+/// </remarks>
+public abstract class AccountStoreContractTests
+{
+ /// <summary>A moment with a fraction PostgreSQL cannot keep: seven digits where it keeps six.</summary>
+ private static readonly DateTimeOffset Moment = new DateTimeOffset(2026, 10, 6, 12, 30, 15, TimeSpan.Zero).AddTicks(1234567);
+
+ private static CancellationToken Cancellation => TestContext.Current.CancellationToken;
+
+ /// <summary>Creates an empty store. A subclass without its database skips the test here.</summary>
+ private protected abstract ValueTask<IAccountStore> CreateStoreAsync();
+
+ private static AccountRecord Account(string name, string email, string? googleSubject = null, string? passwordHash = "hash") =>
+ new(
+ Guid.NewGuid(),
+ name,
+ name.ToUpperInvariant(),
+ email,
+ email.ToUpperInvariant(),
+ googleSubject,
+ passwordHash,
+ Moment);
+
+ private static byte[] Digest(byte seed) => [.. Enumerable.Range(0, 32).Select(index => (byte)(seed + index))];
+
+ [Fact]
+ public async Task AStoredAccountIsFoundByEachOfItsKeys()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord account = Account("Ada", "ada@example.com", googleSubject: "google-ada", passwordHash: null);
+
+ CreateAccountResult result = await store.CreateAsync(account, Cancellation);
+
+ Assert.Equal(CreateAccountStatus.Created, result.Status);
+ AccountRecord stored = Assert.IsType<AccountRecord>(result.Account);
+ Assert.Equal(account with { CreatedAt = stored.CreatedAt }, stored);
+ // What the store returns is what it keeps: a later read gives the same moment, to the last digit.
+ Assert.True((account.CreatedAt - stored.CreatedAt).Duration() < TimeSpan.FromMicroseconds(1));
+ Assert.Equal(stored, await store.FindByIdAsync(account.Id, Cancellation));
+ Assert.Equal(stored, await store.FindByEmailAsync("ADA@EXAMPLE.COM", Cancellation));
+ Assert.Equal(stored, await store.FindByGoogleSubjectAsync("google-ada", Cancellation));
+ }
+
+ [Fact]
+ public async Task AnAccountWithoutAGoogleIdentityKeepsItsPasswordHash()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord account = Account("Grace", "grace@example.com", passwordHash: "AQAAAA-hash");
+
+ await store.CreateAsync(account, Cancellation);
+
+ AccountRecord found = Assert.IsType<AccountRecord>(await store.FindByIdAsync(account.Id, Cancellation));
+ Assert.Null(found.GoogleSubject);
+ Assert.Equal("AQAAAA-hash", found.PasswordHash);
+ Assert.Equal("Grace", found.AccountName);
+ Assert.Equal("grace@example.com", found.Email);
+ }
+
+ [Fact]
+ public async Task UnknownKeysFindNothing()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ await store.CreateAsync(Account("Known", "known@example.com"), Cancellation);
+
+ Assert.Null(await store.FindByIdAsync(Guid.NewGuid(), Cancellation));
+ Assert.Null(await store.FindByEmailAsync("OTHER@EXAMPLE.COM", Cancellation));
+ // The lookup key is the normalized address; the spelling the owner entered is not a key.
+ Assert.Null(await store.FindByEmailAsync("known@example.com", Cancellation));
+ Assert.Null(await store.FindByGoogleSubjectAsync("google-nobody", Cancellation));
+ }
+
+ [Fact]
+ public async Task ATakenNameAddressOrGoogleIdentityIsRefusedAndNothingIsStored()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ await store.CreateAsync(Account("Taken", "taken@example.com", "google-taken"), Cancellation);
+
+ AccountRecord sameName = Account("TAKEN", "one@example.com", "google-one");
+ AccountRecord sameEmail = Account("Two", "Taken@Example.com", "google-two");
+ AccountRecord sameGoogle = Account("Three", "three@example.com", "google-taken");
+
+ Assert.Equal(CreateAccountStatus.AccountNameUnavailable, (await store.CreateAsync(sameName, Cancellation)).Status);
+ Assert.Equal(CreateAccountStatus.EmailUnavailable, (await store.CreateAsync(sameEmail, Cancellation)).Status);
+ Assert.Equal(CreateAccountStatus.GoogleSubjectUnavailable, (await store.CreateAsync(sameGoogle, Cancellation)).Status);
+
+ foreach (AccountRecord refused in new[] { sameName, sameEmail, sameGoogle })
+ {
+ Assert.Null(await store.FindByIdAsync(refused.Id, Cancellation));
+ }
+ // A refused account leaves its free keys free.
+ Assert.Null(await store.FindByEmailAsync("ONE@EXAMPLE.COM", Cancellation));
+ Assert.Null(await store.FindByGoogleSubjectAsync("google-two", Cancellation));
+ Assert.Equal(
+ CreateAccountStatus.Created,
+ (await store.CreateAsync(Account("One", "one@example.com", "google-two"), Cancellation)).Status);
+ }
+
+ [Fact]
+ public async Task SeveralBrokenRulesAreReportedInOneFixedOrder()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ await store.CreateAsync(Account("Taken", "taken@example.com", "google-taken"), Cancellation);
+
+ // Google identity before name, name before address, whichever the store happens to check first.
+ Assert.Equal(
+ CreateAccountStatus.GoogleSubjectUnavailable,
+ (await store.CreateAsync(Account("Taken", "taken@example.com", "google-taken"), Cancellation)).Status);
+ Assert.Equal(
+ CreateAccountStatus.AccountNameUnavailable,
+ (await store.CreateAsync(Account("Taken", "taken@example.com", "google-free"), Cancellation)).Status);
+ Assert.Equal(
+ CreateAccountStatus.AccountNameUnavailable,
+ (await store.CreateAsync(Account("Taken", "taken@example.com"), Cancellation)).Status);
+ Assert.Equal(
+ CreateAccountStatus.EmailUnavailable,
+ (await store.CreateAsync(Account("Free", "taken@example.com"), Cancellation)).Status);
+ }
+
+ [Fact]
+ public async Task ManyAccountsMayBeWithoutAGoogleIdentity()
+ {
+ IAccountStore store = await CreateStoreAsync();
+
+ for (int index = 0; index < 5; index++)
+ {
+ CreateAccountResult result =
+ await store.CreateAsync(Account($"User{index}", $"user{index}@example.com"), Cancellation);
+ Assert.Equal(CreateAccountStatus.Created, result.Status);
+ }
+ }
+
+ [Fact]
+ public async Task OfManyRequestsForOneNameExactlyOneIsStored()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord[] candidates = [.. Enumerable.Range(0, 16).Select(index => Account("Race", $"race{index}@example.com"))];
+
+ CreateAccountResult[] results = await Task.WhenAll(
+ candidates.Select(candidate => Task.Run(async () => await store.CreateAsync(candidate, Cancellation))));
+
+ CreateAccountResult winner = Assert.Single(results, result => result.Status is CreateAccountStatus.Created);
+ Assert.All(
+ results.Where(result => !ReferenceEquals(result, winner)),
+ result => Assert.Equal(CreateAccountStatus.AccountNameUnavailable, result.Status));
+ foreach (AccountRecord candidate in candidates)
+ {
+ AccountRecord? found = await store.FindByIdAsync(candidate.Id, Cancellation);
+ Assert.Equal(candidate.Id == winner.Account!.Id, found is not null);
+ }
+ }
+
+ [Fact]
+ public async Task ASessionIsStoredFoundReplacedAndRevoked()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord first = Account("First", "first@example.com");
+ AccountRecord second = Account("Second", "second@example.com");
+ await store.CreateAsync(first, Cancellation);
+ await store.CreateAsync(second, Cancellation);
+ DateTimeOffset created = new(2026, 10, 6, 8, 0, 0, TimeSpan.Zero);
+ SessionRecord session = new(Digest(1), first.Id, created, created.AddHours(12));
+
+ Assert.Null(await store.FindSessionAsync(Digest(1), Cancellation));
+ await store.StoreSessionAsync(session, Cancellation);
+
+ SessionRecord found = Assert.IsType<SessionRecord>(await store.FindSessionAsync(Digest(1), Cancellation));
+ Assert.Equal(session.TokenDigest, found.TokenDigest);
+ Assert.Equal(first.Id, found.AccountId);
+ Assert.Equal(session.CreatedAt, found.CreatedAt);
+ Assert.Equal(session.ExpiresAt, found.ExpiresAt);
+ Assert.Null(await store.FindSessionAsync(Digest(2), Cancellation));
+
+ // Storing the same digest again replaces the session.
+ await store.StoreSessionAsync(session with { AccountId = second.Id, ExpiresAt = created.AddHours(1) }, Cancellation);
+ SessionRecord replaced = Assert.IsType<SessionRecord>(await store.FindSessionAsync(Digest(1), Cancellation));
+ Assert.Equal(second.Id, replaced.AccountId);
+ Assert.Equal(created.AddHours(1), replaced.ExpiresAt);
+
+ await store.RevokeSessionAsync(Digest(1), Cancellation);
+ Assert.Null(await store.FindSessionAsync(Digest(1), Cancellation));
+ // Revoking again, or revoking what never existed, is not an error.
+ await store.RevokeSessionAsync(Digest(1), Cancellation);
+ await store.RevokeSessionAsync(Digest(9), Cancellation);
+ }
+
+ [Fact]
+ public async Task ASessionOfAnAccountThatIsNotStoredIsRefused()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ DateTimeOffset created = new(2026, 10, 6, 8, 0, 0, TimeSpan.Zero);
+ SessionRecord orphan = new(Digest(3), Guid.NewGuid(), created, created.AddHours(1));
+
+ await Assert.ThrowsAnyAsync<Exception>(async () => await store.StoreSessionAsync(orphan, Cancellation));
+
+ Assert.Null(await store.FindSessionAsync(Digest(3), Cancellation));
+ }
+
+ [Fact]
+ public async Task OnlyExpiredSessionsAreRemoved()
+ {
+ IAccountStore store = await CreateStoreAsync();
+ AccountRecord account = Account("Owner", "owner@example.com");
+ await store.CreateAsync(account, Cancellation);
+ DateTimeOffset now = new(2026, 10, 6, 12, 0, 0, TimeSpan.Zero);
+ await store.StoreSessionAsync(new SessionRecord(Digest(10), account.Id, now.AddHours(-13), now.AddHours(-1)), Cancellation);
+ // A session expires at its expiry, not after it.
+ await store.StoreSessionAsync(new SessionRecord(Digest(20), account.Id, now.AddHours(-12), now), Cancellation);
+ await store.StoreSessionAsync(new SessionRecord(Digest(30), account.Id, now.AddHours(-1), now.AddTicks(10)), Cancellation);
+ await store.StoreSessionAsync(new SessionRecord(Digest(40), account.Id, now, now.AddHours(12)), Cancellation);
+
+ Assert.Equal(2, await store.RemoveExpiredSessionsAsync(now, Cancellation));
+
+ Assert.Null(await store.FindSessionAsync(Digest(10), Cancellation));
+ Assert.Null(await store.FindSessionAsync(Digest(20), Cancellation));
+ Assert.NotNull(await store.FindSessionAsync(Digest(30), Cancellation));
+ Assert.NotNull(await store.FindSessionAsync(Digest(40), Cancellation));
+ Assert.Equal(0, await store.RemoveExpiredSessionsAsync(now, Cancellation));
+ // The account outlives its sessions.
+ Assert.NotNull(await store.FindByIdAsync(account.Id, Cancellation));
+ }
+}
+
+public sealed class InMemoryAccountStoreTests : AccountStoreContractTests
+{
+ private protected override ValueTask<IAccountStore> CreateStoreAsync() =>
+ ValueTask.FromResult<IAccountStore>(new InMemoryAccountStore());
+}
+
+public sealed class PostgresAccountStoreTests : AccountStoreContractTests, IAsyncDisposable
+{
+ private PostgresTestDatabase? database;
+ private NpgsqlDataSource? dataSource;
+
+ private protected override async ValueTask<IAccountStore> CreateStoreAsync()
+ {
+ database = await PostgresTestDatabase.CreateAsync();
+ dataSource = database.OpenDataSource();
+ await AccountSchema.ApplyAsync(dataSource, TestContext.Current.CancellationToken);
+ return new PostgresAccountStore(dataSource);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (dataSource is not null)
+ {
+ await dataSource.DisposeAsync();
+ }
+
+ if (database is not null)
+ {
+ await database.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task WhatOneProcessStoredAnotherProcessFinds()
+ {
+ IAccountStore first = await CreateStoreAsync();
+ AccountRecord account = new(
+ Guid.NewGuid(), "Durable", "DURABLE", "durable@example.com", "DURABLE@EXAMPLE.COM", null, "hash",
+ new DateTimeOffset(2026, 10, 6, 9, 0, 0, TimeSpan.Zero));
+ byte[] digest = [.. Enumerable.Repeat((byte)7, 32)];
+ await first.CreateAsync(account, TestContext.Current.CancellationToken);
+ await first.StoreSessionAsync(
+ new SessionRecord(digest, account.Id, account.CreatedAt, account.CreatedAt.AddHours(12)),
+ TestContext.Current.CancellationToken);
+
+ // A second pool of connections stands for the service after a restart: it shares nothing with the first
+ // but the database.
+ await dataSource!.DisposeAsync();
+ dataSource = database!.OpenDataSource();
+ PostgresAccountStore second = new(dataSource);
+
+ Assert.Equal(account, await second.FindByIdAsync(account.Id, TestContext.Current.CancellationToken));
+ SessionRecord session = Assert.IsType<SessionRecord>(
+ await second.FindSessionAsync(digest, TestContext.Current.CancellationToken));
+ Assert.Equal(account.Id, session.AccountId);
+ Assert.Equal(
+ CreateAccountStatus.AccountNameUnavailable,
+ (await second.CreateAsync(account with { Id = Guid.NewGuid(), Email = "x@example.com", NormalizedEmail = "X@EXAMPLE.COM" },
+ TestContext.Current.CancellationToken)).Status);
+ }
+
+ [Fact]
+ public async Task TheSchemaIsAppliedOnceHoweverOftenAndHoweverConcurrentlyItIsAsked()
+ {
+ await using PostgresTestDatabase fresh = await PostgresTestDatabase.CreateAsync();
+ await using NpgsqlDataSource source = fresh.OpenDataSource();
+
+ int[] taken = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ =>
+ Task.Run(() => AccountSchema.ApplyAsync(source, TestContext.Current.CancellationToken))));
+
+ // Processes that start together wait for one another; one of them takes every step and the rest none.
+ Assert.Equal(AccountSchema.CurrentVersion, taken.Sum());
+ Assert.Equal(0, await AccountSchema.ApplyAsync(source, TestContext.Current.CancellationToken));
+ await using NpgsqlCommand versions = source.CreateCommand("SELECT count(*), max(version) FROM account_schema_versions");
+ await using NpgsqlDataReader reader = await versions.ExecuteReaderAsync(TestContext.Current.CancellationToken);
+ await reader.ReadAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(AccountSchema.CurrentVersion, reader.GetInt64(0));
+ Assert.Equal(AccountSchema.CurrentVersion, reader.GetInt32(1));
+ }
+
+ [Fact]
+ public async Task ADatabaseNewerThanTheProgramIsRefusedAndLeftAsItIs()
+ {
+ await using PostgresTestDatabase fresh = await PostgresTestDatabase.CreateAsync();
+ await using NpgsqlDataSource source = fresh.OpenDataSource();
+ await AccountSchema.ApplyAsync(source, TestContext.Current.CancellationToken);
+ await using (NpgsqlCommand future = source.CreateCommand("INSERT INTO account_schema_versions (version) VALUES ($1)"))
+ {
+ future.Parameters.AddWithValue(AccountSchema.CurrentVersion + 1);
+ await future.ExecuteNonQueryAsync(TestContext.Current.CancellationToken);
+ }
+
+ InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(
+ () => AccountSchema.ApplyAsync(source, TestContext.Current.CancellationToken));
+
+ Assert.Contains("schema version", refused.Message, StringComparison.Ordinal);
+ await using NpgsqlCommand count = source.CreateCommand("SELECT count(*) FROM accounts");
+ Assert.Equal(0L, await count.ExecuteScalarAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void AMomentIsCutToTheMicrosecondInUtc()
+ {
+ DateTimeOffset local = new DateTimeOffset(2026, 10, 6, 15, 30, 0, TimeSpan.FromHours(3)).AddTicks(1234567);
+
+ DateTimeOffset stored = PostgresAccountStore.ToStoredPrecision(local);
+
+ Assert.Equal(TimeSpan.Zero, stored.Offset);
+ Assert.Equal(new DateTimeOffset(2026, 10, 6, 12, 30, 0, TimeSpan.Zero).AddTicks(1234560), stored);
+ Assert.Equal(stored, PostgresAccountStore.ToStoredPrecision(stored));
+ }
+}
diff --git a/apps/api-tests/AccountStoreSelectionTests.cs b/apps/api-tests/AccountStoreSelectionTests.cs
new file mode 100644
index 0000000..e9724c0
--- /dev/null
+++ b/apps/api-tests/AccountStoreSelectionTests.cs
@@ -0,0 +1,273 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Progmasoft.Progmaweb.Api.Accounts;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+public sealed class AccountStoreSelectionTests
+{
+ private const string Database = "Host=/var/run/postgresql;Database=progmaweb;Username=progmaweb";
+
+ private static IConfiguration Configuration(string? store, string? connectionString) =>
+ new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary<string, string?>
+ {
+ [AccountStoreRegistration.StoreKey] = store,
+ ["ConnectionStrings:Accounts"] = connectionString
+ })
+ .Build();
+
+ [Theory]
+ [InlineData(null, Database, false, AccountStoreKind.PostgreSql)]
+ [InlineData(null, Database, true, AccountStoreKind.PostgreSql)]
+ [InlineData("PostgreSQL", Database, true, AccountStoreKind.PostgreSql)]
+ [InlineData("postgresql", Database, false, AccountStoreKind.PostgreSql)]
+ [InlineData(null, null, false, AccountStoreKind.Memory)]
+ [InlineData("", " ", false, AccountStoreKind.Memory)]
+ [InlineData("Memory", null, false, AccountStoreKind.Memory)]
+ [InlineData("memory", null, true, AccountStoreKind.Memory)]
+ internal void TheConfigurationSelectsTheStore(
+ string? store,
+ string? connectionString,
+ bool isProduction,
+ AccountStoreKind expected)
+ {
+ Assert.Equal(expected, AccountStoreRegistration.Select(Configuration(store, connectionString), isProduction));
+ }
+
+ [Theory]
+ // Production never falls back to memory on its own.
+ [InlineData(null, null, true, "No account database is configured")]
+ [InlineData("", "", true, "No account database is configured")]
+ [InlineData("PostgreSQL", null, false, "is not set")]
+ [InlineData("Memory", Database, false, "Remove one of them")]
+ [InlineData("Sqlite", null, false, "must be PostgreSQL or Memory")]
+ public void AConfigurationThatCannotBeMeantIsRefused(
+ string? store,
+ string? connectionString,
+ bool isProduction,
+ string expectedMessage)
+ {
+ InvalidOperationException refused = Assert.Throws<InvalidOperationException>(
+ () => AccountStoreRegistration.Select(Configuration(store, connectionString), isProduction));
+
+ Assert.Contains(expectedMessage, refused.Message, StringComparison.Ordinal);
+ // A message about the configuration never repeats the connection string.
+ Assert.DoesNotContain("Host=", refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AProductionServiceWithoutADatabaseDoesNotStart()
+ {
+ using AccountApiFactory factory = new(environment: "Production");
+
+ InvalidOperationException refused = Assert.Throws<InvalidOperationException>(() => factory.CreateAccountClient());
+
+ Assert.Contains("No account database is configured", refused.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task AProductionServiceThatAsksForMemoryStartsAndKeepsAccountsInMemory()
+ {
+ await using AccountApiFactory factory = new(environment: "Production", accountStore: "Memory");
+ using HttpClient client = factory.CreateAccountClient();
+
+ HttpResponseMessage health = await client.GetAsync("/health", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, health.StatusCode);
+ Assert.IsType<InMemoryAccountStore>(factory.Services.GetRequiredService<IAccountStore>());
+ }
+
+ [Fact]
+ public async Task TheTestServiceKeepsAccountsInMemoryWithoutBeingAsked()
+ {
+ await using AccountApiFactory factory = new();
+ using HttpClient client = factory.CreateAccountClient();
+ await client.GetAsync("/health", TestContext.Current.CancellationToken);
+
+ Assert.IsType<InMemoryAccountStore>(factory.Services.GetRequiredService<IAccountStore>());
+ }
+}
+
+public sealed class ExpiredSessionCleanupTests
+{
+ private sealed class FixedClock(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ }
+
+ private sealed class FailingStore : IAccountStore
+ {
+ public int Attempts { get; private set; }
+
+ public ValueTask<int> RemoveExpiredSessionsAsync(DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ Attempts++;
+ throw new IOException("The database does not answer.");
+ }
+
+ public ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask<AccountRecord?> FindByGoogleSubjectAsync(string googleSubject, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+
+ public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken) =>
+ throw new NotSupportedException();
+ }
+
+ [Fact]
+ public async Task ARemovalTakesWhatHasExpiredByTheClock()
+ {
+ DateTimeOffset now = new(2026, 10, 6, 12, 0, 0, TimeSpan.Zero);
+ InMemoryAccountStore store = new();
+ AccountRecord account = new(Guid.NewGuid(), "Owner", "OWNER", "o@example.com", "O@EXAMPLE.COM", null, "hash", now);
+ await store.CreateAsync(account, TestContext.Current.CancellationToken);
+ byte[] expired = [.. Enumerable.Repeat((byte)1, 32)];
+ byte[] valid = [.. Enumerable.Repeat((byte)2, 32)];
+ await store.StoreSessionAsync(new SessionRecord(expired, account.Id, now.AddHours(-13), now.AddHours(-1)), TestContext.Current.CancellationToken);
+ await store.StoreSessionAsync(new SessionRecord(valid, account.Id, now, now.AddHours(12)), TestContext.Current.CancellationToken);
+ using ExpiredSessionCleanup cleanup = new(store, new FixedClock(now), NullLogger<ExpiredSessionCleanup>.Instance);
+
+ await cleanup.RemoveOnceAsync(TestContext.Current.CancellationToken);
+
+ Assert.Null(await store.FindSessionAsync(expired, TestContext.Current.CancellationToken));
+ Assert.NotNull(await store.FindSessionAsync(valid, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ARemovalThatFailsDoesNotStopTheService()
+ {
+ FailingStore store = new();
+ using ExpiredSessionCleanup cleanup = new(store, TimeProvider.System, NullLogger<ExpiredSessionCleanup>.Instance);
+
+ await cleanup.RemoveOnceAsync(TestContext.Current.CancellationToken);
+ await cleanup.RemoveOnceAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, store.Attempts);
+ }
+
+ [Fact]
+ public async Task ACancelledRemovalIsNotSwallowed()
+ {
+ InMemoryAccountStore store = new();
+ using ExpiredSessionCleanup cleanup = new(store, TimeProvider.System, NullLogger<ExpiredSessionCleanup>.Instance);
+ using CancellationTokenSource cancelled = new();
+ await cancelled.CancelAsync();
+
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cleanup.RemoveOnceAsync(cancelled.Token));
+ }
+}
+
+public sealed class DurableAccountApiTests
+{
+ private static async Task<string> SessionCookieAsync(HttpResponseMessage response)
+ {
+ Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+ string header = Assert.Single(
+ response.Headers.GetValues("Set-Cookie"),
+ value => value.StartsWith("__Host-ProgmasoftSession=", StringComparison.Ordinal));
+ return header[..header.IndexOf(';', StringComparison.Ordinal)];
+ }
+
+ private static HttpRequestMessage Me(string cookie)
+ {
+ HttpRequestMessage request = new(HttpMethod.Get, "/api/v1/accounts/me");
+ request.Headers.Add("Cookie", cookie);
+ return request;
+ }
+
+ [Fact]
+ public async Task AnAccountAndItsSessionSurviveARestartOfTheService()
+ {
+ await using PostgresTestDatabase database = await PostgresTestDatabase.CreateAsync();
+ string cookie;
+
+ await using (AccountApiFactory beforeRestart = new(accountDatabase: database.ConnectionString))
+ {
+ using HttpClient client = beforeRestart.CreateAccountClient(handleCookies: false);
+ HttpResponseMessage registration = await client.PostAsJsonAsync("/api/v1/accounts/register", new
+ {
+ accountName = "Survivor",
+ email = "survivor@example.com",
+ password = "a-long-development-password"
+ }, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, registration.StatusCode);
+ cookie = await SessionCookieAsync(registration);
+ Assert.IsType<PostgresAccountStore>(beforeRestart.Services.GetRequiredService<IAccountStore>());
+ }
+
+ // The first service is gone with everything it held in memory. The second starts on the same database.
+ await using AccountApiFactory afterRestart = new(accountDatabase: database.ConnectionString);
+ using HttpClient restarted = afterRestart.CreateAccountClient(handleCookies: false);
+
+ HttpResponseMessage me = await restarted.SendAsync(Me(cookie), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, me.StatusCode);
+ AccountResponse account = Assert.IsType<AccountResponse>(
+ await me.Content.ReadFromJsonAsync<AccountResponse>(TestContext.Current.CancellationToken));
+ Assert.Equal("Survivor", account.AccountName);
+ Assert.Equal("survivor@example.com", account.Email);
+
+ HttpResponseMessage login = await restarted.PostAsJsonAsync("/api/v1/accounts/login", new
+ {
+ email = "survivor@example.com",
+ password = "a-long-development-password"
+ }, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, login.StatusCode);
+
+ HttpResponseMessage again = await restarted.PostAsJsonAsync("/api/v1/accounts/register", new
+ {
+ accountName = "survivor",
+ email = "other@example.com",
+ password = "a-long-development-password"
+ }, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
+
+ // Signing out removes the session from the database, not only from this process.
+ HttpRequestMessage logout = new(HttpMethod.Post, "/api/v1/accounts/logout");
+ logout.Headers.Add("Cookie", cookie);
+ await restarted.SendAsync(logout, TestContext.Current.CancellationToken);
+ HttpResponseMessage afterLogout = await restarted.SendAsync(Me(cookie), TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Unauthorized, afterLogout.StatusCode);
+ }
+
+ [Fact]
+ public async Task TheHealthEndpointAnswersWhileTheDatabaseDoes()
+ {
+ await using PostgresTestDatabase database = await PostgresTestDatabase.CreateAsync();
+ await using AccountApiFactory factory = new(accountDatabase: database.ConnectionString, environment: "Production");
+ using HttpClient client = factory.CreateAccountClient();
+
+ HttpResponseMessage health = await client.GetAsync("/health", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, health.StatusCode);
+ }
+
+ [Fact]
+ public void AServiceWhoseDatabaseCannotBeReachedDoesNotStart()
+ {
+ // Nothing listens on this port of the loopback address; the short timeout keeps the test quick.
+ using AccountApiFactory factory = new(
+ accountDatabase: "Host=127.0.0.1;Port=1;Database=absent;Username=nobody;Timeout=2;Command Timeout=2");
+
+ Assert.ThrowsAny<Exception>(() => factory.CreateAccountClient());
+ }
+}
diff --git a/apps/api-tests/PostgresTestDatabase.cs b/apps/api-tests/PostgresTestDatabase.cs
new file mode 100644
index 0000000..401a69c
--- /dev/null
+++ b/apps/api-tests/PostgresTestDatabase.cs
@@ -0,0 +1,87 @@
+// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
+// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
+
+using Npgsql;
+
+namespace Progmasoft.Progmaweb.Api.Tests;
+
+/// <summary>A schema of its own in the PostgreSQL database of the test run, removed when the test ends.</summary>
+/// <remarks>
+/// <para>
+/// The tests that need PostgreSQL read its connection string from the environment variable
+/// <see cref="VariableName"/>. The user it names must be allowed to create schemas in its database. Without the
+/// variable those tests are skipped, and <see cref="PostgresConfigurationTests"/> fails the run where skipping
+/// them would hide a gap: in continuous integration.
+/// </para>
+/// <para>
+/// Each test works in a schema with a random name, selected through the search path of its connection string, so
+/// tests neither see nor wait for one another and nothing is left behind.
+/// </para>
+/// </remarks>
+internal sealed class PostgresTestDatabase : IAsyncDisposable
+{
+