Files
jaap-jan 1d262b7ccc 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.
2026-07-29 11:37:49 +02:00

147 lines
6.0 KiB
C#

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);
}