From 1ffb8886c07074305a9cd143bd82e61a51189bc4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:50:42 +0000 Subject: [PATCH] Move client: Refused only for Heimdall's coded move refusals, any pipeline exception is Unknown, ids end at \z An uncoded 4xx (gateway 404, 408, 410, unknown code) is Unknown: Refused wipes sealed proofs. Unrecognized leaves the refusal enum. The catch lists no types: only the caller's cancellation propagates. moveId and reversesMoveId patterns use \z in client and fake, so a trailing line feed is refused as Heimdall's ajv refuses it; 64, 65 and newline are pinned. Co-Authored-By: Claude Sonnet 5.5 --- .../Heimdall/HeimdallIdentityMoveClient.cs | 44 +++++++---- .../FakeHeimdallMoverTests.cs | 3 + .../HeimdallIdentityMoveClientTests.cs | 75 ++++++++++++++++--- tests/Bifrost.Web.Tests/Support/FakeMoves.cs | 2 +- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index 94b7853..e485b37 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -33,7 +33,7 @@ public enum HeimdallProofTier public sealed record HeimdallMoveReceipt( string MoveId, string AccountId, IReadOnlyList MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId); -/// Heimdall's fixed refusal codes for a move, plus the two local classes: a 400, and a 4xx this client does not know. +/// Heimdall's fixed refusal codes for a move, plus the one local class: Heimdall's schema 400. public enum HeimdallMoveRefusal { AppAuthRequired, @@ -46,8 +46,7 @@ public enum HeimdallMoveRefusal AlreadyMoved, ProviderConflict, IdentityNotFound, - RequestInvalid, - Unrecognized + RequestInvalid } public enum HeimdallProofSide @@ -90,7 +89,8 @@ public sealed partial class HeimdallIdentityMoveClient( private const int MaxReceiptBytes = 64 * 1024; private const int MaxProofLength = 8192; - [GeneratedRegex("^[A-Za-z0-9_-]{16,64}$")] + // \z, not $: $ also matches before a final line feed, which Heimdall's schema pattern refuses with a 400. + [GeneratedRegex("^[A-Za-z0-9_-]{16,64}\\z")] private static partial Regex MoveIdShape(); public async Task MoveAsync(HeimdallMoveRequest request, CancellationToken cancellationToken) @@ -128,19 +128,28 @@ public sealed partial class HeimdallIdentityMoveClient( return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); } - var refusal = await HeimdallRefusal.ReadAsync(response, token); - return status == 400 ? new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid) : Refused(refusal); + // Fastify's schema 400 answers before the route runs, so it carries no Heimdall code and applied nothing. + if (status == 400) + { + return new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid); + } + + return Refused(await HeimdallRefusal.ReadAsync(response, token), status); } - catch (Exception error) when (error is HttpRequestException or IOException or JsonException or NotSupportedException or OperationCanceledException - && !cancellationToken.IsCancellationRequested) + catch (Exception) when (!cancellationToken.IsCancellationRequested) { + // Whatever the pipeline throws after the request may have gone out is not a refusal; only the caller's own cancellation propagates. return new HeimdallMoveOutcome.Unknown("transport"); } } - private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal) + /// + /// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute). A 4xx without one of those codes + /// (a gateway's HTML 404, a 408, a code a later Heimdall adds) proves nothing was applied, so it is Unknown. move_id_conflict is + /// Unknown too: a committed move answers it when its proofs no longer verify. + /// + private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal, int status) { - // Not a refusal: a committed move answers 409 move_id_conflict when its proofs no longer verify, so it proves nothing about the move. if (refusal?.Code == "move_id_conflict") { return new HeimdallMoveOutcome.Unknown("move_id_conflict"); @@ -158,18 +167,23 @@ public sealed partial class HeimdallIdentityMoveClient( "already_moved" => HeimdallMoveRefusal.AlreadyMoved, "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, - _ => HeimdallMoveRefusal.Unrecognized + _ => (HeimdallMoveRefusal?)null }; - return code switch + if (code is not { } known) { - HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(code, Side: refusal?.Proof switch + return new HeimdallMoveOutcome.Unknown($"HTTP {status} without a Heimdall refusal code"); + } + + return known switch + { + HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(known, Side: refusal?.Proof switch { "from" => HeimdallProofSide.From, "to" => HeimdallProofSide.To, _ => null }), - HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(code, Provider: refusal?.Provider), - _ => new HeimdallMoveOutcome.Refused(code) + HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(known, Provider: refusal?.Provider), + _ => new HeimdallMoveOutcome.Refused(known) }; } diff --git a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs index b1311a5..b49c8be 100644 --- a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs +++ b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs @@ -63,6 +63,9 @@ public sealed class FakeHeimdallMoverTests Move(Id(), Enumerable.Repeat("p", 9).ToArray(), "x"), Move(Id(), [new string('p', 8193)], "x"), Move("short", [strong()], "x"), + Move(Id() + "\n", [strong()], "x"), + Move(new string('a', 65), [strong()], "x"), + Move(Id(), [strong()], reverses: Id() + "\n"), """{"moveId":"move-0123456789abcdef","fromProofs":"p","toProof":"x"}""", "[]" }; diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index 8157cd1..8055c6d 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -292,23 +292,70 @@ public sealed class HeimdallIdentityMoveClientTests } [Fact] - public async Task A_missing_route_is_Unrecognized_and_a_400_is_RequestInvalid_both_definitive_refusals() + public async Task A_400_is_RequestInvalid_a_definitive_refusal_that_moves_nothing() { using var host = new HeimdallHost(); var moves = host.Heimdall.Moves; var (source, _) = AccountWith(moves, "discord"); var (destination, _) = AccountWith(moves, "twitch"); - var request = Forward(moves, source, destination); - - moves.MoveStatus = HttpStatusCode.NotFound; - Assert.Equal(new Refused(HeimdallMoveRefusal.Unrecognized), await Move(host, request)); moves.MoveStatus = HttpStatusCode.BadRequest; - Assert.Equal(new Refused(HeimdallMoveRefusal.RequestInvalid), await Move(host, request)); - moves.MoveStatus = (HttpStatusCode)418; - Assert.Equal(new Refused(HeimdallMoveRefusal.Unrecognized), await Move(host, request)); + + Assert.Equal(new Refused(HeimdallMoveRefusal.RequestInvalid), await Move(host, Forward(moves, source, destination))); Assert.Empty(moves.Stored); } + [Theory] + [InlineData(HttpStatusCode.NotFound, "404 Not Found")] + [InlineData(HttpStatusCode.NotFound, "")] + [InlineData(HttpStatusCode.RequestTimeout, "")] + [InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")] + [InlineData(HttpStatusCode.Gone, "")] + [InlineData(HttpStatusCode.UnavailableForLegalReasons, "")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"weird_code"}""")] + [InlineData(HttpStatusCode.Forbidden, """{"message":"no error field"}""")] + [InlineData((HttpStatusCode)418, """{"error":"app_auth_required_sort_of"}""")] + public async Task A_4xx_without_one_of_Heimdalls_move_codes_is_Unknown_because_it_proves_nothing_was_applied_no_more_than_a_gateway_does( + HttpStatusCode status, string body) + { + Assert.IsType(await Answered(body, Plain, status)); + } + + [Fact] + public async Task A_404_with_no_body_from_the_route_is_Unknown_not_identity_not_found() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + moves.MoveStatus = HttpStatusCode.NotFound; + + Assert.IsType(await Move(host, Forward(moves, source, destination))); + } + + [Fact] + public async Task An_exception_of_any_kind_from_the_pipeline_is_Unknown_and_only_the_callers_cancellation_propagates() + { + Exception[] thrown = + [ + new InvalidOperationException("handler"), new ObjectDisposedException("connection"), + new System.Security.Authentication.AuthenticationException("tls"), new InvalidDataException("zip"), new UriFormatException("uri"), + new HttpRequestException("down"), new IOException("reset"), new TaskCanceledException("deadline") + ]; + foreach (var error in thrown) + { + var client = new HeimdallIdentityMoveClient( + new HttpClient(new Throwing(error)) { BaseAddress = new Uri("https://heimdall.test") }, + Options.Create(new HeimdallOptions()), NullLogger.Instance); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + } + } + + private sealed class Throwing(Exception error) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw error; + } + // ---- unknown outcomes ---- [Theory] @@ -475,7 +522,13 @@ public sealed class HeimdallIdentityMoveClientTests new(good, proofs, IdentityIds: ["i"], ReversesMoveId: NewMoveId()), new(good, proofs, "t", []), new(good, proofs, "t", Enumerable.Repeat("i", 33).ToArray()), - new(good, [""], "t") + new(good, [""], "t"), + new(good + "\n", proofs, "t"), + new(good, proofs, ReversesMoveId: good + "\n"), + new("move-0123456789abcdef\n", proofs, "t"), + new(new string('a', 65), proofs, "t"), + new(new string('a', 15), proofs, "t"), + new(good, proofs, ReversesMoveId: new string('a', 65)) }; foreach (var request in broken) @@ -543,7 +596,9 @@ public sealed class HeimdallIdentityMoveClientTests var (destination, _) = AccountWith(moves, "twitch"); var proofs = Enumerable.Range(0, 8).Select(_ => moves.Issue(source, stepUpMethod: Passkey)).ToArray(); - var receipt = Receipt(await Move(host, new(NewMoveId(), proofs, moves.Issue(destination), ids))); + var longest = new string('m', 64); + var receipt = Receipt(await Move(host, new(longest, proofs, moves.Issue(destination), ids))); + Assert.Equal(longest, receipt.MoveId); Assert.Equal(ids, receipt.MovedIdentityIds); Assert.Equal(8, JsonDocument.Parse(moves.Requests[0].Body).RootElement.GetProperty("fromProofs").GetArrayLength()); diff --git a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs index e1df159..ef3eb35 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs @@ -135,7 +135,7 @@ public sealed class FakeMoves(Func now) private sealed record Move(string MoveId, string[] FromProofs, string? ToProof, string[]? IdentityIds, string? ReversesMoveId); - private static readonly System.Text.RegularExpressions.Regex MoveIdShape = new("^[A-Za-z0-9_-]{16,64}$"); + private static readonly System.Text.RegularExpressions.Regex MoveIdShape = new(@"^[A-Za-z0-9_-]{16,64}\z"); /// /// Heimdall's route schema (app.ts, the identity-moves route): moveId pattern, 1 to 8 fromProofs of 1 to 8192 chars, toProof, 1 to 32