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 <noreply@anthropic.com>
This commit is contained in:
parent
baa70bcef0
commit
1ffb8886c0
4 changed files with 98 additions and 26 deletions
|
|
@ -33,7 +33,7 @@ public enum HeimdallProofTier
|
|||
public sealed record HeimdallMoveReceipt(
|
||||
string MoveId, string AccountId, IReadOnlyList<string> MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId);
|
||||
|
||||
/// <summary>Heimdall's fixed refusal codes for a move, plus the two local classes: a 400, and a 4xx this client does not know.</summary>
|
||||
/// <summary>Heimdall's fixed refusal codes for a move, plus the one local class: Heimdall's schema 400.</summary>
|
||||
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<HeimdallMoveOutcome> 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)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}""",
|
||||
"[]"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, "<html><body>404 Not Found</body></html>")]
|
||||
[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<Unknown>(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<Unknown>(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<HeimdallIdentityMoveClient>.Instance);
|
||||
|
||||
Assert.IsType<Unknown>(await client.MoveAsync(Plain, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Throwing(Exception error) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> 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());
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ public sealed class FakeMoves(Func<DateTimeOffset> 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");
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue