Public Access
Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
<Project Path="tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj" />
|
||||
<Project Path="tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj" />
|
||||
<Project Path="tests/DodoSSH.Infrastructure.Tests/DodoSSH.Infrastructure.Tests.csproj" />
|
||||
</Folder>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1:*/callback";
|
||||
/// <remarks>
|
||||
/// 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 <c>http://127.0.0.1:*/callback</c> looks more explicit and is worse: Keycloak
|
||||
/// parses the <c>*</c> 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.
|
||||
/// </remarks>
|
||||
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";
|
||||
|
||||
/// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Api.Setup;
|
||||
|
||||
/// <summary>Brings the host's JSON handling into line with the shared contract.</summary>
|
||||
internal static class Json
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the minimal-API serialiser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <em>numbers</em>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by running the real client against the real server for the first time. Every test until then
|
||||
/// serialised its requests with <c>PostAsJsonAsync</c>'s defaults, so both sides agreed on integers and
|
||||
/// nothing disagreed with anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
|
||||
services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions));
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -72,6 +72,55 @@ public sealed partial class DodoSshJsonContext : JsonSerializerContext
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions StrictRequestOptions => LazyStrictRequestOptions.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Applies these settings to options this process does not own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ASP.NET Core exposes its JSON options as a get-only <see cref="JsonSerializerOptions"/> already
|
||||
/// constructed from <see cref="JsonSerializerDefaults.Web"/>, so a host cannot simply hand it
|
||||
/// <see cref="StrictRequestOptions"/> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why this matters more than it looks.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="target">Options to bring into line. Must not already be read-only.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Lazy, not a static initialiser. The generated <c>Default</c> property is a static of this
|
||||
/// same class, so reading it from this type's initialiser is a cycle: the accessor runs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sends and reads request bodies the way a real client does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not a convenience. <c>PostAsJsonAsync</c>'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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything here goes through <see cref="DodoSshJsonContext"/> for that reason. If the server's JSON
|
||||
/// configuration regresses, these tests are the ones that must fail.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ContractJson
|
||||
{
|
||||
private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions;
|
||||
|
||||
internal static Task<HttpResponseMessage> PostContractAsync<T>(
|
||||
this HttpClient client,
|
||||
string url,
|
||||
T value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
internal static Task<T?> ReadContractAsync<T>(this HttpContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
return content.ReadFromJsonAsync<T>(Options, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Reads an RFC 9457 problem body.</summary>
|
||||
/// <remarks>
|
||||
/// Deliberately <em>not</em> through the contract options. Problem details are written by the framework
|
||||
/// and are not part of <see cref="DodoSshJsonContext"/>'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 <c>code</c> extension belongs to us.
|
||||
/// </remarks>
|
||||
internal static Task<JsonProblem?> ReadProblemAsync(this HttpContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
return content.ReadFromJsonAsync<JsonProblem>(TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncPushResponse>();
|
||||
var body = await push.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<EnrollmentResponse>(responses.Length);
|
||||
foreach (var response in responses)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<EnrollmentResponse>();
|
||||
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
||||
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<MeResponse>();
|
||||
var me = await response.Content.ReadContractAsync<MeResponse>();
|
||||
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<EnrollmentResponse>();
|
||||
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
||||
body.ShouldNotBeNull();
|
||||
return body;
|
||||
}
|
||||
@@ -884,7 +884,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
response.StatusCode.ShouldBe(expectedStatus);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<JsonProblem>();
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
problem.Code.ShouldBe(expectedCode);
|
||||
}
|
||||
|
||||
@@ -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<JsonProblem>();
|
||||
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<DodoDbContext>();
|
||||
@@ -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<SyncPushResponse>();
|
||||
var pushed = await push.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPullResponse>();
|
||||
var pulled = await pull.Content.ReadContractAsync<SyncPullResponse>();
|
||||
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<SyncPullResponse>();
|
||||
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
|
||||
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<SyncPullResponse>();
|
||||
.Content.ReadContractAsync<SyncPullResponse>();
|
||||
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<SyncPullResponse>();
|
||||
.Content.ReadContractAsync<SyncPullResponse>();
|
||||
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<SyncPullResponse>())!.NextCursor;
|
||||
var cursor = (await pull.Content.ReadContractAsync<SyncPullResponse>())!.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<JsonProblem>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await stale.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await replay.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<SyncPullResponse>();
|
||||
var body = await pull.Content.ReadContractAsync<SyncPullResponse>();
|
||||
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<SyncPushResponse>();
|
||||
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
|
||||
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace DodoSSH.Contracts.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Applying the contract's settings to options this process does not own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host cannot be handed <c>StrictRequestOptions</c> — 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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<SyncPullRequest>(
|
||||
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<JsonException>(() => JsonSerializer.Deserialize<SyncPullRequest>(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<JsonException>(() => JsonSerializer.Deserialize<EncryptedPayload>(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<NotSupportedException>(() => 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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Mirrors ASP.NET Core's own options: web defaults <em>with</em> a reflection resolver already
|
||||
/// installed, which is the state <c>ConfigureHttpJsonOptions</c> hands over.
|
||||
/// </remarks>
|
||||
private static JsonSerializerOptions Applied()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
|
||||
};
|
||||
|
||||
DodoSshJsonContext.ApplyTo(options);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private sealed record Unrelated(string Name);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// The development stack this suite talks to, and how to find out whether it is there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately targets a stack the developer brought up rather than starting its own containers. That
|
||||
/// makes the suite test the configuration that is actually committed — the realm file, the API's
|
||||
/// appsettings, the migration history — instead of a parallel arrangement assembled for the test, which is
|
||||
/// where a divergence between "works in the suite" and "works when you run it" comes from.
|
||||
/// <para>
|
||||
/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Explains why the suite cannot run, or returns null when it can.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Keycloak user this test run owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fresh account per run, rather than the realm's <c>alice</c>. 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".
|
||||
/// </remarks>
|
||||
internal static async Task<DevStackUser> 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<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
|
||||
{
|
||||
using var form = new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>(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<bool> RespondsAsync(
|
||||
HttpClient http,
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An account created for one test run.</summary>
|
||||
internal sealed record DevStackUser(string Username, string Password);
|
||||
@@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
M1's definition of done, automated: a real DodoSSH API, a real PostgreSQL, a real Keycloak and a real
|
||||
sshd, driven by the real client from sign-in to an interactive shell.
|
||||
|
||||
Nothing here is stubbed. Every other suite in the repository substitutes something — a stubbed identity
|
||||
provider, an in-memory vault server, an in-memory cache — and each of those substitutions is a place a
|
||||
misunderstanding of the protocol can hide on both sides at once. This suite exists to catch exactly
|
||||
that, and it already has: the realm file registered a loopback redirect URI that Keycloak rejects,
|
||||
which no stub would ever have noticed.
|
||||
|
||||
It needs the development stack up and is skipped otherwise. The commands are in the README under
|
||||
"End-to-end verification"; a skipped run prints them too. (They cannot be repeated here: an XML
|
||||
comment may not contain a double hyphen, and every one of them has a flag.)
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Exit code 8 is "no tests ran". Microsoft.Testing.Platform reports that as a failure, so an assembly
|
||||
whose only test skips would fail the solution-wide `dotnet test` on any machine without the stack up.
|
||||
The minimum-expected-tests option cannot express this: it demands a non-zero integer.
|
||||
|
||||
Narrow enough to be safe here: this project contains exactly one test, so if it runs at all it either
|
||||
passes or fails, and 8 can only mean it skipped for want of the stack.
|
||||
-->
|
||||
<TestingPlatformCommandLineArguments>--ignore-exit-code 8</TestingPlatformCommandLineArguments>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Testcontainers" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <c>sshd</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <remarks>
|
||||
/// 64 MiB is the floor <c>EnrollmentLimits</c> 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.
|
||||
/// </remarks>
|
||||
private static readonly Argon2Profile ServerFloorProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
|
||||
|
||||
private readonly List<string> directories = [];
|
||||
|
||||
private string? unavailable;
|
||||
private IContainer? sshd;
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 ----
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<HostSecret> 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();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<ClientCacheFactory> 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<VaultSession> UnlockAsync(ClientCacheFactory caches)
|
||||
{
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
return outcome.Session!;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static async Task<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using DodoSSH.Client.Auth;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// Signs in to a real Keycloak by driving its login form over HTTP.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything up to the redirect is awaited; the final hop is not.</b> That split is not tidiness.
|
||||
/// <c>OidcClient</c> awaits the launcher before it awaits the callback, and the last hop of this flow is a
|
||||
/// request <em>to</em> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
|
||||
{
|
||||
/// <summary>How many times a sign-in was driven, so the key binding's second one is visible.</summary>
|
||||
internal int SignInCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fetches the login page and posts the credentials, returning where Keycloak sends us.</summary>
|
||||
private async Task<Uri> 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<string, string>(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));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Only the <c>name=value</c> part of each <c>Set-Cookie</c>, which is all a request may send back.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>Surfaces Keycloak's own error text, which is the only useful part of a rejection page.</remarks>
|
||||
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(
|
||||
"""<form[^>]*id="kc-form-login"[^>]*action="(?<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)[^"]*"[^>]*>(?<text>[^<]{1,300})<""",
|
||||
RegexOptions.IgnoreCase,
|
||||
matchTimeoutMilliseconds: 2000)]
|
||||
private static partial Regex ErrorMessage();
|
||||
|
||||
[GeneratedRegex("""<title>(?<text>[^<]{0,200})</title>""", RegexOptions.IgnoreCase,
|
||||
matchTimeoutMilliseconds: 2000)]
|
||||
private static partial Regex PageTitle();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user