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;
///
/// Hosts the API in-process against a real PostgreSQL container and a stubbed identity provider.
///
///
/// One container and one host per assembly. Tests therefore use distinct users and vaults rather
/// than assuming an empty database.
///
public sealed class ApiFixture : WebApplicationFactory, IAsyncLifetime
{
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine")
.WithDatabase("dodossh")
.WithUsername("postgres")
.WithPassword("test")
.Build();
/// The stubbed identity provider.
public StubIdentityProvider IdentityProvider { get; } = new();
///
public async ValueTask InitializeAsync()
{
await container.StartAsync();
await using var scope = Services.CreateAsyncScope();
var database = scope.ServiceProvider.GetRequiredService();
await database.Database.MigrateAsync();
}
///
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await container.DisposeAsync();
IdentityProvider.Dispose();
}
///
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(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);
}
}
/// Creates a client carrying a valid token for the given subject.
///
/// defaults to true, which is what a provider asserts about an
/// address it has checked and what every ordinary sign-in means. Passing false mints a token that
/// carries the address and no email_verified claim — the shape a team invitation has to
/// refuse, and the only way a test can present it.
///
public HttpClient CreateClientFor(string subject, string? email = null, bool emailVerified = true)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IdentityProvider.MintToken(subject, email, emailVerified: emailVerified));
return client;
}
/// Creates a client carrying the supplied raw token.
public HttpClient CreateClientWithToken(string token)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
/// Opens a database scope for arranging state and asserting on it.
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
///
/// Opens the event socket as the given subject, through the real pipeline.
///
///
///
/// 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.
///
///
/// TestServer speaks WebSockets in-memory with no port and no network, so these run
/// wherever the rest of the suite does.
///
///
public Task 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);
}
}
/// Shares one host and container across every test class in the assembly.
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture
{
/// Collection name.
public const string Name = "api";
}