using System.Net.Http.Headers;
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();
}
/// 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";
}