Files
DodoSSH/tests/DodoSSH.Api.Tests/ApiFixture.cs
T
jaap-jan 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +02:00

150 lines
5.9 KiB
C#

using System.Net.Http.Headers;
using System.Net.WebSockets;
using DodoSSH.Contracts;
using DodoSSH.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using Xunit;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Hosts the API in-process against a real PostgreSQL container and a stubbed identity provider.
/// </summary>
/// <remarks>
/// One container and one host per assembly. Tests therefore use distinct users and vaults rather
/// than assuming an empty database.
/// </remarks>
public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine")
.WithDatabase("dodossh")
.WithUsername("postgres")
.WithPassword("test")
.Build();
/// <summary>The stubbed identity provider.</summary>
public StubIdentityProvider IdentityProvider { get; } = new();
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
await container.StartAsync();
await using var scope = Services.CreateAsyncScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
await database.Database.MigrateAsync();
}
/// <inheritdoc />
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await container.DisposeAsync();
IdentityProvider.Dispose();
}
/// <inheritdoc />
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.UseEnvironment("Testing");
// Environment variables rather than ConfigureAppConfiguration. Under minimal hosting the
// application's own configuration sources are added inside Program.cs after this callback
// runs, so an appsettings.json value would win over anything registered here. Program.cs
// adds AddEnvironmentVariables("DODOSSH_") last, which makes these authoritative.
var settings = new Dictionary<string, string?>(StringComparer.Ordinal)
{
["DODOSSH_ConnectionStrings__Postgres"] = container.GetConnectionString(),
["DODOSSH_Server__PublicBaseUrl"] = "http://localhost",
["DODOSSH_Oidc__Authority"] = IdentityProvider.Authority,
["DODOSSH_Oidc__Audience"] = StubIdentityProvider.Audience,
["DODOSSH_Oidc__ClientId"] = "dodossh-desktop",
// The stub serves plaintext HTTP on a loopback port.
["DODOSSH_Oidc__RequireHttpsMetadata"] = "false",
["DODOSSH_Relay__Enabled"] = "false",
// Fixed so cursors stay valid for the lifetime of the test host.
["DODOSSH_Sync__CursorSigningKey"] = Convert.ToBase64String(new byte[32]),
};
foreach (var (name, value) in settings)
{
Environment.SetEnvironmentVariable(name, value);
}
}
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
/// <remarks>
/// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
/// a test could present the one shape the server refused — an address the provider had not vouched
/// for, offered against a pending team invitation. Nothing in the server reads
/// <c>email_verified</c> now, because nothing decides access from an address at all, so a parameter
/// here would be one that changes no outcome.
/// </remarks>
public HttpClient CreateClientFor(string subject, string? email = null)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IdentityProvider.MintToken(subject, email));
return client;
}
/// <summary>Creates a client carrying the supplied raw token.</summary>
public HttpClient CreateClientWithToken(string token)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
/// <summary>Opens a database scope for arranging state and asserting on it.</summary>
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
/// <summary>
/// Opens the event socket as the given subject, through the real pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The bearer token goes on the upgrade request, which is the whole of the socket's authorization
/// — see ADR 0012 — so a test that stubbed it would be testing nothing. The subprotocol is offered
/// because the server refuses an upgrade that does not, and that refusal is itself under test.
/// </para>
/// <para>
/// <c>TestServer</c> speaks WebSockets in-memory with no port and no network, so these run
/// wherever the rest of the suite does.
/// </para>
/// </remarks>
public Task<WebSocket> ConnectEventsAsync(string subject, CancellationToken cancellationToken)
{
var token = IdentityProvider.MintToken(subject);
var client = Server.CreateWebSocketClient();
client.SubProtocols.Add(VaultEvents.SubProtocol);
// The server-side request, so the header is a raw string rather than a typed value.
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {token}";
return client.ConnectAsync(
new Uri(Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
cancellationToken);
}
}
/// <summary>Shares one host and container across every test class in the assembly.</summary>
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiFixture>
{
/// <summary>Collection name.</summary>
public const string Name = "api";
}