Make the end-to-end suite self-contained with Testcontainers

It needed a hand-started stack and an opt-in flag, so it ran on one machine
and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server
itself, applies the committed migrations and starts the API as a child
process, which makes it part of the ordinary test run at ~25s.

The API runs as a process rather than through WebApplicationFactory. The
client builds its own HttpClient for a URL the user typed, so there is no
seam to hand a test handler through without inventing one that exists only
for tests — and a test host would replace the entry point, Kestrel and the
content root, so it would never prove that Program.cs composes or that the
committed appsettings is found and layered in the documented order. Running
out of the API's own output directory is what makes its configuration real.

The suite still consumes what ships: the realm file from deploy/keycloak,
the EF migrations, the API's own appsettings. Only Oidc:Authority is
overridden, because the container's port is assigned at start. Falsified by
reintroducing the wildcard-port redirect URI the realm once had — Keycloak
rejects the authorization request and the suite fails at sign-in, which is
what proves the committed file is the one imported. Skipping the migration
step likewise fails, and the failure names the pending migration.

A fresh Keycloak per run also sidesteps the --import-realm trap: editing the
realm file and rerunning now always tests the edit.

DodoDbContextFactory gains a Create(connectionString) so the fixture and
dotnet ef place the migrations history table in exactly one place. If they
disagreed the API would report every migration pending, which is how the
readiness gate catches it.
This commit is contained in:
2026-07-29 12:09:15 +02:00
parent 1d262b7ccc
commit 34304b989b
9 changed files with 656 additions and 190 deletions
+5 -2
View File
@@ -48,8 +48,11 @@ jobs:
- name: test - name: test
run: dotnet test DodoSSH.slnx --no-build --configuration Release run: dotnet test DodoSSH.slnx --no-build --configuration Release
# Integration tests land in M1 and need Docker for Testcontainers (PostgreSQL, # This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
# OpenSSH). They run on this ubuntu job because macOS runners have no Docker daemon. # server through Testcontainers and runs the API as a child process — so it needs a
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
# macOS, whose runners have no daemon at all. Expect the Keycloak image pull to
# dominate a cold run.
build-windows: build-windows:
name: build (windows) name: build (windows)
+20 -20
View File
@@ -74,6 +74,10 @@ dotnet build DodoSSH.slnx
dotnet test DodoSSH.slnx dotnet test DodoSSH.slnx
``` ```
The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
nothing to clean up after — but with no daemon those suites fail rather than skip.
Run the API locally: Run the API locally:
```bash ```bash
@@ -85,32 +89,28 @@ Development — `/openapi/v1.json`.
### End-to-end verification ### End-to-end verification
One suite runs against a real server rather than a stub, and it is opt-in because it needs the stack: One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it
is part of the ordinary test run:
```bash ```bash
docker compose -f deploy/docker-compose.dev.yml up -d dotnet test tests/DodoSSH.SystemTests
``` ```
```bash It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations,
dotnet ef database update --project src/DodoSSH.Infrastructure starts the API as a child process out of its own build output, and then drives the real client: sign in
``` through Keycloak, enroll, unlock, create a host, sync it, read it back on a second simulated machine,
unlock again with no network, and open a shell on the `sshd`. Roughly 25 seconds once the images are
pulled.
```bash What makes it worth its weight is that it consumes the artefacts that ship — the realm file from
dotnet run --project src/DodoSSH.Api `deploy/keycloak`, the EF migrations, the API's own `appsettings` — rather than a fixture written to match
``` them. On its first run it found a loopback redirect URI the realm registered in a form Keycloak rejects,
and a JSON configuration gap that made the whole sync surface unreachable from the real client while every
other test passed. Both are the same class of bug: two sides of a stub agreeing with each other about
something the specification never said.
```bash The one value it cannot take from a committed file is `Oidc:Authority`, since the container's port is
DODOSSH_E2E=1 dotnet test tests/DodoSSH.SystemTests assigned at start. Everything that authority points at is still the real realm.
```
It signs in through a real Keycloak, enrolls, unlocks, creates a host, syncs it, reads it back on a second
simulated machine, unlocks again with no network, and opens a shell on a real `sshd`. Skipped otherwise,
with a message naming the commands above.
It is worth its weight: on its first run it found a loopback redirect URI the realm registered in a form
Keycloak rejects, and a JSON configuration gap that made the whole sync surface unreachable from the real
client while every other test passed. Both are the same class of bug — two sides of a stub agreeing with
each other about something the specification never said.
Development and testing are currently **Windows-only**. Anything known or suspected to differ on Development and testing are currently **Windows-only**. Anything known or suspected to differ on
Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md), along with the Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md), along with the
+16
View File
@@ -137,6 +137,10 @@ database inside the container, so the realm has to be recreated along with it:
`docker compose rm -sf keycloak && docker compose up -d keycloak`. Cost an otherwise inexplicable `docker compose rm -sf keycloak && docker compose up -d keycloak`. Cost an otherwise inexplicable
debugging detour. debugging detour.
`DodoSSH.SystemTests` is immune to this by construction — its Keycloak is created and destroyed per run —
which is a second reason the end-to-end suite starts its own containers rather than reusing the developer's
stack. Editing the realm file and rerunning the suite always tests the edit.
## Local cache ## Local cache
**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it: **The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it:
@@ -189,6 +193,18 @@ macOS runners have no Docker daemon, and the Windows CI job is deliberately buil
anything proved by an integration test is proved on Linux only — which is the right place for anything proved by an integration test is proved on Linux only — which is the right place for
server code, and no coverage at all for client platform behaviour. server code, and no coverage at all for client platform behaviour.
**The end-to-end suite launches the API's own launcher executable**, falling back to `dotnet exec` on the
assembly. The fallback exists for one reason: a checkout or artefact copy that lost the execute bit
produces a `Win32Exception` on Linux and nothing whatsoever on Windows. *Verified on Windows only* — the
launcher path is what runs here, so the fallback itself is reasoned rather than exercised. If the suite
fails in CI with a permission error before any container work, that is the path to look at.
**It also depends on `Server:PublicBaseUrl` being knowable before startup.** The port is chosen by binding
a loopback socket and releasing it, because the API reads that URL at startup and advertises it to clients,
so it cannot be discovered from Kestrel afterwards. The window for another process to take the port is a
few milliseconds; if the suite ever fails with an address-in-use, this is why, and a retry is the fix
rather than a redesign.
**`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that **`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that
locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output
directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the
@@ -7,18 +7,36 @@ namespace DodoSSH.Infrastructure;
/// Builds a context for <c>dotnet ef</c> at design time. /// Builds a context for <c>dotnet ef</c> at design time.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Deliberately independent of the API host: generating a migration should not require the web /// Deliberately independent of the API host: generating or applying a migration should not require the web
/// application to start, nor a reachable database. The connection string here is never used to /// application to start. The API never migrates in production — it fails readiness instead — so this is the
/// connect — only to select the provider so the model can be built. /// one place that knows how to open the database without a web host, and the end-to-end suite uses it for
/// the same reason <c>dotnet ef</c> does.
/// </remarks> /// </remarks>
public sealed class DodoDbContextFactory : IDesignTimeDbContextFactory<DodoDbContext> public sealed class DodoDbContextFactory : IDesignTimeDbContextFactory<DodoDbContext>
{ {
/// <inheritdoc /> /// <summary>Environment variable naming the database to work against.</summary>
public DodoDbContext CreateDbContext(string[] args) /// <remarks>
{ /// Only <c>database update</c> and <c>dbcontext script</c> actually connect. Generating a migration
var connectionString = Environment.GetEnvironmentVariable("DODOSSH_DESIGN_CONNECTION") /// needs a provider but not a reachable server, which is why there is a usable default at all.
?? "Host=localhost;Database=dodossh_design;Username=postgres"; /// </remarks>
public const string ConnectionVariable = "DODOSSH_DESIGN_CONNECTION";
/// <inheritdoc />
public DodoDbContext CreateDbContext(string[] args) =>
Create(Environment.GetEnvironmentVariable(ConnectionVariable)
?? "Host=localhost;Database=dodossh_design;Username=postgres");
/// <summary>
/// Builds a context for one database, configured as the API configures its own.
/// </summary>
/// <param name="connectionString">Where to connect.</param>
/// <remarks>
/// The migrations history table has to be placed exactly where <c>AddDodoPersistence</c> looks for it.
/// Defined here once so that a schema applied outside the API is a schema the API agrees is current,
/// rather than one it reports as entirely pending.
/// </remarks>
public static DodoDbContext Create(string connectionString)
{
var options = new DbContextOptionsBuilder<DodoDbContext>() var options = new DbContextOptionsBuilder<DodoDbContext>()
.UseNpgsql(connectionString, npgsql => .UseNpgsql(connectionString, npgsql =>
npgsql.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)) npgsql.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName))
+302
View File
@@ -0,0 +1,302 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Text;
namespace DodoSSH.SystemTests;
/// <summary>
/// The DodoSSH API, running as a child process for the length of a test run.
/// </summary>
/// <remarks>
/// <para>
/// A process rather than a <c>WebApplicationFactory</c>, which is what the API's own integration suite
/// uses. Two reasons, and both are the point of this suite. The client establishes its connection by
/// creating an <c>HttpClient</c> for a URL the user typed, so there is no seam to hand a test handler
/// through without inventing one that exists only for tests. And a test host replaces the entry point,
/// Kestrel and the content root — so it never proves that <c>Program.cs</c> composes, that the committed
/// <c>appsettings.json</c> is found and layered in the documented order, or that the server answers on a
/// socket.
/// </para>
/// <para>
/// It runs out of the API's own output directory, which is what makes its configuration real: the working
/// directory becomes the content root, so the <c>appsettings.json</c> beside the assembly is the one that
/// ships. Only the values that cannot be known before the containers start are overridden, through the
/// <c>DODOSSH_</c> environment prefix that <c>Program.cs</c> adds last and which therefore wins.
/// </para>
/// </remarks>
internal sealed class ApiProcess : IAsyncDisposable
{
/// <summary>Assembly metadata written by the <c>DodoCaptureApiPath</c> target in this csproj.</summary>
private const string ApiPathKey = "DodoSSH.ApiAssemblyPath";
private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(90);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
private readonly Process process;
private readonly StringBuilder log = new();
private ApiProcess(Process process, Uri baseUrl)
{
this.process = process;
BaseUrl = baseUrl;
process.OutputDataReceived += Capture;
process.ErrorDataReceived += Capture;
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
/// <summary>Where the API is listening.</summary>
internal Uri BaseUrl { get; }
/// <summary>Everything the API has written to its console.</summary>
/// <remarks>
/// Kept so a failure can be explained. A child process that dies during startup is otherwise a
/// connection refused with no cause, which is a genuinely miserable thing to debug.
/// </remarks>
internal string Log
{
get
{
lock (log)
{
return log.ToString();
}
}
}
/// <summary>Starts the API against the given database and identity provider, and waits for readiness.</summary>
internal static async Task<ApiProcess> StartAsync(
string connectionString,
Uri authority,
CancellationToken cancellationToken)
{
var baseUrl = new Uri(
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{FreeLoopbackPort()}"));
var api = new ApiProcess(Launch(baseUrl, connectionString, authority), baseUrl);
try
{
await api.WaitUntilReadyAsync(cancellationToken);
return api;
}
catch
{
await api.DisposeAsync();
throw;
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
try
{
if (!process.HasExited)
{
// No graceful shutdown: Ctrl+C cannot be delivered to a specific child on Windows, and
// the drain this would exercise belongs to the relay, which M1 does not ship.
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// Exited between the check and the kill.
}
await process.WaitForExitAsync(CancellationToken.None);
process.Dispose();
}
private static Process Launch(Uri baseUrl, string connectionString, Uri authority)
{
var assembly = ResolveApiAssembly();
var launcher = AppHost(assembly);
var startInfo = new ProcessStartInfo
{
FileName = launcher ?? "dotnet",
// The content root: WebApplication.CreateBuilder takes the working directory, and the
// appsettings files live beside the assembly.
WorkingDirectory = Path.GetDirectoryName(assembly)!,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
if (launcher is null)
{
startInfo.ArgumentList.Add("exec");
startInfo.ArgumentList.Add(assembly);
}
// Development, because the committed development configuration is precisely what this suite
// exists to check — the realm file and appsettings.Development.json are a pair, and the first bug
// this suite found lived in exactly that pair.
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
startInfo.Environment["ASPNETCORE_URLS"] = baseUrl.ToString();
startInfo.Environment["DODOSSH_ConnectionStrings__Postgres"] = connectionString;
startInfo.Environment["DODOSSH_Oidc__Authority"] = authority.ToString();
// Advertised to clients in the discovery document, so it has to be the port actually bound
// rather than the 5233 a developer runs on.
startInfo.Environment["DODOSSH_Server__PublicBaseUrl"] = baseUrl.ToString();
startInfo.Environment["DODOSSH_Relay__WebSocketUrl"] =
new UriBuilder(baseUrl) { Scheme = "ws", Path = "/api/v1/relay/connect" }.Uri.ToString();
try
{
return Start(startInfo);
}
catch (Win32Exception) when (launcher is not null)
{
// The launcher is there but would not run. On Linux a checkout or artefact copy that lost the
// execute bit is the usual cause, and the runtime can load the assembly directly regardless —
// so this falls back rather than failing CI on a file mode.
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Insert(0, assembly);
startInfo.ArgumentList.Insert(0, "exec");
return Start(startInfo);
}
}
private static Process Start(ProcessStartInfo startInfo) =>
Process.Start(startInfo)
?? throw new InvalidOperationException(
$"Could not start the API: {startInfo.FileName} produced no process.");
/// <summary>
/// The native launcher beside the assembly, if the build produced one.
/// </summary>
/// <remarks>
/// Preferred over <c>dotnet exec</c> only because it needs nothing on PATH. The fallback is not
/// hypothetical tidiness: a build with <c>UseAppHost=false</c> produces no launcher at all.
/// </remarks>
private static string? AppHost(string assembly)
{
var candidate = Path.ChangeExtension(assembly, OperatingSystem.IsWindows() ? ".exe" : null);
return File.Exists(candidate) ? candidate : null;
}
private static string ResolveApiAssembly()
{
var path = typeof(ApiProcess).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(attribute => string.Equals(attribute.Key, ApiPathKey, StringComparison.Ordinal))
?.Value;
if (string.IsNullOrEmpty(path))
{
throw new InvalidOperationException(
$"The build did not record {ApiPathKey}. The DodoCaptureApiPath target in "
+ "DodoSSH.SystemTests.csproj is what writes it.");
}
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"The API was recorded at {path}, but nothing is there. Build the solution.");
}
return path;
}
/// <remarks>
/// Bound and released rather than left at zero, because the port has to be known before the process
/// starts: it goes into <c>Server:PublicBaseUrl</c>, which the API reads at startup and hands to
/// clients. The window in which another process could take it is a few milliseconds wide, and the
/// alternative — reading the bound port back out of Kestrel's log line — cannot be fed to
/// configuration that has already been read.
/// </remarks>
private static int FreeLoopbackPort()
{
using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
probe.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)probe.LocalEndPoint!).Port;
}
private void Capture(object sender, DataReceivedEventArgs args)
{
if (args.Data is null)
{
return;
}
lock (log)
{
log.AppendLine(args.Data);
}
}
/// <remarks>
/// Readiness rather than liveness, and that distinction is load-bearing: <c>/healthz/ready</c> also
/// asserts the database is reachable and that no migration is pending. Waiting on it means a schema the
/// fixture failed to apply is reported here, against the API's own opinion of it, rather than as an
/// inscrutable 500 in the middle of enrollment.
/// </remarks>
private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(ReadyTimeout);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
var ready = new Uri(BaseUrl, "healthz/ready");
try
{
while (!deadline.IsCancellationRequested && !process.HasExited)
{
if (await RespondsAsync(http, ready, deadline.Token))
{
return;
}
await Task.Delay(PollInterval, deadline.Token);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The deadline, not the caller. Reported below with the API's own output.
}
cancellationToken.ThrowIfCancellationRequested();
throw new InvalidOperationException(
string.Create(
CultureInfo.InvariantCulture,
$"The API did not become ready at {ready} within {ReadyTimeout.TotalSeconds:N0}s. "
+ $"{(process.HasExited ? $"It exited with code {process.ExitCode}." : "It is still running.")}"
+ $" Its output was:{Environment.NewLine}{Log}"));
}
private static async Task<bool> RespondsAsync(HttpClient http, Uri url, CancellationToken token)
{
try
{
using var response = await http.GetAsync(url, token);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
catch (TaskCanceledException)
{
// Either the per-request timeout or the deadline. The loop condition decides which.
return false;
}
}
}
+176 -75
View File
@@ -2,85 +2,194 @@ using System.Globalization;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using DodoSSH.Infrastructure;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Microsoft.EntityFrameworkCore;
using Testcontainers.PostgreSql;
namespace DodoSSH.SystemTests; namespace DodoSSH.SystemTests;
/// <summary> /// <summary>
/// The development stack this suite talks to, and how to find out whether it is there. /// The whole stack the end-to-end suite talks to, brought up per run and thrown away after.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Deliberately targets a stack the developer brought up rather than starting its own containers. That
/// makes the suite test the configuration that is actually committed — the realm file, the API's
/// appsettings, the migration history — instead of a parallel arrangement assembled for the test, which is
/// where a divergence between "works in the suite" and "works when you run it" comes from.
/// <para> /// <para>
/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start. /// PostgreSQL, Keycloak and an OpenSSH server in containers; the API as a child process out of its own
/// build output. Nothing is stubbed and nothing has to be started by hand, which is what allows this suite
/// to run in CI rather than only on the machine of whoever remembered the commands.
/// </para>
/// <para>
/// It deliberately consumes the artefacts that ship — the realm file from <c>deploy/keycloak</c>, the
/// committed EF migrations, the API's own <c>appsettings</c> — rather than an arrangement written to match
/// them. That distinction is the whole value of the suite: a fixture that restates the configuration agrees
/// with itself by construction, and both bugs this suite has found were in the committed configuration,
/// invisible to every other test.
/// </para>
/// <para>
/// One thing cannot come from a committed file: the identity provider's port is assigned when its container
/// starts, so <c>Oidc:Authority</c> is overridden to match. Everything the authority points at — the realm,
/// the public client, the loopback redirect URI, the audience mapper — is still the real file, and the URL
/// is the one value a self-hosted deployment is guaranteed to change anyway.
/// </para>
/// <para>
/// A fresh Keycloak per run also removes a footgun the development stack has: <c>--import-realm</c> skips a
/// realm that already exists, so editing the realm file and restarting the container serves the old
/// configuration and looks exactly like the edit being wrong.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal static class DevStack public sealed class DevStack : IAsyncLifetime
{ {
internal const string ApiBaseUrl = "http://localhost:5233"; /// <summary>The realm the committed file defines.</summary>
internal const string KeycloakBaseUrl = "http://localhost:18080";
internal const string Realm = "dodossh"; internal const string Realm = "dodossh";
private const string OptInVariable = "DODOSSH_E2E"; /// <summary>The account the OpenSSH container is built around.</summary>
internal const string SshUsername = "dodo";
/// <summary>Its password. A container that lives for one test run, on a random loopback port.</summary>
internal const string SshPassword = "correct-horse-battery-staple";
/// <summary>
/// Explains why the suite cannot run, or returns null when it can.
/// </summary>
/// <remarks> /// <remarks>
/// A message rather than a boolean, because a skipped end-to-end suite that does not say what is /// Pinned to the same version as <c>deploy/docker-compose.dev.yml</c>. Two places, deliberately: the
/// missing is a suite nobody ever runs again. /// suite cannot read the compose file without becoming a compose parser, and a version skew between
/// them shows up here as a failure rather than as a difference nobody notices.
/// </remarks> /// </remarks>
internal static async Task<string?> WhyUnavailableAsync(CancellationToken cancellationToken) private const string KeycloakImage = "quay.io/keycloak/keycloak:26.4";
private const string PostgresImage = "postgres:18-alpine";
private const string SshImage = "linuxserver/openssh-server:latest";
private const ushort KeycloakPort = 8080;
private const ushort SshPort = 2222;
/// <remarks>
/// Generous, and covering image pulls on a cold machine. It exists so that a daemon that never answers
/// fails with a sentence rather than hanging until the test host is killed.
/// </remarks>
private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(8);
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder(PostgresImage)
.WithDatabase("dodossh")
.WithUsername("dodossh")
.WithPassword("dodossh")
// Matching the compose stack: deterministic collation, as the server's InvariantGlobalization
// implies. Ordering differences between a C locale and a language-aware one are exactly the kind
// of thing that behaves in development and not in production.
.WithEnvironment("POSTGRES_INITDB_ARGS", "--encoding=UTF8 --locale=C")
.Build();
private readonly IContainer keycloak = new ContainerBuilder(KeycloakImage)
// start-dev, as the development stack does: no HTTPS enforcement and throwaway H2 state. The realm
// is imported from the committed file, which is the artefact under test here.
.WithCommand("start-dev", "--import-realm")
.WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
.WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
.WithPortBinding(KeycloakPort, assignRandomHostPort: true)
.WithResourceMapping(
new FileInfo(Path.Combine(AppContext.BaseDirectory, "realm-dodossh.json")),
"/opt/keycloak/data/import/")
// Serving the realm's discovery document is the only readiness that means anything: the port opens
// well before the import finishes, and an unimported realm answers 404 to every request the client
// is about to make.
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request
.ForPort(KeycloakPort)
.ForPath($"/realms/{Realm}/.well-known/openid-configuration")))
.Build();
private readonly IContainer sshd = new ContainerBuilder(SshImage)
.WithEnvironment("PUID", "1000")
.WithEnvironment("PGID", "1000")
.WithEnvironment("USER_NAME", SshUsername)
.WithEnvironment("USER_PASSWORD", SshPassword)
.WithEnvironment("PASSWORD_ACCESS", "true")
.WithEnvironment("SUDO_ACCESS", "false")
.WithPortBinding(SshPort, assignRandomHostPort: true)
// A published port is not readiness: the entrypoint generates host keys and rewrites sshd_config
// first. This is the check that actually proves sshd is accepting.
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
.Build();
private ApiProcess? api;
/// <summary>Where the API is listening, which is the only URL the client is told.</summary>
internal Uri ApiBaseUrl => Api.BaseUrl;
/// <summary>The identity provider's base URL, port included.</summary>
internal Uri KeycloakBaseUrl => new(string.Create(
CultureInfo.InvariantCulture,
$"http://{keycloak.Hostname}:{keycloak.GetMappedPublicPort(KeycloakPort)}"));
/// <summary>The issuer the API is configured to trust.</summary>
internal Uri Authority => new(KeycloakBaseUrl, $"/realms/{Realm}");
/// <summary>Where the OpenSSH container answers.</summary>
internal string SshHostname => sshd.Hostname;
/// <summary>Its mapped port.</summary>
internal int SshHostPort => sshd.GetMappedPublicPort(SshPort);
/// <summary>The API's console output, for explaining a failure.</summary>
internal string ApiLog => Api.Log;
private ApiProcess Api => api
?? throw new InvalidOperationException("The stack has not been started.");
/// <inheritdoc />
public async ValueTask InitializeAsync()
{ {
if (Environment.GetEnvironmentVariable(OptInVariable) is not "1") using var deadline = new CancellationTokenSource(StartupTimeout);
// Concurrently: none of the three depends on another, and Keycloak alone takes longer than the
// other two together.
await Task.WhenAll(
postgres.StartAsync(deadline.Token),
keycloak.StartAsync(deadline.Token),
sshd.StartAsync(deadline.Token));
await MigrateAsync(deadline.Token);
api = await ApiProcess.StartAsync(postgres.GetConnectionString(), Authority, deadline.Token);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// The API first: it holds connections to PostgreSQL, and tearing the database out from under it
// produces a page of exceptions that look like a failure rather than a shutdown.
if (api is not null)
{ {
return $"Set {OptInVariable}=1 to run the end-to-end suite. It needs the development stack:" await api.DisposeAsync();
+ "\n docker compose -f deploy/docker-compose.dev.yml up -d"
+ "\n dotnet ef database update --project src/DodoSSH.Infrastructure"
+ "\n dotnet run --project src/DodoSSH.Api";
} }
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; await Task.WhenAll(
postgres.DisposeAsync().AsTask(),
if (!await RespondsAsync(http, $"{ApiBaseUrl}/healthz/ready", cancellationToken).ConfigureAwait(false)) keycloak.DisposeAsync().AsTask(),
{ sshd.DisposeAsync().AsTask());
return $"The API is not ready at {ApiBaseUrl}. Start it with "
+ "`dotnet run --project src/DodoSSH.Api`, and check that migrations have been applied — "
+ "readiness fails while any are pending, by design.";
}
var discovery = $"{KeycloakBaseUrl}/realms/{Realm}/.well-known/openid-configuration";
if (!await RespondsAsync(http, discovery, cancellationToken).ConfigureAwait(false))
{
return $"Keycloak is not serving the '{Realm}' realm at {KeycloakBaseUrl}. Bring it up with "
+ "`docker compose -f deploy/docker-compose.dev.yml up -d`.";
}
return null;
} }
/// <summary> /// <summary>
/// Creates a Keycloak user this test run owns. /// Creates a Keycloak user this test run owns.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A fresh account per run, rather than the realm's <c>alice</c>. Enrollment happens once per account /// A fresh account rather than the realm's <c>alice</c>. Enrollment happens once per account and cannot
/// and cannot be undone from the client, so reusing an account would mean the second run exercises a /// be undone from the client, so an account shared between tests would mean the second one exercises a
/// different path from the first and neither could assert an exact vault state. This way every run /// different path from the first and neither could assert an exact vault state. The realm is thrown
/// starts from "no identity key, no vault". /// away with the container, so this only has to be unique within a run — but making it unique per call
/// is what keeps a second test from silently depending on the first.
/// </remarks> /// </remarks>
internal static async Task<DevStackUser> CreateUserAsync(CancellationToken cancellationToken) internal async Task<DevStackUser> CreateUserAsync(CancellationToken cancellationToken)
{ {
var username = string.Create( var username = string.Create(CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}");
CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}");
const string Password = "e2e-password"; const string Password = "e2e-password";
using var http = new HttpClient { BaseAddress = new Uri(KeycloakBaseUrl) }; using var http = new HttpClient { BaseAddress = KeycloakBaseUrl };
var token = await AdminTokenAsync(http, cancellationToken).ConfigureAwait(false); var token = await AdminTokenAsync(http, cancellationToken);
http.DefaultRequestHeaders.Authorization = new("Bearer", token); http.DefaultRequestHeaders.Authorization = new("Bearer", token);
@@ -103,15 +212,28 @@ internal static class DevStack
}, },
}; };
using var response = await http using var response = await http.PostAsJsonAsync(
.PostAsJsonAsync($"/admin/realms/{Realm}/users", user, cancellationToken) $"/admin/realms/{Realm}/users", user, cancellationToken);
.ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return new DevStackUser(username, Password); return new DevStackUser(username, Password);
} }
/// <remarks>
/// Through the same design-time factory <c>dotnet ef database update</c> uses, so the migrations and
/// the history table land exactly where the API expects them. If they did not, the API's readiness
/// check would refuse to pass and <see cref="ApiProcess"/> would say so — the schema is verified by the
/// server's own opinion of it rather than by this fixture's.
/// </remarks>
private async Task MigrateAsync(CancellationToken cancellationToken)
{
var context = DodoDbContextFactory.Create(postgres.GetConnectionString());
await using var scope = context.ConfigureAwait(false);
await context.Database.MigrateAsync(cancellationToken);
}
private static async Task<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken) private static async Task<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
{ {
using var form = new FormUrlEncodedContent( using var form = new FormUrlEncodedContent(
@@ -123,37 +245,16 @@ internal static class DevStack
["grant_type"] = "password", ["grant_type"] = "password",
}); });
using var response = await http using var response = await http.PostAsync(
.PostAsync("/realms/master/protocol/openid-connect/token", form, cancellationToken) "/realms/master/protocol/openid-connect/token", form, cancellationToken);
.ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString() return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString()
?? throw new InvalidOperationException("Keycloak returned no admin access token."); ?? throw new InvalidOperationException("Keycloak returned no admin access token.");
} }
private static async Task<bool> RespondsAsync(
HttpClient http,
string url,
CancellationToken cancellationToken)
{
try
{
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
catch (TaskCanceledException)
{
return false;
}
}
} }
/// <summary>An account created for one test run.</summary> /// <summary>An account created for one test run.</summary>
@@ -7,34 +7,60 @@
Nothing here is stubbed. Every other suite in the repository substitutes something — a stubbed identity Nothing here is stubbed. Every other suite in the repository substitutes something — a stubbed identity
provider, an in-memory vault server, an in-memory cache — and each of those substitutions is a place a provider, an in-memory vault server, an in-memory cache — and each of those substitutions is a place a
misunderstanding of the protocol can hide on both sides at once. This suite exists to catch exactly misunderstanding of the protocol can hide on both sides at once. This suite exists to catch exactly
that, and it already has: the realm file registered a loopback redirect URI that Keycloak rejects, that, and it already has: the realm file registered a loopback redirect URI that Keycloak rejects, and
which no stub would ever have noticed. the API never applied the contract's JSON settings, which put the whole sync surface out of reach of
the real client. No stub would have noticed either.
It needs the development stack up and is skipped otherwise. The commands are in the README under It needs a Docker daemon and nothing else. See DevStack for what it starts and why.
"End-to-end verification"; a skipped run prints them too. (They cannot be repeated here: an XML
comment may not contain a double hyphen, and every one of them has a flag.)
--> -->
<PropertyGroup>
<!--
Exit code 8 is "no tests ran". Microsoft.Testing.Platform reports that as a failure, so an assembly
whose only test skips would fail the solution-wide `dotnet test` on any machine without the stack up.
The minimum-expected-tests option cannot express this: it demands a non-zero integer.
Narrow enough to be safe here: this project contains exactly one test, so if it runs at all it either
passes or fails, and 8 can only mean it skipped for want of the stack.
-->
<TestingPlatformCommandLineArguments>--ignore-exit-code 8</TestingPlatformCommandLineArguments>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" /> <ProjectReference Include="../../src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" /> <ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" /> <ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<!-- Applies the committed migrations to the throwaway database, as `dotnet ef database update` would. -->
<ProjectReference Include="../../src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
<!--
Built but not linked. The API runs as a child process out of its own output directory so that it
reads its own committed appsettings and executes its real entry point; referencing its assembly here
would copy a DLL with no runtime configuration beside it and prove nothing. ReferenceOutputAssembly
keeps the build dependency without the reference.
-->
<ProjectReference Include="../../src/DodoSSH.Api/DodoSSH.Api.csproj" ReferenceOutputAssembly="false" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Testcontainers" /> <PackageReference Include="Testcontainers" />
<PackageReference Include="Testcontainers.PostgreSql" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!--
The realm the deployment ships, copied so the Keycloak container can be handed the real file rather
than a fixture written to match it. A copy rather than a bind mount: Testcontainers hands the bytes
to the daemon, which works the same whether the daemon is local, in a VM, or remote.
-->
<Content Include="../../deploy/keycloak/realm-dodossh.json"
Link="realm-dodossh.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!--
Where the API's own build put it, asked of the API project rather than assembled from the bin layout.
A changed output path then moves this with it instead of failing at run time with a path nobody wrote.
-->
<Target Name="DodoCaptureApiPath" BeforeTargets="GetAssemblyAttributes">
<MSBuild Projects="../../src/DodoSSH.Api/DodoSSH.Api.csproj"
Targets="GetTargetPath"
Properties="Configuration=$(Configuration);TargetFramework=$(TargetFramework)">
<Output TaskParameter="TargetOutputs" ItemName="DodoApiTarget" />
</MSBuild>
<ItemGroup>
<AssemblyMetadata Include="DodoSSH.ApiAssemblyPath" Value="@(DodoApiTarget)" />
</ItemGroup>
</Target>
</Project> </Project>
@@ -5,8 +5,6 @@ using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Crypto; using DodoSSH.Crypto;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
namespace DodoSSH.SystemTests; namespace DodoSSH.SystemTests;
@@ -28,12 +26,9 @@ namespace DodoSSH.SystemTests;
/// between tests or repeating a minute of setup per assertion. /// between tests or repeating a minute of setup per assertion.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class M1VerticalSliceTests : IAsyncLifetime public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
{ {
private const string Passphrase = "an end to end passphrase"; private const string Passphrase = "an end to end passphrase";
private const string SshUsername = "dodo";
private const string SshPassword = "correct-horse-battery-staple";
private const int SshPort = 2222;
/// <remarks> /// <remarks>
/// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other /// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
@@ -46,68 +41,25 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
private readonly List<string> directories = []; private readonly List<string> directories = [];
private string? unavailable;
private IContainer? sshd;
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask InitializeAsync() public ValueTask DisposeAsync()
{ {
unavailable = await DevStack.WhyUnavailableAsync(TestContext.Current.CancellationToken);
if (unavailable is not null)
{
// No container either. Paying half a minute of startup for a suite about to skip is how an
// opt-in suite becomes one nobody opts into.
return;
}
sshd = new ContainerBuilder("linuxserver/openssh-server:latest")
.WithEnvironment("PUID", "1000")
.WithEnvironment("PGID", "1000")
.WithEnvironment("USER_NAME", SshUsername)
.WithEnvironment("USER_PASSWORD", SshPassword)
.WithEnvironment("PASSWORD_ACCESS", "true")
.WithEnvironment("SUDO_ACCESS", "false")
.WithPortBinding(SshPort, assignRandomHostPort: true)
// A published port is not readiness: the entrypoint generates host keys and rewrites
// sshd_config first. Both conditions are needed, and the port check is the one that actually
// proves sshd is accepting.
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
.Build();
await sshd.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (sshd is not null)
{
await sshd.DisposeAsync();
}
foreach (var directory in directories.Where(Directory.Exists)) foreach (var directory in directories.Where(Directory.Exists))
{ {
Directory.Delete(directory, recursive: true); Directory.Delete(directory, recursive: true);
} }
return ValueTask.CompletedTask;
} }
[Fact] [Fact]
public async Task TheWholeSlice() public async Task TheWholeSlice()
{ {
if (unavailable is not null) var account = await stack.CreateUserAsync(Token);
{
Assert.Skip(unavailable);
}
var account = await DevStack.CreateUserAsync(Token);
var browser = new ScriptedBrowser(account.Username, account.Password); var browser = new ScriptedBrowser(account.Username, account.Password);
using var connection = await ServerConnection using var connection = await ServerConnection
.SignInAsync(new Uri(DevStack.ApiBaseUrl), browser, TimeProvider.System, Token); .SignInAsync(stack.ApiBaseUrl, browser, TimeProvider.System, Token);
AssertDiscoveredFromTheServer(connection); AssertDiscoveredFromTheServer(connection);
@@ -139,17 +91,23 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
/// The user typed one server URL. Everything about the identity provider — the authority, the client /// The user typed one server URL. Everything about the identity provider — the authority, the client
/// id, the scopes — came back from the server, which is the whole onboarding story. /// id, the scopes — came back from the server, which is the whole onboarding story.
/// </remarks> /// </remarks>
private static void AssertDiscoveredFromTheServer(ServerConnection connection) private void AssertDiscoveredFromTheServer(ServerConnection connection)
{ {
connection.Configuration.Oidc.Authority.ToString() connection.Configuration.Oidc.Authority.ToString()
.ShouldStartWith($"{DevStack.KeycloakBaseUrl}/realms/{DevStack.Realm}"); .ShouldStartWith(stack.Authority.ToString());
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop"); connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
// because it is configuration the server states about itself and nothing else would notice it
// being wrong.
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
connection.Meta.SyncProtocolVersion.ShouldBe(1); connection.Meta.SyncProtocolVersion.ShouldBe(1);
connection.Meta.CryptoSpecVersion.ShouldBe(1); connection.Meta.CryptoSpecVersion.ShouldBe(1);
} }
private static async Task EnrollAsync( private async Task EnrollAsync(
ServerConnection connection, ServerConnection connection,
ClientCacheFactory caches, ClientCacheFactory caches,
ScriptedBrowser browser) ScriptedBrowser browser)
@@ -157,11 +115,11 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
var provisioner = new AccountProvisioner( var provisioner = new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile); connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
var before = await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token); var before = await provisioner.RefreshAsync(ServerUrl, Token);
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired); before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
var enrolled = await provisioner.EnrollAsync( var enrolled = await provisioner.EnrollAsync(
DevStack.ApiBaseUrl, Passphrase, "e2e-laptop", "Personal", Token); ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);
enrolled.Status.ShouldBe(ProvisionStatus.Ready); enrolled.Status.ShouldBe(ProvisionStatus.Ready);
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace(); enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
@@ -211,7 +169,7 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile); connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
// Already enrolled, so this only caches what an offline unlock will need. // Already enrolled, so this only caches what an offline unlock will need.
(await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token)).Status (await provisioner.RefreshAsync(ServerUrl, Token)).Status
.ShouldBe(ProvisionStatus.Ready); .ShouldBe(ProvisionStatus.Ready);
var desktop = await UnlockAsync(desktopCache); var desktop = await UnlockAsync(desktopCache);
@@ -253,7 +211,7 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
var factory = new SshNetConnectionFactory(knownHosts); var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest( var request = new SshConnectionRequest(
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(SshPassword)); host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
try try
{ {
@@ -280,20 +238,23 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
private static CancellationToken Token => TestContext.Current.CancellationToken; private static CancellationToken Token => TestContext.Current.CancellationToken;
private static HostSecret BuildHostFor(string hostname, int port) => /// <remarks>
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
/// offline unlock compares against to refuse a cache belonging to another server.
/// </remarks>
private string ServerUrl => stack.ApiBaseUrl.ToString();
private HostSecret BuildHost() =>
new() new()
{ {
Label = "e2e-target", Label = "e2e-target",
Hostname = hostname, Hostname = stack.SshHostname,
Port = port, Port = stack.SshHostPort,
Username = SshUsername, Username = DevStack.SshUsername,
Notes = "created by the end-to-end slice", Notes = "created by the end-to-end slice",
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]), Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
}; };
private HostSecret BuildHost() =>
BuildHostFor(sshd!.Hostname, sshd.GetMappedPublicPort(SshPort));
private async Task<ClientCacheFactory> OpenCacheAsync() private async Task<ClientCacheFactory> OpenCacheAsync()
{ {
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}"); var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
@@ -46,6 +46,15 @@
"SharpZipLib": "1.4.2" "SharpZipLib": "1.4.2"
} }
}, },
"Testcontainers.PostgreSql": {
"type": "Direct",
"requested": "[4.13.0, )",
"resolved": "4.13.0",
"contentHash": "2ow4AE8drI9iA9Fr4ycAPusXPB1lJfQyyNONSMLE/XqLpm8VuNAh3pK38fOjkOWtTrnD03s4hAIdl4036Ik69A==",
"dependencies": {
"Testcontainers": "4.13.0"
}
},
"xunit.v3": { "xunit.v3": {
"type": "Direct", "type": "Direct",
"requested": "[3.2.2, )", "requested": "[3.2.2, )",
@@ -293,6 +302,14 @@
"resolved": "5.0.0", "resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
}, },
"Npgsql": {
"type": "Transitive",
"resolved": "10.0.3",
"contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "10.0.0"
}
},
"SharpZipLib": { "SharpZipLib": {
"type": "Transitive", "type": "Transitive",
"resolved": "1.4.2", "resolved": "1.4.2",
@@ -447,6 +464,17 @@
"NSec.Cryptography": "[26.4.0, )" "NSec.Cryptography": "[26.4.0, )"
} }
}, },
"dodossh.domain": {
"type": "Project"
},
"dodossh.infrastructure": {
"type": "Project",
"dependencies": {
"DodoSSH.Domain": "[1.0.0, )",
"EFCore.NamingConventions": "[10.0.1, )",
"Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )"
}
},
"BouncyCastle.Cryptography": { "BouncyCastle.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[2.6.2, )", "requested": "[2.6.2, )",
@@ -509,6 +537,17 @@
"SQLitePCLRaw.core": "2.1.11" "SQLitePCLRaw.core": "2.1.11"
} }
}, },
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "CentralTransitive",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "IPGrrZnRkuW7OlHDhUESZz4G5DLkW7Nej/O3Cx+0iTsgyU5XJxBgpsvTHLloo3WWuAKKbDHXBvWPVkX1deRh1Q==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)",
"Npgsql": "10.0.3"
}
},
"NSec.Cryptography": { "NSec.Cryptography": {
"type": "CentralTransitive", "type": "CentralTransitive",
"requested": "[26.4.0, )", "requested": "[26.4.0, )",