diff --git a/DodoSSH.slnx b/DodoSSH.slnx index dd5dde5..f65649c 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -41,6 +41,7 @@ + diff --git a/README.md b/README.md index ff3d022..345cfe3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,35 @@ dotnet run --project src/DodoSSH.Api It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in Development — `/openapi/v1.json`. +### 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: + +```bash +docker compose -f deploy/docker-compose.dev.yml up -d +``` + +```bash +dotnet ef database update --project src/DodoSSH.Infrastructure +``` + +```bash +dotnet run --project src/DodoSSH.Api +``` + +```bash +DODOSSH_E2E=1 dotnet test tests/DodoSSH.SystemTests +``` + +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 Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md), along with the deployment gotchas that have already cost time once. Read it before assuming something works @@ -110,8 +139,10 @@ off-Windows. vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's whole path is covered by tests against an in-memory server, so the states that matter most (the recovery code that cannot be skipped, the unlock that needs no network) are checked rather than remembered. - *Remaining:* the manual end-to-end run against the real API and a real Keycloak from - `deploy/docker-compose.dev.yml`, which is what M1's definition of done actually asks for. + *Verified end to end:* `tests/DodoSSH.SystemTests` drives the whole slice against a real Keycloak, a + real API, a real PostgreSQL and a real `sshd` — sign-in, the identity-provider key binding, enrollment, + offline unlock, a host through the vault to a second machine, and an interactive shell. See + [End-to-end verification](#end-to-end-verification). Known gaps in the client, stated rather than implied by the interface: credentials are not a synced entity type yet, so a connection still asks for a password; known host keys live in memory for one diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 5df19e6..d799eed 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -38,8 +38,15 @@ services: keycloak: image: quay.io/keycloak/keycloak:26.4 container_name: dodossh-dev-keycloak - # start-dev, never in production: it disables HTTPS enforcement and uses an in-memory - # database. The realm is imported on every start so this stays disposable. + # start-dev, never in production: it disables HTTPS enforcement and keeps its state in a + # throwaway H2 database inside the container. + # + # --import-realm skips a realm that already exists, so editing realm-dodossh.json and running + # `restart` does nothing at all — the edit appears to have no effect and the old configuration + # keeps being served. To pick up a change, recreate the container so its H2 state goes with it: + # + # docker compose -f deploy/docker-compose.dev.yml rm -sf keycloak + # docker compose -f deploy/docker-compose.dev.yml up -d keycloak command: ["start-dev", "--import-realm"] environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin diff --git a/deploy/keycloak/realm-dodossh.json b/deploy/keycloak/realm-dodossh.json index bed404f..7626467 100644 --- a/deploy/keycloak/realm-dodossh.json +++ b/deploy/keycloak/realm-dodossh.json @@ -23,11 +23,10 @@ "implicitFlowEnabled": false, "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "http://127.0.0.1:*/*" + "post.logout.redirect.uris": "http://127.0.0.1/*" }, "redirectUris": [ - "http://127.0.0.1:*/callback", - "http://localhost:*/callback" + "http://127.0.0.1/callback" ], "webOrigins": [], "protocolMappers": [ diff --git a/docs/platform-flags.md b/docs/platform-flags.md index 1565ef9..893b6d9 100644 --- a/docs/platform-flags.md +++ b/docs/platform-flags.md @@ -103,6 +103,40 @@ minimal desktop or inside a Flatpak sandbox — where the portal is the correct sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists so a platform-specific opener can be substituted without touching the flow. +## Identity provider + +**A loopback redirect URI must be registered without a port, not with a wildcard port.** Keycloak — and +providers implementing RFC 8252 §7.3 generally — ignores the port when the registered redirect URI's host +is a loopback literal, which is what lets a native client bind an ephemeral port. Registering +`http://127.0.0.1:*/callback` looks more explicit and is *broken*: the `*` is parsed as a literal port and +every real authorization request comes back `400 Invalid parameter: redirect_uri`. Keycloak's wildcard +support is trailing-only, so a `*` in the middle of a URI never means what it looks like. + +Register `http://127.0.0.1/callback`. Keep the path — it is the part that stops another process on the +machine having an authorization code delivered to a different endpoint. `Oidc:LoopbackRedirectPattern`, +which the server advertises through `/.well-known/dodossh-configuration`, says the same thing so an +operator configuring a different provider copies something that works. + +Found by running the sign-in against a real Keycloak; every test until then used a stub that accepted +whatever it was given. + +**Keycloak marks its session cookies `Secure` even over plain HTTP**, because `SameSite=None` is only +legal alongside `Secure`. A spec-conformant HTTP client therefore refuses to store them from an `http://` +origin — .NET's `CookieContainer` drops every one silently — and the login form POST then comes back +`400` with no explanation at all. Browsers complete the flow because they treat loopback as a trustworthy +origin and make the exception. + +This does not affect the product: the client uses the system browser, which makes that exception. It does +affect any non-browser automation against a development Keycloak, which has to carry the cookies by hand +(see `ScriptedBrowser`) or be given HTTPS. Two hours of "the credentials must be wrong". + +**`--import-realm` skips a realm that already exists.** Editing `deploy/keycloak/realm-dodossh.json` and +running `docker compose restart keycloak` therefore changes nothing, and the stale configuration keeps +being served — which reads exactly like the edit being wrong. `start-dev` keeps its state in an H2 +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 +debugging detour. + ## Local cache **The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it: diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs index 4466112..fed8d34 100644 --- a/src/DodoSSH.Api/Program.cs +++ b/src/DodoSSH.Api/Program.cs @@ -11,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddKeyPerFile("/run/secrets", optional: true); builder.Configuration.AddEnvironmentVariables(prefix: "DODOSSH_"); +builder.Services.AddDodoJson(); builder.Services.AddDodoOptions(); builder.Services.AddDodoPersistence(builder.Configuration); builder.Services.AddDodoAuthentication(); diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs index ce79c20..97edac1 100644 --- a/src/DodoSSH.Api/Setup/DodoOptions.cs +++ b/src/DodoSSH.Api/Setup/DodoOptions.cs @@ -34,7 +34,15 @@ public sealed class OidcOptions /// an ephemeral port with the system browser, never a custom scheme and never an embedded /// browser, so the user can see the real address bar. /// - public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1:*/callback"; + /// + /// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and + /// providers implement that by ignoring the port when the host is a loopback literal — Keycloak + /// included. Writing http://127.0.0.1:*/callback looks more explicit and is worse: Keycloak + /// parses the * as a literal port and rejects every real redirect with "Invalid parameter: + /// redirect_uri". Pinning the path is the part that matters, since it stops another local process + /// having a code delivered somewhere else. + /// + public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback"; /// Whether HTTPS metadata is required. Only ever false for local development. public bool RequireHttpsMetadata { get; set; } = true; diff --git a/src/DodoSSH.Api/Setup/Json.cs b/src/DodoSSH.Api/Setup/Json.cs new file mode 100644 index 0000000..6c0fc19 --- /dev/null +++ b/src/DodoSSH.Api/Setup/Json.cs @@ -0,0 +1,28 @@ +using DodoSSH.Contracts; + +namespace DodoSSH.Api.Setup; + +/// Brings the host's JSON handling into line with the shared contract. +internal static class Json +{ + /// + /// Applies 's settings to the minimal-API serialiser. + /// + /// + /// + /// Not optional, and not a performance tweak. Without it the framework's web defaults apply: camelCase + /// property names — which happen to match — but enums as numbers, and no rejection of unmapped + /// members. Every request DTO carrying an enum then fails to bind against a client that writes the + /// specified string form, which is the entire sync surface. The failure is a 400 naming only the + /// parameter, and it is invisible to any test that posts with its own default options rather than the + /// contract's. + /// + /// + /// Found by running the real client against the real server for the first time. Every test until then + /// serialised its requests with PostAsJsonAsync's defaults, so both sides agreed on integers and + /// nothing disagreed with anything. + /// + /// + internal static IServiceCollection AddDodoJson(this IServiceCollection services) => + services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions)); +} diff --git a/src/DodoSSH.Api/appsettings.json b/src/DodoSSH.Api/appsettings.json index a40458d..9dbf573 100644 --- a/src/DodoSSH.Api/appsettings.json +++ b/src/DodoSSH.Api/appsettings.json @@ -13,7 +13,7 @@ "Oidc": { "Audience": "dodossh-api", "ClientId": "dodossh-desktop", - "LoopbackRedirectPattern": "http://127.0.0.1:*/callback", + "LoopbackRedirectPattern": "http://127.0.0.1/callback", "RequireHttpsMetadata": true, "AllowEmailLinking": false }, diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs index d7fdd18..8b6d092 100644 --- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs +++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs @@ -72,6 +72,55 @@ public sealed partial class DodoSshJsonContext : JsonSerializerContext /// public static JsonSerializerOptions StrictRequestOptions => LazyStrictRequestOptions.Value; + /// + /// Applies these settings to options this process does not own. + /// + /// + /// + /// ASP.NET Core exposes its JSON options as a get-only already + /// constructed from , so a host cannot simply hand it + /// — it has to be mutated in place. That copying lives here, next + /// to the settings it has to mirror, so adding a setting above is one edit rather than two in different + /// projects with nothing connecting them. + /// + /// + /// Why this matters more than it looks. Without it the framework defaults apply, and those + /// serialise an enum as a number. Every request DTO carrying one — which is the whole sync surface — + /// then fails to bind against a client that writes the specified string form, with a 400 that names + /// only the parameter. Nothing catches it if the tests post with their own default options, because + /// both sides then agree on a form the specification never described. + /// + /// + /// Options to bring into line. Must not already be read-only. + public static void ApplyTo(JsonSerializerOptions target) + { + ArgumentNullException.ThrowIfNull(target); + + var source = StrictRequestOptions; + + target.PropertyNamingPolicy = source.PropertyNamingPolicy; + target.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive; + target.NumberHandling = source.NumberHandling; + target.DefaultIgnoreCondition = source.DefaultIgnoreCondition; + target.UnmappedMemberHandling = source.UnmappedMemberHandling; + target.DictionaryKeyPolicy = source.DictionaryKeyPolicy; + + foreach (var converter in source.Converters) + { + target.Converters.Add(converter); + } + + // Inserted at the front rather than assigned, so contract types use the source-generated metadata + // while any resolver the caller already installed keeps handling everything else — ProblemDetails + // among them, which a host must still be able to write. + // + // Options carrying no resolver at all end up with a chain containing only this context, and + // anything outside the contract then throws NotSupportedException. That is deliberate: adding a + // reflection fallback here would quietly cost trimmability, which is half the reason this context + // is source-generated. ASP.NET Core's own JSON options already have one. + target.TypeInfoResolverChain.Insert(0, Default); + } + /// /// Lazy, not a static initialiser. The generated Default property is a static of this /// same class, so reading it from this type's initialiser is a cycle: the accessor runs diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt index 989c765..42b87e5 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -523,6 +523,7 @@ static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryE static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool static DodoSSH.Contracts.DodoSshConfiguration.operator ==(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool +static DodoSSH.Contracts.DodoSshJsonContext.ApplyTo(System.Text.Json.JsonSerializerOptions! target) -> void static DodoSSH.Contracts.DodoSshJsonContext.ResponseOptions.get -> System.Text.Json.JsonSerializerOptions! static DodoSSH.Contracts.DodoSshJsonContext.StrictRequestOptions.get -> System.Text.Json.JsonSerializerOptions! static DodoSSH.Contracts.EncryptedPayload.operator !=(DodoSSH.Contracts.EncryptedPayload? left, DodoSSH.Contracts.EncryptedPayload? right) -> bool diff --git a/tests/DodoSSH.Api.Tests/ContractJson.cs b/tests/DodoSSH.Api.Tests/ContractJson.cs new file mode 100644 index 0000000..106fdf1 --- /dev/null +++ b/tests/DodoSSH.Api.Tests/ContractJson.cs @@ -0,0 +1,57 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DodoSSH.Contracts; + +namespace DodoSSH.Api.Tests; + +/// +/// Sends and reads request bodies the way a real client does. +/// +/// +/// +/// Not a convenience. PostAsJsonAsync's default options write an enum as a number, and the shared +/// contract writes it as a string. Tests that used the defaults therefore agreed with a server that had +/// also been left on the defaults, and the pair of them agreed on a wire form the specification never +/// described — so the entire sync surface was unreachable from the real client and every test passed. +/// +/// +/// Everything here goes through for that reason. If the server's JSON +/// configuration regresses, these tests are the ones that must fail. +/// +/// +internal static class ContractJson +{ + private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions; + + internal static Task PostContractAsync( + this HttpClient client, + string url, + T value) + { + ArgumentNullException.ThrowIfNull(client); + + return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken); + } + + internal static Task ReadContractAsync(this HttpContent content) + { + ArgumentNullException.ThrowIfNull(content); + + return content.ReadFromJsonAsync(Options, TestContext.Current.CancellationToken); + } + + /// Reads an RFC 9457 problem body. + /// + /// Deliberately not through the contract options. Problem details are written by the framework + /// and are not part of 's source-generated set, so resolving them + /// against it fails outright — a source-generated context does not fall back to reflection. Reading + /// them with the ambient web options is correct rather than a shortcut: the shape is the RFC's, not + /// ours, and only the code extension belongs to us. + /// + internal static Task ReadProblemAsync(this HttpContent content) + { + ArgumentNullException.ThrowIfNull(content); + + return content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + } +} diff --git a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs index 6c92636..3d6c5e4 100644 --- a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs @@ -277,7 +277,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) await EnrollAsync(client, enrollment.Build()); - var push = await client.PostAsJsonAsync( + var push = await client.PostContractAsync( $"/api/v1/vaults/{enrollment.VaultId}/sync/push", new SyncPushRequest( [ @@ -293,7 +293,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) push.EnsureSuccessStatusCode(); - var body = await push.Content.ReadFromJsonAsync(); + var body = await push.Content.ReadContractAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); } @@ -360,7 +360,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) try { var responses = await Task.WhenAll(enrollments.Select(e => - e.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, e.Build()))); + e.CreateClient(fixture).PostContractAsync(EnrollUrl, e.Build()))); foreach (var response in responses) { @@ -418,14 +418,14 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) var responses = await Task.WhenAll( Enumerable.Range(0, 4).Select(_ => - enrollment.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, request))); + enrollment.CreateClient(fixture).PostContractAsync(EnrollUrl, request))); var bodies = new List(responses.Length); foreach (var response in responses) { response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body.ShouldNotBeNull(); bodies.Add(body); } @@ -457,7 +457,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject); - var response = await client.PostAsJsonAsync(EnrollUrl, second.Build()); + var response = await client.PostContractAsync(EnrollUrl, second.Build()); await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled); } @@ -469,7 +469,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) var client = enrollment.CreateClient(fixture); await EnrollAsync(client, enrollment.Build()); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( EnrollUrl, enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7()))); @@ -484,7 +484,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var second = NewEnrollment(); - var response = await second.CreateClient(fixture).PostAsJsonAsync( + var response = await second.CreateClient(fixture).PostContractAsync( EnrollUrl, second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId))); @@ -504,7 +504,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); var other = enrollment.Statement with { DeviceName = "some-other-device" }; - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(idToken: enrollment.MintIdToken(other))); @@ -516,7 +516,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject()))); @@ -531,7 +531,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) // different kind of assertion entirely, and must not be interchangeable with one. using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( idToken: enrollment.MintIdToken( @@ -551,7 +551,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) enrollment.Subject, KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement))); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(idToken: foreign)); @@ -563,7 +563,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( idToken: enrollment.MintIdToken( @@ -578,7 +578,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example"))); @@ -591,7 +591,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true))); @@ -607,7 +607,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); var client = enrollment.CreateClient(fixture); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( EnrollUrl, enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true))); @@ -633,7 +633,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); using var other = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(statementSignature: other.Sign(enrollment.Statement))); @@ -651,7 +651,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); var impersonating = enrollment.Statement with { Subject = NewSubject() }; - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(statement: impersonating)); @@ -676,7 +676,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1))); @@ -692,7 +692,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4))); @@ -712,7 +712,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) // The signature and token are supplied ready-made: a malformed statement cannot be // canonically encoded at all, so neither can be derived from it. Shape validation runs // before any cryptography, so the server never gets that far either. - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build( statement: truncated, @@ -736,7 +736,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) EncryptionPublicKey = enrollment.Statement.SigningPublicKey, }; - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(statement: reused)); @@ -752,7 +752,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) using var enrollment = NewEnrollment(); var later = enrollment.Statement with { KeyGeneration = 2 }; - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build(statement: later)); @@ -769,7 +769,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) // device that can never unlock anything and that the server could never fix. using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build() with { DeviceWrappedPrivateKey = null }); @@ -784,7 +784,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build() with { RecoveryKdfParameters = null }); @@ -819,7 +819,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await enrollment.CreateClient(fixture).PostAsJsonAsync( + var response = await enrollment.CreateClient(fixture).PostContractAsync( EnrollUrl, enrollment.Build() with { KdfParameters = null! }); @@ -834,7 +834,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { using var enrollment = NewEnrollment(); - var response = await fixture.CreateClient().PostAsJsonAsync(EnrollUrl, enrollment.Build()); + var response = await fixture.CreateClient().PostContractAsync(EnrollUrl, enrollment.Build()); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } @@ -854,7 +854,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative)); response.EnsureSuccessStatusCode(); - var me = await response.Content.ReadFromJsonAsync(); + var me = await response.Content.ReadContractAsync(); me.ShouldNotBeNull(); return me; } @@ -863,10 +863,10 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) HttpClient client, EnrollmentRequest request) { - var response = await client.PostAsJsonAsync(EnrollUrl, request); + var response = await client.PostContractAsync(EnrollUrl, request); response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body.ShouldNotBeNull(); return body; } @@ -884,7 +884,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture) { response.StatusCode.ShouldBe(expectedStatus); - var problem = await response.Content.ReadFromJsonAsync(); + var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(expectedCode); } diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index 1a9eced..f0beec1 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -28,7 +28,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { var client = fixture.CreateClient(); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -40,7 +40,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { var client = fixture.CreateClient(); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PushUrl(Guid.CreateVersion7()), new SyncPushRequest([])); @@ -54,7 +54,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintTokenWithForeignKey(NewSubject())); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -67,7 +67,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintToken(NewSubject(), audience: "some-other-api")); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -80,7 +80,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientWithToken( fixture.IdentityProvider.MintToken(NewSubject(), issuer: "https://evil.example")); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -94,7 +94,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) NewSubject(), expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10))); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -111,13 +111,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) // ciphertext it has no key for. var client = fixture.CreateClientFor(NewSubject()); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); - var problem = await response.Content.ReadFromJsonAsync(); + var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired); } @@ -128,7 +128,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (_, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(NewSubject()); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); + var response = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); response.StatusCode.ShouldBe(HttpStatusCode.Forbidden); @@ -147,7 +147,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (_, vaultId) = await SeedUserWithVaultAsync(); var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); - var response = await intruder.PostAsJsonAsync( + var response = await intruder.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); @@ -160,7 +160,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (_, vaultId) = await SeedUserWithVaultAsync(); var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); - var response = await intruder.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); + var response = await intruder.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); response.StatusCode.ShouldBe(HttpStatusCode.NotFound); } @@ -173,7 +173,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync()); var batch = NewCreateBatch(); - await intruder.PostAsJsonAsync(PushUrl(vaultId), batch); + await intruder.PostContractAsync(PushUrl(vaultId), batch); await using var scope = fixture.CreateScope(); var database = scope.ServiceProvider.GetRequiredService(); @@ -187,7 +187,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { var client = fixture.CreateClientFor(await SeedEnrolledUserAsync()); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(Guid.CreateVersion7()), new SyncPullRequest(null, null, null)); @@ -201,7 +201,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var vaultId = await SeedTeamVaultAsync(); var client = fixture.CreateClientFor(await SeedEnrolledUserAsync()); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); @@ -217,21 +217,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientFor(subject); var batch = NewCreateBatch(); - var push = await client.PostAsJsonAsync(PushUrl(vaultId), batch); + var push = await client.PostContractAsync(PushUrl(vaultId), batch); push.EnsureSuccessStatusCode(); - var pushed = await push.Content.ReadFromJsonAsync(); + var pushed = await push.Content.ReadContractAsync(); pushed.ShouldNotBeNull(); pushed.Results.Count.ShouldBe(1); pushed.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); pushed.Results[0].Version.ShouldBe(1); - var pull = await client.PostAsJsonAsync( + var pull = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); pull.EnsureSuccessStatusCode(); - var pulled = await pull.Content.ReadFromJsonAsync(); + var pulled = await pull.Content.ReadContractAsync(); pulled.ShouldNotBeNull(); pulled.Changes.Count.ShouldBe(1); @@ -248,30 +248,30 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); + await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); - var first = await (await client.PostAsJsonAsync( + var first = await (await client.PostContractAsync( PullUrl(vaultId), - new SyncPullRequest(null, null, null))).Content.ReadFromJsonAsync(); + new SyncPullRequest(null, null, null))).Content.ReadContractAsync(); first.ShouldNotBeNull(); // Nothing new since that cursor. - var empty = await (await client.PostAsJsonAsync( + var empty = await (await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) - .Content.ReadFromJsonAsync(); + .Content.ReadContractAsync(); empty.ShouldNotBeNull(); empty.Changes.ShouldBeEmpty(); // The cursor must not have rewound, or the next poll would replay history. empty.NextCursor.ShouldBe(first.NextCursor); - await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch()); + await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch()); - var second = await (await client.PostAsJsonAsync( + var second = await (await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(first.NextCursor, null, null))) - .Content.ReadFromJsonAsync(); + .Content.ReadContractAsync(); second.ShouldNotBeNull(); second.Changes.Count.ShouldBe(1); } @@ -283,13 +283,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (_, otherVault) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var pull = await client.PostAsJsonAsync( + var pull = await client.PostContractAsync( PullUrl(firstVault), new SyncPullRequest(null, null, null)); - var cursor = (await pull.Content.ReadFromJsonAsync())!.NextCursor; + var cursor = (await pull.Content.ReadContractAsync())!.NextCursor; // Correctly signed, but issued for a different vault. - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(otherVault), new SyncPullRequest(cursor, null, null)); @@ -303,13 +303,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var response = await client.PostAsJsonAsync( + var response = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest("bm90LWEtcmVhbC1jdXJzb3I", null, null)); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); - var problem = await response.Content.ReadFromJsonAsync(); + var problem = await response.Content.ReadProblemAsync(); problem.ShouldNotBeNull(); problem.Code.ShouldBe(ProblemCodes.InvalidCursor); } @@ -324,23 +324,23 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; - await client.PostAsJsonAsync(PushUrl(vaultId), create); + await client.PostContractAsync(PushUrl(vaultId), create); // Update to version 2. - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]), ])); // A second client still believes it is on version 1. - var stale = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + var stale = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]), ])); stale.EnsureSuccessStatusCode(); - var body = await stale.Content.ReadFromJsonAsync(); + var body = await stale.Content.ReadContractAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict); body.Results[0].Version.ShouldBe(2); @@ -358,13 +358,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; - await client.PostAsJsonAsync(PushUrl(vaultId), create); - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), create); + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]), ])); - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]), ])); @@ -386,14 +386,14 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var batch = NewCreateBatch(); - var first = await client.PostAsJsonAsync(PushUrl(vaultId), batch); + var first = await client.PostContractAsync(PushUrl(vaultId), batch); first.EnsureSuccessStatusCode(); // Exactly the same batch again, as a retry after a timeout would be. - var replay = await client.PostAsJsonAsync(PushUrl(vaultId), batch); + var replay = await client.PostContractAsync(PushUrl(vaultId), batch); replay.EnsureSuccessStatusCode(); - var body = await replay.Content.ReadFromJsonAsync(); + var body = await replay.Content.ReadContractAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Duplicate); @@ -414,7 +414,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientFor(subject); var existing = NewCreateBatch(); - await client.PostAsJsonAsync(PushUrl(vaultId), existing); + await client.PostContractAsync(PushUrl(vaultId), existing); var goodId = Guid.CreateVersion7(); var mixed = new SyncPushRequest( @@ -423,12 +423,12 @@ public sealed class SyncEndpointTests(ApiFixture fixture) NewOperation(goodId, expectedVersion: null, envelope: [2, 2]), ]); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), mixed); + var response = await client.PostContractAsync(PushUrl(vaultId), mixed); // 200 despite a failed operation: per-operation status carries the detail. response.StatusCode.ShouldBe(HttpStatusCode.OK); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body.ShouldNotBeNull(); body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict); body.Results[1].Status.ShouldBe(SyncOperationStatus.Applied); @@ -447,7 +447,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -461,7 +461,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } @@ -471,7 +471,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -485,7 +485,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } @@ -497,7 +497,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var client = fixture.CreateClientFor(subject); var entityId = Guid.CreateVersion7(); - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -509,7 +509,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)), ])); - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -539,9 +539,9 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var create = NewCreateBatch(); var entityId = create.Operations[0].EntityId; - await client.PostAsJsonAsync(PushUrl(vaultId), create); + await client.PostContractAsync(PushUrl(vaultId), create); - await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -553,11 +553,11 @@ public sealed class SyncEndpointTests(ApiFixture fixture) PlaintextFields: null), ])); - var pull = await client.PostAsJsonAsync( + var pull = await client.PostContractAsync( PullUrl(vaultId), new SyncPullRequest(null, null, null)); - var body = await pull.Content.ReadFromJsonAsync(); + var body = await pull.Content.ReadContractAsync(); body.ShouldNotBeNull(); var tombstone = body.Changes.Last(c => c.EntityId == entityId); @@ -574,7 +574,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest([])); + var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([])); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); } @@ -587,7 +587,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); - var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest( + var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest( [ new SyncPushOperation( Guid.CreateVersion7(), @@ -601,7 +601,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(); + var body = await response.Content.ReadContractAsync(); body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid); } diff --git a/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs b/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs new file mode 100644 index 0000000..ccd0e00 --- /dev/null +++ b/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs @@ -0,0 +1,146 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace DodoSSH.Contracts.Tests; + +/// +/// Applying the contract's settings to options this process does not own. +/// +/// +/// The host cannot be handed StrictRequestOptions — ASP.NET Core exposes its serialiser options as +/// a get-only instance already built from the web defaults — so they have to be copied in. Copying is +/// exactly the hazard the context's own documentation warns about, which is why every setting that +/// distinguishes the contract from those defaults is asserted here rather than assumed. +/// +/// The consequence of getting this wrong is not subtle: the web defaults write an enum as a number, and +/// every request DTO carrying one then fails to bind against a real client. +/// +/// +public sealed class ApplyToTests +{ + [Fact] + public void AppliedOptions_WriteEnumsAsStrings() + { + // The setting that actually broke. A number here means the sync surface is unreachable. + var change = new SyncChange( + SyncEntityType.Host, + Guid.CreateVersion7(), + SyncOperation.Upsert, + Version: 1, + ChangeSequence: 1, + Payload: null, + PlaintextFields: null, + UpdatedAt: DateTimeOffset.UnixEpoch); + + var json = JsonSerializer.Serialize(change, Applied()); + + json.ShouldContain("\"Host\""); + json.ShouldContain("\"Upsert\""); + json.ShouldNotContain("\"entityType\":1"); + } + + [Fact] + public void AppliedOptions_ReadEnumsAsStrings() + { + var request = new SyncPullRequest("cursor", 100, [SyncEntityType.Host, SyncEntityType.Credential]); + + var options = Applied(); + + var restored = JsonSerializer.Deserialize( + JsonSerializer.Serialize(request, options), options); + + restored.ShouldNotBeNull(); + restored.EntityTypes.ShouldBe(request.EntityTypes); + } + + [Fact] + public void AppliedOptions_RejectAnUnmappedMember() + { + // The documented reason StrictRequestOptions exists: a renamed or misspelled property surfaces as + // a 400 rather than as a silently missing value that later looks like data loss. + const string Body = """{"cursor":"c","limit":10,"entityTypes":null,"typo":true}"""; + + Should.Throw(() => JsonSerializer.Deserialize(Body, Applied())); + } + + [Fact] + public void AppliedOptions_RejectANumberInAString() + { + // JsonSerializerDefaults.Web replaces Strict with AllowReadingFromString, and two implementations + // that disagree about whether "1" is a number disagree silently. + const string Body = """ + {"envelope":"AQID","wrappedDataKey":"BAU=", + "dataKeyId":"0192f0c8-0000-7000-8000-000000000000", + "keyGeneration":"1","aadVersion":1} + """; + + Should.Throw(() => JsonSerializer.Deserialize(Body, Applied())); + } + + [Fact] + public void AppliedOptions_UseCamelCase() + { + // Compared exactly. Shouldly's ShouldNotContain is case-insensitive, so asserting the absence of + // "Cursor" would pass on "cursor" and prove nothing. + JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), Applied()) + .ShouldBe("""{"cursor":"c","limit":1}"""); + } + + [Fact] + public void AppliedOptions_MatchTheContractsOwnSettings() + { + // A blanket comparison, so a setting added to the source-generation attributes and forgotten in + // ApplyTo fails here rather than at a wire boundary. + var applied = Applied(); + var contract = DodoSshJsonContext.StrictRequestOptions; + + applied.PropertyNamingPolicy.ShouldBe(contract.PropertyNamingPolicy); + applied.NumberHandling.ShouldBe(contract.NumberHandling); + applied.DefaultIgnoreCondition.ShouldBe(contract.DefaultIgnoreCondition); + applied.UnmappedMemberHandling.ShouldBe(contract.UnmappedMemberHandling); + applied.PropertyNameCaseInsensitive.ShouldBe(contract.PropertyNameCaseInsensitive); + applied.DictionaryKeyPolicy.ShouldBe(contract.DictionaryKeyPolicy); + } + + [Fact] + public void AppliedOptions_LeaveTheCallersOwnResolverInPlace() + { + // The guarantee a host depends on. The context goes in front so contract types use the generated + // metadata, and whatever the host already installed keeps handling everything else — + // ProblemDetails among them. Replacing the chain would break every framework type a server writes. + JsonSerializer.Serialize(new Unrelated("value"), Applied()).ShouldBe("""{"name":"value"}"""); + } + + [Fact] + public void WithoutAFallbackResolver_TypesOutsideTheContractAreRefused() + { + // Stated rather than assumed, because it is the trade being made: a reflection fallback inside + // ApplyTo would cost trimmability, which is half the reason this context is source-generated. + // Options carrying no resolver are the caller's problem, loudly rather than silently. + var bare = new JsonSerializerOptions(JsonSerializerDefaults.Web); + DodoSshJsonContext.ApplyTo(bare); + + Should.Throw(() => JsonSerializer.Serialize(new Unrelated("v"), bare)); + + // Contract types still work, so the failure is narrow rather than total. + JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), bare).ShouldNotBeNullOrEmpty(); + } + + /// + /// Mirrors ASP.NET Core's own options: web defaults with a reflection resolver already + /// installed, which is the state ConfigureHttpJsonOptions hands over. + /// + private static JsonSerializerOptions Applied() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + DodoSshJsonContext.ApplyTo(options); + + return options; + } + + private sealed record Unrelated(string Name); +} diff --git a/tests/DodoSSH.SystemTests/DevStack.cs b/tests/DodoSSH.SystemTests/DevStack.cs new file mode 100644 index 0000000..6ff3f0a --- /dev/null +++ b/tests/DodoSSH.SystemTests/DevStack.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace DodoSSH.SystemTests; + +/// +/// The development stack this suite talks to, and how to find out whether it is there. +/// +/// +/// 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. +/// +/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start. +/// +/// +internal static class DevStack +{ + internal const string ApiBaseUrl = "http://localhost:5233"; + internal const string KeycloakBaseUrl = "http://localhost:18080"; + internal const string Realm = "dodossh"; + + private const string OptInVariable = "DODOSSH_E2E"; + + /// + /// Explains why the suite cannot run, or returns null when it can. + /// + /// + /// A message rather than a boolean, because a skipped end-to-end suite that does not say what is + /// missing is a suite nobody ever runs again. + /// + internal static async Task WhyUnavailableAsync(CancellationToken cancellationToken) + { + if (Environment.GetEnvironmentVariable(OptInVariable) is not "1") + { + return $"Set {OptInVariable}=1 to run the end-to-end suite. It needs the development stack:" + + "\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) }; + + if (!await RespondsAsync(http, $"{ApiBaseUrl}/healthz/ready", cancellationToken).ConfigureAwait(false)) + { + 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; + } + + /// + /// Creates a Keycloak user this test run owns. + /// + /// + /// A fresh account per run, rather than the realm's alice. Enrollment happens once per account + /// and cannot be undone from the client, so reusing an account would mean the second run exercises a + /// different path from the first and neither could assert an exact vault state. This way every run + /// starts from "no identity key, no vault". + /// + internal static async Task CreateUserAsync(CancellationToken cancellationToken) + { + var username = string.Create( + CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}"); + + const string Password = "e2e-password"; + + using var http = new HttpClient { BaseAddress = new Uri(KeycloakBaseUrl) }; + + var token = await AdminTokenAsync(http, cancellationToken).ConfigureAwait(false); + + http.DefaultRequestHeaders.Authorization = new("Bearer", token); + + var user = new JsonObject + { + ["username"] = username, + ["email"] = $"{username}@example.test", + ["firstName"] = "End", + ["lastName"] = "ToEnd", + ["enabled"] = true, + ["emailVerified"] = true, + ["credentials"] = new JsonArray + { + new JsonObject + { + ["type"] = "password", + ["value"] = Password, + ["temporary"] = false, + }, + }, + }; + + using var response = await http + .PostAsJsonAsync($"/admin/realms/{Realm}/users", user, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + return new DevStackUser(username, Password); + } + + private static async Task AdminTokenAsync(HttpClient http, CancellationToken cancellationToken) + { + using var form = new FormUrlEncodedContent( + new Dictionary(StringComparer.Ordinal) + { + ["client_id"] = "admin-cli", + ["username"] = "admin", + ["password"] = "admin", + ["grant_type"] = "password", + }); + + using var response = await http + .PostAsync("/realms/master/protocol/openid-connect/token", form, cancellationToken) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("Keycloak returned no admin access token."); + } + + private static async Task 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; + } + } +} + +/// An account created for one test run. +internal sealed record DevStackUser(string Username, string Password); diff --git a/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj b/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj new file mode 100644 index 0000000..fb4df43 --- /dev/null +++ b/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj @@ -0,0 +1,40 @@ + + + + + + + --ignore-exit-code 8 + + + + + + + + + + + + + diff --git a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs new file mode 100644 index 0000000..a28a31f --- /dev/null +++ b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs @@ -0,0 +1,371 @@ +using System.Text; +using DodoSSH.Client.Domain; +using DodoSSH.Client.Session; +using DodoSSH.Client.Ssh; +using DodoSSH.Client.Storage; +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace DodoSSH.SystemTests; + +/// +/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine, +/// and open a shell on it. +/// +/// +/// +/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the +/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real sshd answers at +/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a +/// misreading of the protocol can be consistent on both sides and still wrong in production — which is +/// exactly what this found the first time it ran. +/// +/// +/// One test rather than several, because the steps are not independent: you cannot unlock without having +/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state +/// between tests or repeating a minute of setup per assertion. +/// +/// +public sealed class M1VerticalSliceTests : IAsyncLifetime +{ + 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; + + /// + /// 64 MiB is the floor EnrollmentLimits enforces, and this suite has to respect it — the other + /// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that + /// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only + /// available because nothing is checking, and the difference is a 400 rather than a slow test. + /// + private static readonly Argon2Profile ServerFloorProfile = + Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1); + + private readonly List directories = []; + + private string? unavailable; + private IContainer? sshd; + + /// + public async ValueTask InitializeAsync() + { + 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); + } + + /// + public async ValueTask DisposeAsync() + { + if (sshd is not null) + { + await sshd.DisposeAsync(); + } + + foreach (var directory in directories.Where(Directory.Exists)) + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task TheWholeSlice() + { + if (unavailable is not null) + { + Assert.Skip(unavailable); + } + + var account = await DevStack.CreateUserAsync(Token); + var browser = new ScriptedBrowser(account.Username, account.Password); + + using var connection = await ServerConnection + .SignInAsync(new Uri(DevStack.ApiBaseUrl), browser, TimeProvider.System, Token); + + AssertDiscoveredFromTheServer(connection); + + using var laptopCache = await OpenCacheAsync(); + await EnrollAsync(connection, laptopCache, browser); + + var laptop = await UnlockAsync(laptopCache); + await using var laptopSession = laptop; + + var host = BuildHost(); + var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token); + + var pushed = await laptop.SyncAsync(connection.Sync, Token); + pushed.Pushed.ShouldBe(1); + pushed.NeedsAttention.ShouldBeFalse(); + + await AssertTheServerCannotSeeTheAddressAsync(connection, entityId); + + var seen = await ReadOnASecondMachineAsync(connection, host, entityId); + + await AssertUnlocksOfflineAsync(laptopCache); + + await OpenAShellAsync(seen); + } + + // ---- Steps ---- + + /// + /// 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. + /// + private static void AssertDiscoveredFromTheServer(ServerConnection connection) + { + connection.Configuration.Oidc.Authority.ToString() + .ShouldStartWith($"{DevStack.KeycloakBaseUrl}/realms/{DevStack.Realm}"); + + connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop"); + connection.Meta.SyncProtocolVersion.ShouldBe(1); + connection.Meta.CryptoSpecVersion.ShouldBe(1); + } + + private static async Task EnrollAsync( + ServerConnection connection, + ClientCacheFactory caches, + ScriptedBrowser browser) + { + var provisioner = new AccountProvisioner( + connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile); + + var before = await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token); + before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired); + + var enrolled = await provisioner.EnrollAsync( + DevStack.ApiBaseUrl, Passphrase, "e2e-laptop", "Personal", Token); + + enrolled.Status.ShouldBe(ProvisionStatus.Ready); + enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace(); + + // Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose + // nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS + // before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for + // someone who never enrolled — see ADR 0001 — and it is invisible unless something counts. + browser.SignInCount.ShouldBe( + 2, "enrollment must obtain an identity-provider signature over the published key"); + } + + /// + /// Asserted against what the server hands back, not against the local mirror. With relay off the + /// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise. + /// + private static async Task AssertTheServerCannotSeeTheAddressAsync( + ServerConnection connection, + Guid entityId) + { + var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId; + + var page = await connection.Sync.SyncPullAsync( + vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token); + + var change = page.Changes.Single(c => c.EntityId == entityId); + + change.PlaintextFields.ShouldNotBeNull(); + change.PlaintextFields.RelayEnabled.ShouldBeFalse(); + change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload"); + change.PlaintextFields.Port.ShouldBeNull(); + + // What it does hold is opaque, and it carries its data key as the specification requires. + change.Payload.ShouldNotBeNull(); + change.Payload.WrappedDataKey.ShouldNotBeEmpty(); + change.Payload.DataKeyId.ShouldNotBe(Guid.Empty); + } + + private async Task ReadOnASecondMachineAsync( + ServerConnection connection, + HostSecret expected, + Guid entityId) + { + using var desktopCache = await OpenCacheAsync(); + + var provisioner = new AccountProvisioner( + connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile); + + // Already enrolled, so this only caches what an offline unlock will need. + (await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token)).Status + .ShouldBe(ProvisionStatus.Ready); + + var desktop = await UnlockAsync(desktopCache); + await using var session = desktop; + + var pulled = await desktop.SyncAsync(connection.Sync, Token); + pulled.Pulled.ShouldBe(1); + + var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token); + var seen = listing.Hosts.ShouldHaveSingleItem(); + + seen.EntityId.ShouldBe(entityId); + seen.HasUnsyncedChanges.ShouldBeFalse(); + + // The decrypted host survived a round trip through a server that could read none of it — including + // the directives, which merge per name and therefore have to come back in canonical form. + seen.Host.ShouldBe(expected); + + return seen.Host; + } + + private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches) + { + // Nothing here touches the network: the salt, the parameters and the wrapped bundle are local. + var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); + + offline.IsUnlocked.ShouldBeTrue(offline.Message); + await offline.Session!.DisposeAsync(); + } + + /// + /// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the + /// caller pins it and retries — which is what the interface does, and the only way to prove the + /// fingerprint a user would be shown is the one the server actually presented. + /// + private static async Task OpenAShellAsync(HostSecret host) + { + var knownHosts = new InMemoryKnownHostStore(); + var factory = new SshNetConnectionFactory(knownHosts); + + var request = new SshConnectionRequest( + host.Hostname, host.Port, host.Username!, new SshPasswordCredential(SshPassword)); + + try + { + await using var first = await factory.ConnectAsync(request, Token); + Assert.Fail("An unseen host key must not be trusted silently."); + } + catch (SshHostKeyUnknownException exception) + { + exception.Presentation.Fingerprint.ShouldStartWith("SHA256:"); + await knownHosts.TrustAsync(exception.Presentation, Token); + } + + await using var connection = await factory.ConnectAsync(request, Token); + await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token); + + await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token); + + var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok"); + + output.ShouldContain("dodossh-e2e-ok"); + } + + // ---- Helpers ---- + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static HostSecret BuildHostFor(string hostname, int port) => + new() + { + Label = "e2e-target", + Hostname = hostname, + Port = port, + Username = SshUsername, + Notes = "created by the end-to-end slice", + Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]), + }; + + private HostSecret BuildHost() => + BuildHostFor(sshd!.Hostname, sshd.GetMappedPublicPort(SshPort)); + + private async Task OpenCacheAsync() + { + var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}"); + Directory.CreateDirectory(directory); + directories.Add(directory); + + var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile); + + try + { + await factory.MigrateAsync(Token); + return factory; + } + catch + { + factory.Dispose(); + throw; + } + } + + private static async Task UnlockAsync(ClientCacheFactory caches) + { + var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); + + outcome.IsUnlocked.ShouldBeTrue(outcome.Message); + return outcome.Session!; + } + + /// + /// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather + /// than for a fixed time. The login banner arrives first and its length is not something this test + /// should have to know. + /// + private static async Task ReadUntilEchoedAsync(ISshShellSession shell, string marker) + { + var text = new StringBuilder(); + var buffer = new byte[8192]; + + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token); + deadline.CancelAfter(TimeSpan.FromSeconds(30)); + + while (!deadline.IsCancellationRequested) + { + var read = await shell.ReadAsync(buffer, deadline.Token); + + if (read == 0) + { + break; + } + + text.Append(Encoding.UTF8.GetString(buffer, 0, read)); + + if (Occurrences(text.ToString(), marker) >= 2) + { + break; + } + } + + return text.ToString(); + } + + private static int Occurrences(string text, string marker) + { + var count = 0; + var index = 0; + + while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += marker.Length; + } + + return count; + } +} diff --git a/tests/DodoSSH.SystemTests/ScriptedBrowser.cs b/tests/DodoSSH.SystemTests/ScriptedBrowser.cs new file mode 100644 index 0000000..7ab0ede --- /dev/null +++ b/tests/DodoSSH.SystemTests/ScriptedBrowser.cs @@ -0,0 +1,207 @@ +using System.Text.RegularExpressions; +using System.Web; +using DodoSSH.Client.Auth; + +namespace DodoSSH.SystemTests; + +/// +/// Signs in to a real Keycloak by driving its login form over HTTP. +/// +/// +/// +/// Stands in for the system browser, and only for the browser — everything else in the flow is the real +/// thing. The authorization request, the login form, the redirect back to the loopback listener, the code +/// exchange and PKCE verification all happen exactly as they would for a person clicking through. +/// +/// +/// Everything up to the redirect is awaited; the final hop is not. That split is not tidiness. +/// OidcClient awaits the launcher before it awaits the callback, and the last hop of this flow is a +/// request to that callback — completing it inline would block on a request nobody is reading yet +/// and the sign-in would deadlock instead of failing. Awaiting the earlier steps is what makes a rejected +/// authorization request surface immediately, with Keycloak's own words, rather than as a five-minute wait +/// for a browser that was never going to arrive. +/// +/// +internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher +{ + /// How many times a sign-in was driven, so the key binding's second one is visible. + internal int SignInCount { get; private set; } + + /// + public async Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(url); + + SignInCount++; + + // Cookies are carried by hand, and automatic handling is off. That is not a shortcut — it is + // required, and the reason is worth knowing: + // + // Keycloak marks its authentication-session cookies `Secure; SameSite=None`, because SameSite=None + // is only legal alongside Secure. Over a development stack served on plain HTTP, a + // spec-conformant client refuses to store a Secure cookie from an insecure origin, so + // CookieContainer silently drops every one of them and the login POST comes back 400 with no + // explanation. Browsers do complete this flow, because they treat loopback as a trustworthy + // origin and make the exception. Carrying the cookies manually emulates that exception + // deliberately, in one visible place, rather than looking like broken cookie handling. + var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }; + + var http = new HttpClient(handler); + + try + { + var redirect = await SubmitCredentialsAsync(http, url, cancellationToken) + .ConfigureAwait(false); + + // Deliberately not awaited: this is the request the loopback listener is waiting for, and it + // is only read after this method returns. Ownership of the client passes to the continuation. + _ = DeliverAsync(http, handler, redirect, cancellationToken); + + } + catch + { + http.Dispose(); + handler.Dispose(); + throw; + } + } + + /// Fetches the login page and posts the credentials, returning where Keycloak sends us. + private async Task SubmitCredentialsAsync( + HttpClient http, + Uri authorizationUrl, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, authorizationUrl); + using var opened = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var cookies = CollectCookies(opened); + + var loginPage = await opened.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var action = ExtractLoginAction(loginPage); + + using var credentials = new FormUrlEncodedContent( + new Dictionary(StringComparer.Ordinal) + { + ["username"] = username, + ["password"] = password, + ["credentialId"] = string.Empty, + }); + + using var login = new HttpRequestMessage(HttpMethod.Post, action) { Content = credentials }; + login.Headers.Add("Cookie", cookies); + + using var posted = await http.SendAsync(login, cancellationToken).ConfigureAwait(false); + + if (posted.Headers.Location is { } redirect) + { + return redirect; + } + + // Keycloak answers a rejected login with another page rather than a header, so the reason is only + // in the body. Reporting the status alone would be almost useless. + var body = await posted.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + throw new InvalidOperationException( + $"Keycloak answered the login form with {(int)posted.StatusCode} and no redirect. It said: " + + Summarise(body)); + } + + /// + /// Only the name=value part of each Set-Cookie, which is all a request may send back. + /// + private static string CollectCookies(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var values)) + { + return string.Empty; + } + + var pairs = values + .Select(value => value.Split(';', 2)[0].Trim()) + .Where(pair => pair.Length > 0); + + return string.Join("; ", pairs); + } + + private static async Task DeliverAsync( + HttpClient http, + HttpClientHandler handler, + Uri redirect, + CancellationToken cancellationToken) + { + try + { + using var delivered = await http.GetAsync(redirect, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // Nothing useful to do here. If the code never arrives, the sign-in fails on its own timeout + // and rethrowing on an unobserved task would take the test host down with it instead. + } + finally + { + http.Dispose(); + handler.Dispose(); + } + } + + /// + /// The form's action carries the session code and the execution id, so it cannot be constructed — it + /// has to be read back out of the page, and it arrives HTML-escaped. + /// + private static Uri ExtractLoginAction(string loginPage) + { + var match = LoginFormAction().Match(loginPage); + + if (!match.Success) + { + throw new InvalidOperationException( + "Could not find Keycloak's login form, so the authorization request was rejected before a " + + $"login was ever offered. Keycloak said: {Summarise(loginPage)}"); + } + + return new Uri(HttpUtility.HtmlDecode(match.Groups["action"].Value), UriKind.Absolute); + } + + /// Surfaces Keycloak's own error text, which is the only useful part of a rejection page. + private static string Summarise(string page) + { + var messages = ErrorMessage().Matches(page) + .Select(match => match.Groups["text"].Value.Trim()) + .Where(text => text.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (messages.Length > 0) + { + return string.Join(" / ", messages.Select(text => $"\"{text}\"")); + } + + var title = PageTitle().Match(page); + + return title.Success + ? $"a page titled \"{title.Groups["text"].Value.Trim()}\" with no error text" + : $"nothing recognisable, in {page.Length} characters"; + } + + // A timeout because the input is a page from a server, and an unbounded backtrack on untrusted + // input is a hang rather than a failure. + [GeneratedRegex( + """]*id="kc-form-login"[^>]*action="(?[^"]+)""", + RegexOptions.IgnoreCase, + matchTimeoutMilliseconds: 2000)] + private static partial Regex LoginFormAction(); + + // Matches both shapes Keycloak uses: the per-field "input-error" spans on a login page, and the + // "kc-feedback" / alert text on a rejected request. + [GeneratedRegex( + """(?:id|class)="[^"]*(?:input-error|kc-feedback-text|pf-v5-c-alert__title)[^"]*"[^>]*>(?[^<]{1,300})<""", + RegexOptions.IgnoreCase, + matchTimeoutMilliseconds: 2000)] + private static partial Regex ErrorMessage(); + + [GeneratedRegex("""(?<text>[^<]{0,200})""", RegexOptions.IgnoreCase, + matchTimeoutMilliseconds: 2000)] + private static partial Regex PageTitle(); +} diff --git a/tests/DodoSSH.SystemTests/packages.lock.json b/tests/DodoSSH.SystemTests/packages.lock.json new file mode 100644 index 0000000..842c0cb --- /dev/null +++ b/tests/DodoSSH.SystemTests/packages.lock.json @@ -0,0 +1,564 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "Testcontainers": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3", + "Docker.DotNet.Enhanced.LegacyHttp": "4.3.3", + "Docker.DotNet.Enhanced.NPipe": "4.3.3", + "Docker.DotNet.Enhanced.NativeHttp": "4.3.3", + "Docker.DotNet.Enhanced.Unix": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + }, + "Microsoft.EntityFrameworkCore.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.api": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Auth": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.client.domain": { + "type": "Project" + }, + "dodossh.client.session": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Api": "[1.0.0, )", + "DodoSSH.Client.Auth": "[1.0.0, )", + "DodoSSH.Client.Domain": "[1.0.0, )", + "DodoSSH.Client.Storage": "[1.0.0, )", + "DodoSSH.Client.Sync": "[1.0.0, )" + } + }, + "dodossh.client.ssh": { + "type": "Project", + "dependencies": { + "SSH.NET": "[2025.1.0, )" + } + }, + "dodossh.client.storage": { + "type": "Project", + "dependencies": { + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )" + } + }, + "dodossh.client.sync": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Api": "[1.0.0, )", + "DodoSSH.Client.Domain": "[1.0.0, )", + "DodoSSH.Client.Storage": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.client.terminal": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Ssh": "[1.0.0, )" + } + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" + } + }, + "Microsoft.EntityFrameworkCore.Sqlite": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyModel": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "SQLitePCLRaw.core": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "CentralTransitive", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + }, + "SSH.NET": { + "type": "CentralTransitive", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + } + } + } +} \ No newline at end of file