From ee700d49313d335a6fd143d9059ebd20c3505268 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 02:20:25 +0000 Subject: [PATCH 01/20] Test: snapshot has no drift from the design-time model (passes at base 425af74) Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/SnapshotDriftTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/Bifrost.Web.Tests/SnapshotDriftTests.cs diff --git a/tests/Bifrost.Web.Tests/SnapshotDriftTests.cs b/tests/Bifrost.Web.Tests/SnapshotDriftTests.cs new file mode 100644 index 0000000..ce89dc4 --- /dev/null +++ b/tests/Bifrost.Web.Tests/SnapshotDriftTests.cs @@ -0,0 +1,28 @@ +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class SnapshotDriftTests +{ + // Migrations here are hand-written with a hand-edited snapshot, so the snapshot is compared with the model the app runs on: + // a column or index the model has and the snapshot lacks would never reach a database. + [Fact] + public void The_model_snapshot_has_no_drift_from_the_design_time_model() + { + using var db = new BifrostDbContext(new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options); + var snapshotModel = db.GetService().ModelSnapshot!.Model; + var designModel = db.GetService().Model; + var differ = db.GetService(); + + var differences = differ.GetDifferences( + db.GetService().Initialize(snapshotModel).GetRelationalModel(), + designModel.GetRelationalModel()); + + Assert.Empty(differences.Select(x => x.GetType().Name + ": " + x)); + } +} -- 2.47.3 From b54cf271012e0f9d5deffa9fb56c529b116f5230 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 02:27:38 +0000 Subject: [PATCH 02/20] Heimdall identity-move client: closed Moved/Refused/Unknown outcome POST /v1/apps/bifrost/identity-moves under the app secret (HeimdallAuthService.ConfigureClient, same header redaction), 15 s timeout, request validated to the contract's closed body before any I/O, 200 trusted only as a whole receipt answering this request. HeimdallRefusal gains ReadAsync (code plus safe proof side and provider); CodeAsync delegates to it. Co-Authored-By: Claude Sonnet 5.5 --- .../Heimdall/HeimdallIdentityMoveClient.cs | 267 ++++++++++++++++++ .../Features/Heimdall/HeimdallRefusal.cs | 30 +- src/Bifrost.Web/Program.cs | 7 + 3 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs new file mode 100644 index 0000000..b670aee --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -0,0 +1,267 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// What Bifrost asks Heimdall to move (docs/service-contract.md, POST /v1/apps/{app}/identity-moves). Exactly one of +/// (a forward move, optionally of ) and (the undo). The body +/// never names an account: the proofs, live Heimdall access tokens, say which accounts are meant. +/// +public sealed record HeimdallMoveRequest( + string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null); + +public enum HeimdallProofTier +{ + Strong, + Weak +} + +/// The receipt of a move: the destination account, the logins that moved and the tier of the proofs that carried it. +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. +public enum HeimdallMoveRefusal +{ + AppAuthRequired, + NotAllowedForApp, + NotReversible, + ProofInvalid, + SameAccount, + ProofStale, + IdentityNotProved, + AlreadyMoved, + ProviderConflict, + MoveIdConflict, + IdentityNotFound, + RequestInvalid, + Unrecognized +} + +public enum HeimdallProofSide +{ + From, + To +} + +/// +/// The closed answer to a move. Moved: the receipt. Refused: Heimdall answered a 4xx before applying anything, so nothing changed. +/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one): the caller replays the same request. +/// +public abstract record HeimdallMoveOutcome +{ + private HeimdallMoveOutcome() + { + } + + public sealed record Moved(HeimdallMoveReceipt Receipt) : HeimdallMoveOutcome; + + public sealed record Refused(HeimdallMoveRefusal Code, HeimdallProofSide? Side = null, string? Provider = null) : HeimdallMoveOutcome; + + public sealed record Unknown(string Reason) : HeimdallMoveOutcome; +} + +/// +/// Heimdall's move route under Bifrost's app secret (HeimdallAuthService.ConfigureClient adds the header). Each call sends one request and +/// retries nothing: replay with the same is the caller's, and Heimdall answers it with the stored +/// receipt. Logs name the outcome class and refusal code only. +/// +public sealed partial class HeimdallIdentityMoveClient( + HttpClient httpClient, IOptions heimdallOptions, ILogger logger) +{ + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private const int MaxMovedIdentities = 256; + + [GeneratedRegex("^[A-Za-z0-9_-]{16,64}$")] + private static partial Regex MoveIdShape(); + + public async Task MoveAsync(HeimdallMoveRequest request, CancellationToken cancellationToken) + { + var body = Serialize(request); + var outcome = await SendAsync(request, body, cancellationToken); + logger.LogInformation("Heimdall identity move: {Outcome} {Code}", outcome.GetType().Name, outcome is HeimdallMoveOutcome.Refused refused ? refused.Code : null); + return outcome; + } + + private async Task SendAsync(HeimdallMoveRequest request, string body, CancellationToken cancellationToken) + { + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/identity-moves") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var status = (int)response.StatusCode; + if (response.StatusCode == HttpStatusCode.OK) + { + return await ReadReceiptAsync(response, request, cancellationToken) is { } receipt + ? new HeimdallMoveOutcome.Moved(receipt) + : new HeimdallMoveOutcome.Unknown("malformed or mismatched receipt"); + } + + if (status is < 400 or >= 500 || status == 429) + { + return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); + } + + var refusal = await HeimdallRefusal.ReadAsync(response, cancellationToken); + return status == 400 ? new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid) : Refused(refusal); + } + catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + && !cancellationToken.IsCancellationRequested) + { + return new HeimdallMoveOutcome.Unknown("transport"); + } + } + + private static HeimdallMoveOutcome.Refused Refused(HeimdallRefusalBody? refusal) + { + var code = refusal?.Code switch + { + "app_auth_required" => HeimdallMoveRefusal.AppAuthRequired, + "not_allowed_for_app" => HeimdallMoveRefusal.NotAllowedForApp, + "not_reversible" => HeimdallMoveRefusal.NotReversible, + "proof_invalid" => HeimdallMoveRefusal.ProofInvalid, + "same_account" => HeimdallMoveRefusal.SameAccount, + "proof_stale" => HeimdallMoveRefusal.ProofStale, + "identity_not_proved" => HeimdallMoveRefusal.IdentityNotProved, + "already_moved" => HeimdallMoveRefusal.AlreadyMoved, + "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, + "move_id_conflict" => HeimdallMoveRefusal.MoveIdConflict, + "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, + _ => HeimdallMoveRefusal.Unrecognized + }; + return code switch + { + HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new(code, Side: refusal?.Proof switch + { + "from" => HeimdallProofSide.From, + "to" => HeimdallProofSide.To, + _ => null + }), + HeimdallMoveRefusal.ProviderConflict => new(code, Provider: refusal?.Provider), + _ => new(code) + }; + } + + /// + /// A 200 body is a receipt only when it is whole and answers this request: its moveId (and reversesMoveId) equal the request's, + /// a forward move names a destination proof tier and a reversal does not. Anything else is not trusted as a move. + /// + private static async Task ReadReceiptAsync(HttpResponseMessage response, HeimdallMoveRequest request, CancellationToken cancellationToken) + { + using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || Text(root, "moveId") != request.MoveId + || Text(root, "reversesMoveId") != request.ReversesMoveId + || Text(root, "accountId") is not { Length: > 0 } account + || !root.TryGetProperty("movedIdentityIds", out var moved) + || moved.ValueKind != JsonValueKind.Array + || moved.GetArrayLength() is 0 or > MaxMovedIdentities + || !root.TryGetProperty("proof", out var proof) + || proof.ValueKind != JsonValueKind.Object + || Tier(proof, "from") is not { } from) + { + return null; + } + + var ids = new List(); + foreach (var item in moved.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out _)) + { + return null; + } + + ids.Add(item.GetString()!); + } + + HeimdallProofTier? to = null; + if (request.ReversesMoveId is null) + { + to = Tier(proof, "to"); + if (to is null) + { + return null; + } + } + else if (!proof.TryGetProperty("to", out var none) || none.ValueKind != JsonValueKind.Null) + { + return null; + } + + return new HeimdallMoveReceipt(request.MoveId, account, ids, from, to, request.ReversesMoveId); + } + + private static HeimdallProofTier? Tier(JsonElement proof, string name) => Text(proof, name) switch + { + "strong" => HeimdallProofTier.Strong, + "weak" => HeimdallProofTier.Weak, + _ => null + }; + + private static string? Text(JsonElement item, string name) => + item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + /// The contract's closed body, or ArgumentException before any I/O when the request breaks it. + private static string Serialize(HeimdallMoveRequest request) + { + if (request.MoveId is null || !MoveIdShape().IsMatch(request.MoveId)) + { + throw new ArgumentException("moveId must match ^[A-Za-z0-9_-]{16,64}$", nameof(request)); + } + + if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException("fromProofs must hold 1 to 8 proofs", nameof(request)); + } + + if ((request.ToProof is null) == (request.ReversesMoveId is null)) + { + throw new ArgumentException("exactly one of toProof and reversesMoveId is required", nameof(request)); + } + + if (request.ToProof is not null && string.IsNullOrWhiteSpace(request.ToProof)) + { + throw new ArgumentException("toProof is empty", nameof(request)); + } + + if (request.ReversesMoveId is not null && !MoveIdShape().IsMatch(request.ReversesMoveId)) + { + throw new ArgumentException("reversesMoveId must match the moveId shape", nameof(request)); + } + + if (request.IdentityIds is not null + && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrWhiteSpace))) + { + throw new ArgumentException("identityIds are 1 to 32 and only with toProof", nameof(request)); + } + + var body = new Dictionary { ["moveId"] = request.MoveId, ["fromProofs"] = request.FromProofs }; + if (request.ToProof is not null) + { + body["toProof"] = request.ToProof; + } + + if (request.IdentityIds is not null) + { + body["identityIds"] = request.IdentityIds; + } + + if (request.ReversesMoveId is not null) + { + body["reversesMoveId"] = request.ReversesMoveId; + } + + return JsonSerializer.Serialize(body); + } +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs index fea10aa..c6bd638 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs @@ -11,7 +11,14 @@ public static class HeimdallRefusal /// The error code in the first 4 KiB of a body when it is a short snake_case token, else null. A body that cannot be /// read or parsed yields null: a refusal never becomes a 500, and free text or tokens never reach a log or a page. /// - public static async Task CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) + public static async Task CodeAsync(HttpResponseMessage response, CancellationToken cancellationToken) => + (await ReadAsync(response, cancellationToken))?.Code; + + /// + /// The error code plus the two qualifiers a move refusal carries (the contract's `proof`, only "from" or "to", and `provider`, a + /// short snake_case token), each kept only when it is that safe shape. Nothing else of the body is read. Null when no code reads. + /// + public static async Task ReadAsync(HttpResponseMessage response, CancellationToken cancellationToken) { try { @@ -24,13 +31,10 @@ public static class HeimdallRefusal } using var body = JsonDocument.Parse(head.AsMemory(0, length)); - if (body.RootElement.ValueKind == JsonValueKind.Object - && body.RootElement.TryGetProperty("error", out var error) - && error.ValueKind == JsonValueKind.String - && error.GetString() is { Length: > 0 and <= 64 } code - && code.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_')) + if (body.RootElement.ValueKind == JsonValueKind.Object && Token(body.RootElement, "error", 64) is { } code) { - return code; + var proof = body.RootElement.TryGetProperty("proof", out var side) && side.ValueKind == JsonValueKind.String ? side.GetString() : null; + return new HeimdallRefusalBody(code, proof is "from" or "to" ? proof : null, Token(body.RootElement, "provider", 32)); } } catch (Exception) when (!cancellationToken.IsCancellationRequested) @@ -39,4 +43,16 @@ public static class HeimdallRefusal return null; } + + private static string? Token(JsonElement root, string name, int maxLength) => + root.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String + && value.GetString() is { Length: > 0 } text + && text.Length <= maxLength + && text.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_') + ? text + : null; } + +/// A Heimdall refusal's safe fields: the code, the proof side ("from"/"to") and the provider, when present and well formed. +public sealed record HeimdallRefusalBody(string Code, string? Proof, string? Provider); diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 48c0f74..2590f77 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -158,6 +158,13 @@ builder.Services.AddHttpClient((serviceProvider, client) => builder.Services.AddHttpClient((serviceProvider, client) => HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction. +builder.Services.AddHttpClient((serviceProvider, client) => + { + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); + client.Timeout = HeimdallIdentityMoveClient.Timeout; + }) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -- 2.47.3 From fbb5853b1d76cd5185242e0395352dfca2da6366 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 02:27:38 +0000 Subject: [PATCH 03/20] Fake Heimdall mover and consumer-side move client tests FakeHeimdall.Moves owns per-account logins, a bearer registry, revoked sessions and stored moves, and answers the identity-moves route in decideMove's order (4fe367a): replay before proofs, strict passkey coverage, freshness windows, first-receipt replay. Fault switches for status, apply-then-fail and down. Co-Authored-By: Claude Sonnet 5.5 --- .../HeimdallIdentityMoveClientTests.cs | 442 ++++++++++++++++++ tests/Bifrost.Web.Tests/Support/FakeMoves.cs | 304 ++++++++++++ .../Support/HeimdallTestKit.cs | 11 + 3 files changed, 757 insertions(+) create mode 100644 tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/FakeMoves.cs diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs new file mode 100644 index 0000000..8b312ba --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -0,0 +1,442 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Tests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moved = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Moved; +using Refused = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Refused; +using Unknown = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Unknown; + +namespace Bifrost.Web.Tests; + +/// +/// Bifrost's client for Heimdall's identity-move route, from the caller's side, against the fake Heimdall's account model, which answers +/// as Heimdall 4fe367a's service contract does. Moved and Refused are definitive; anything else is Unknown and is healed by replaying the same moveId. +/// +public sealed class HeimdallIdentityMoveClientTests +{ + private const string Passkey = "bifrost-passkey"; + private const string EmailCode = "bifrost-email-code"; + + private static string NewMoveId() => "move-" + Guid.NewGuid().ToString("N"); + + private static string NewAccount() => HeimdallShapes.NewAccountId(); + + private static HeimdallIdentityMoveClient ClientOf(HeimdallHost host) => host.Services.GetRequiredService(); + + private static Task Move(HeimdallHost host, HeimdallMoveRequest request) => + ClientOf(host).MoveAsync(request, CancellationToken.None); + + private static HeimdallMoveReceipt Receipt(HeimdallMoveOutcome outcome) => Assert.IsType(outcome).Receipt; + + /// An account holding these providers, one login each, in order. + private static (string Account, string[] Ids) AccountWith(FakeMoves moves, params string[] providers) + { + var account = NewAccount(); + return (account, providers.Select(p => moves.AddLogin(account, p)).ToArray()); + } + + /// The usual forward move of everything: a passkey proof of the source and a weak proof of the destination. + private static HeimdallMoveRequest Forward(FakeMoves moves, string source, string destination, string? destinationLogin = null, string[]? ids = null) => + new(NewMoveId(), [moves.Issue(source, stepUpMethod: Passkey)], moves.Issue(destination, destinationLogin), ids); + + // ---- the forward move and its receipt ---- + + [Fact] + public async Task A_forward_move_with_one_weak_proof_per_login_answers_the_receipt_under_the_app_secret_and_names_no_account() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, destinationIds) = AccountWith(moves, "twitch"); + var request = new HeimdallMoveRequest( + NewMoveId(), [moves.Issue(source, ids[0]), moves.Issue(source, ids[1])], moves.Issue(destination, destinationIds[0])); + + var receipt = Receipt(await Move(host, request)); + + Assert.Equal(new HeimdallMoveReceipt(request.MoveId, destination, ids, HeimdallProofTier.Weak, HeimdallProofTier.Weak, null), receipt with { MovedIdentityIds = ids }); + Assert.Equal(ids, receipt.MovedIdentityIds); + Assert.Equal(["twitch", "discord", "patreon"], moves.Logins[destination].Select(x => x.Provider)); + Assert.Empty(moves.Logins[source]); + var sent = Assert.Single(moves.Requests); + Assert.Equal(HeimdallHost.AppSecret, sent.Secret); + Assert.Null(sent.Authorization); + Assert.DoesNotContain(source, sent.Body); + Assert.DoesNotContain(destination, sent.Body); + var names = JsonDocument.Parse(sent.Body).RootElement.EnumerateObject().Select(x => x.Name).Order().ToArray(); + Assert.Equal(["fromProofs", "moveId", "toProof"], names); + } + + [Fact] + public async Task A_proof_through_one_login_moves_only_that_login_and_only_an_asserted_passkey_speaks_for_the_account() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (destination, _) = AccountWith(moves, "twitch"); + + // Discord proves Discord; Patreon, and so the whole account, is not proved by it. + var (a, aIds) = AccountWith(moves, "discord", "patreon"); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0])], moves.Issue(destination), aIds[1..]))); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0])], moves.Issue(destination)))); + Assert.Equal(2, moves.Logins[a].Count); + + // An email code proves an inbox: strong, fresh, and still only its own login. + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination), aIds[1..]))); + Assert.Equal(2, moves.Logins[a].Count); + + // The same proof does move the one login it came through. + Assert.Equal([aIds[0]], Receipt(await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination), aIds[..1]))).MovedIdentityIds); + (destination, _) = AccountWith(moves, "twitch"); + + // An asserted passkey moves everything. + var (b, bIds) = AccountWith(moves, "discord", "patreon", "google"); + var all = Receipt(await Move(host, Forward(moves, b, destination))); + Assert.Equal(bIds, all.MovedIdentityIds); + Assert.Equal(HeimdallProofTier.Strong, all.ProofFrom); + } + + [Fact] + public async Task A_proof_by_a_stale_weak_sign_in_or_step_up_is_refused_with_its_side_at_the_window_edge() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (destination, _) = AccountWith(moves, "twitch"); + + HeimdallMoveRequest Weak(string source, string login, int ago, int toAgo = 0) => + new(NewMoveId(), [moves.Issue(source, login, authAgo: ago)], moves.Issue(destination, authAgo: toAgo)); + + var (a, a1) = AccountWith(moves, "discord"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.From), await Move(host, Weak(a, a1[0], 301))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.To), await Move(host, Weak(a, a1[0], 0, 301))); + Assert.Equal(HeimdallProofTier.Weak, Receipt(await Move(host, Weak(a, a1[0], 300))).ProofFrom); + + var (b, _) = AccountWith(moves, "patreon"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(b, stepUpMethod: Passkey, authAgo: 601, stepUpAgo: 601)], moves.Issue(destination)))); + Assert.Equal(HeimdallProofTier.Strong, + Receipt(await Move(host, new(NewMoveId(), [moves.Issue(b, stepUpMethod: Passkey, authAgo: 600, stepUpAgo: 600)], moves.Issue(destination)))).ProofFrom); + } + + // ---- replay ---- + + [Fact] + public async Task A_replayed_move_id_answers_the_first_receipt_after_the_sources_sessions_were_revoked_and_the_tokens_expired() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + var first = Receipt(await Move(host, request)); + Assert.True(moves.IsRevoked(request.FromProofs[0])); + host.Clock.Advance(TimeSpan.FromHours(2)); + + var again = Receipt(await Move(host, request)); + + Assert.Equal(first.AccountId, again.AccountId); + Assert.Equal(ids, again.MovedIdentityIds); + Assert.Equal(first.ProofFrom, again.ProofFrom); + Assert.Single(moves.Stored); + Assert.Equal(2, moves.Requests.Count); + } + + [Fact] + public async Task A_replay_with_different_identity_ids_answers_the_first_receipt_and_moves_nothing_more() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var proofs = new[] { moves.Issue(source, stepUpMethod: Passkey) }; + var toProof = moves.Issue(destination); + var moveId = NewMoveId(); + + var first = Receipt(await Move(host, new(moveId, proofs, toProof, ids[..1]))); + var again = Receipt(await Move(host, new(moveId, proofs, toProof, ids))); + + Assert.Equal([ids[0]], first.MovedIdentityIds); + Assert.Equal([ids[0]], again.MovedIdentityIds); + Assert.Equal([ids[1]], moves.Logins[source].Select(x => x.Id)); + } + + [Fact] + public async Task A_move_whose_answer_was_lost_heals_by_replaying_the_same_request() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + + moves.MoveApplyThenFail = HttpStatusCode.InternalServerError; + Assert.IsType(await Move(host, request)); + Assert.Single(moves.Stored); + moves.MoveApplyThenFail = HttpStatusCode.OK; + Assert.IsType(await Move(host, request)); + + moves.MoveApplyThenFail = null; + var healed = Receipt(await Move(host, request)); + Assert.Equal(destination, healed.AccountId); + Assert.Equal(ids, healed.MovedIdentityIds); + Assert.Single(moves.Stored); + } + + // ---- reversal ---- + + [Fact] + public async Task A_reversal_moves_back_exactly_the_logins_the_forward_move_carried_once() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, destinationIds) = AccountWith(moves, "twitch"); + var forward = Forward(moves, source, destination, ids: ids[..1]); + Receipt(await Move(host, forward)); + Assert.Equal(["twitch", "discord"], moves.Logins[destination].Select(x => x.Provider)); + + // The destination's bearer proves a login it holds; the carried one is proved by its own sign-in. + var undo = new HeimdallMoveRequest(NewMoveId(), [moves.Issue(destination, ids[0])], ReversesMoveId: forward.MoveId); + var receipt = Receipt(await Move(host, undo)); + + Assert.Equal(source, receipt.AccountId); + Assert.Equal([ids[0]], receipt.MovedIdentityIds); + Assert.Equal(forward.MoveId, receipt.ReversesMoveId); + Assert.Null(receipt.ProofTo); + Assert.Equal([destinationIds[0]], moves.Logins[destination].Select(x => x.Id)); + Assert.Equal(ids.Order(), moves.Logins[source].Select(x => x.Id).Order()); + + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(destination, destinationIds[0], Passkey)], ReversesMoveId: forward.MoveId))); + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(source, ids[0], Passkey)], ReversesMoveId: undo.MoveId))); + } + + // ---- refusals ---- + + [Fact] + public async Task Each_contract_refusal_code_maps_to_its_member_and_only_provider_conflict_carries_a_provider() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + + // not_allowed_for_app + var (s0, _) = AccountWith(moves, "discord"); + var (d0, _) = AccountWith(moves, "twitch"); + moves.Granted = false; + Assert.Equal(new Refused(HeimdallMoveRefusal.NotAllowedForApp), await Move(host, Forward(moves, s0, d0))); + moves.Granted = true; + + // proof_invalid: unknown, revoked, foreign audience (from side); a lapsed destination proof (to side) + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), ["no-such-bearer"], moves.Issue(d0)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey, aud: "someone-else")], moves.Issue(d0)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.To), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey)], "no-such-bearer"))); + var (other, _) = AccountWith(moves, "google"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey), moves.Issue(other, stepUpMethod: Passkey)], moves.Issue(d0)))); + + // same_account + Assert.Equal(new Refused(HeimdallMoveRefusal.SameAccount), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey)], moves.Issue(s0)))); + + // identity_not_found: the proof is a passkey, the login is nobody's + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotFound), + await Move(host, Forward(moves, s0, d0, ids: [Guid.NewGuid().ToString()]))); + + // provider_conflict: the destination already holds a Discord login + var (s1, _) = AccountWith(moves, "discord"); + var (d1, _) = AccountWith(moves, "discord"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), await Move(host, Forward(moves, s1, d1))); + + // move_id_conflict: a used id under other accounts + var (s2, _) = AccountWith(moves, "patreon"); + var (d2, _) = AccountWith(moves, "twitch"); + var used = Forward(moves, s2, d2); + Receipt(await Move(host, used)); + var (s3, _) = AccountWith(moves, "patreon"); + Assert.Equal(new Refused(HeimdallMoveRefusal.MoveIdConflict), + await Move(host, new(used.MoveId, [moves.Issue(s3, stepUpMethod: Passkey)], moves.Issue(d2)))); + + // already_moved: s2 now holds nothing and an unreversed move left it + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s2, d0))); + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s3, s2))); + + // not_reversible: a move that never happened + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(d2, stepUpMethod: Passkey)], ReversesMoveId: NewMoveId()))); + } + + [Fact] + public async Task A_wrong_app_secret_is_refused_as_app_auth_required() + { + using var host = new HeimdallHost(appSecret: "not-the-secret"); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + + Assert.Equal(new Refused(HeimdallMoveRefusal.AppAuthRequired), await Move(host, Forward(moves, source, destination))); + Assert.Empty(moves.Stored); + } + + [Fact] + public async Task A_missing_route_is_Unrecognized_and_a_400_is_RequestInvalid_both_definitive_refusals() + { + 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.Empty(moves.Stored); + } + + // ---- unknown outcomes ---- + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.Found)] + public async Task A_5xx_a_429_or_any_other_status_is_Unknown_never_a_refusal(HttpStatusCode status) + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + moves.MoveStatus = status; + + Assert.IsType(await Move(host, Forward(moves, source, destination))); + } + + [Fact] + public async Task A_timeout_a_transport_error_and_garbage_with_a_200_are_Unknown_while_the_callers_own_cancellation_propagates() + { + 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.MoveDown = new TaskCanceledException("the client timed out"); + Assert.IsType(await Move(host, request)); + moves.MoveDown = new HttpRequestException("Heimdall is down"); + Assert.IsType(await Move(host, request)); + moves.MoveDown = null; + moves.MoveApplyThenFail = HttpStatusCode.OK; + Assert.IsType(await Move(host, request)); + moves.MoveApplyThenFail = null; + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + await Assert.ThrowsAnyAsync(() => ClientOf(host).MoveAsync(request, cancelled.Token)); + } + + private sealed class Stub(string json) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }); + } + + private static Task Answered(string json, HeimdallMoveRequest request) => + new HeimdallIdentityMoveClient( + new HttpClient(new Stub(json)) { BaseAddress = new Uri("https://heimdall.test") }, + Options.Create(new HeimdallOptions()), NullLogger.Instance).MoveAsync(request, CancellationToken.None); + + [Fact] + public async Task A_200_that_is_not_a_whole_receipt_for_this_request_is_Unknown() + { + var id = Guid.NewGuid().ToString(); + var forward = new HeimdallMoveRequest("move-0123456789abcdef", ["p"], "q"); + var reversal = new HeimdallMoveRequest("move-0123456789abcdef", ["p"], ReversesMoveId: "move-fedcba9876543210"); + string Body(string moveId = "move-0123456789abcdef", string? reverses = null, string proof = """{"from":"weak","to":"weak"}""", string ids = "\"" + "ID" + "\"") => + $$"""{"moveId":"{{moveId}}","accountId":"acct","movedIdentityIds":[{{ids.Replace("ID", id)}}],"proof":{{proof}}{{(reverses is null ? "" : $",\"reversesMoveId\":\"{reverses}\"")}}}"""; + + // The well formed shapes are accepted, so each refusal below is about its one flaw. + Assert.IsType(await Answered(Body(), forward)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":null}"""), reversal)); + + Assert.IsType(await Answered(Body("move-someoneelse12345"), forward)); + Assert.IsType(await Answered(Body(reverses: "move-fedcba9876543210"), forward)); + Assert.IsType(await Answered(Body(), reversal)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-other0123456789", """{"from":"strong","to":null}"""), reversal)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":"weak"}"""), reversal)); + Assert.IsType(await Answered(Body(proof: """{"from":"strong","to":null}"""), forward)); + Assert.IsType(await Answered(Body(proof: """{"from":"maybe","to":"weak"}"""), forward)); + Assert.IsType(await Answered(Body(ids: ""), forward)); + Assert.IsType(await Answered(Body(ids: "\"not-a-guid\""), forward)); + Assert.IsType(await Answered("""{"moveId":"move-0123456789abcdef"}""", forward)); + Assert.IsType(await Answered("[]", forward)); + } + + // ---- the closed body ---- + + [Fact] + public async Task A_request_that_breaks_the_contracts_body_throws_before_any_request_reaches_Heimdall() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var good = NewMoveId(); + var proofs = new[] { "p" }; + var broken = new HeimdallMoveRequest[] + { + new(good, proofs, "t", ReversesMoveId: NewMoveId()), + new(good, proofs), + new(good, Enumerable.Repeat("p", 9).ToArray(), "t"), + new(good, [], "t"), + new("short", proofs, "t"), + new("bad move id with spaces", proofs, "t"), + new(good, proofs, ReversesMoveId: "short"), + new(good, proofs, IdentityIds: ["i"], ReversesMoveId: NewMoveId()), + new(good, proofs, "t", []), + new(good, proofs, "t", Enumerable.Repeat("i", 33).ToArray()), + new(good, [""], "t") + }; + + foreach (var request in broken) + { + await Assert.ThrowsAsync(() => Move(host, request)); + } + + Assert.Empty(moves.Requests); + } + + // ---- what the logs hold ---- + + [Fact] + public async Task With_http_trace_logging_on_no_log_line_holds_a_bearer_the_secret_an_identity_id_or_an_account_id() + { + using var host = new HeimdallHost(traceHttp: true); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + Receipt(await Move(host, request)); + Assert.IsType(await Move(host, Forward(moves, source, destination))); + + var logs = string.Join('\n', host.Logs.Lines); + Assert.Contains("Heimdall identity move: Moved", logs); + Assert.Contains("Heimdall identity move: Refused", logs); + Assert.Contains("identity-moves", logs); + foreach (var secret in new[] { HeimdallHost.AppSecret, source, destination, request.FromProofs[0], request.ToProof!, request.MoveId }.Concat(ids)) + { + Assert.DoesNotContain(secret, logs); + } + } +} diff --git a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs new file mode 100644 index 0000000..b28a0c7 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs @@ -0,0 +1,304 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace Bifrost.Web.Tests.Support; + +/// One login of a fake account. +public sealed record MoveLogin(string Id, string Provider); + +/// A move Heimdall stored: the forward move or its reversal, with the receipt it answered byte for byte. +public sealed class StoredMove(string moveId, string from, string to, string? reversesMoveId, IReadOnlyList carried, string receipt) +{ + public string MoveId { get; } = moveId; + + public string From { get; } = from; + + public string To { get; } = to; + + public string? ReversesMoveId { get; } = reversesMoveId; + + public IReadOnlyList Carried { get; } = carried; + + public string Receipt { get; } = receipt; + + public bool Reversed { get; set; } +} + +/// +/// The fake Heimdall's account model for identity moves, and the route over it: POST /v1/apps/bifrost/identity-moves as Heimdall's +/// moveIdentitiesRoute and decideMove answer it (docs/service-contract.md at 4fe367a). It owns per-account logins, a registry of the +/// bearers it issued (token to claims), revoked sessions and stored moves. Nothing else of the fake reads these yet; the linked +/// accounts list can read later so one model answers both routes. +/// +public sealed class FakeMoves(Func now) +{ + private const string App = "bifrost"; + private const int WeakSeconds = 300; + private const int StrongSeconds = 600; + private const int Lifetime = 900; + + private sealed record Claims(string Account, string Sid, string Aud, long AuthAt, string? AuthIdentityId, long? StepUpAt, string? StepUpMethod, long Exp); + + private readonly Dictionary _tokens = new(StringComparer.Ordinal); + private readonly HashSet _revoked = new(StringComparer.Ordinal); + private readonly Dictionary _moves = new(StringComparer.Ordinal); + + public Dictionary> Logins { get; } = new(StringComparer.Ordinal); + + public IReadOnlyCollection Stored => _moves.Values; + + /// Every request that reached the route: the app secret header, the Authorization header and the raw body. + public List<(string? Secret, string? Authorization, string Body)> Requests { get; } = []; + + /// When false the app's profile lacks identityMoves, so the route answers 403 not_allowed_for_app. + public bool Granted { get; set; } = true; + + /// When set, the route answers this status after the schema check, bypassing the fake's own rules (nothing is applied). + public HttpStatusCode? MoveStatus { get; set; } + + /// When set, a move that passes every rule is applied and then answered with this status (200 answers a body that is not JSON): a lost answer. + public HttpStatusCode? MoveApplyThenFail { get; set; } + + /// When set, the route throws it instead of answering, as an unreachable or timed-out Heimdall does. + public Exception? MoveDown { get; set; } + + public string AddLogin(string account, string provider, string? id = null) + { + id ??= Guid.NewGuid().ToString(); + if (!Logins.TryGetValue(account, out var list)) + { + Logins[account] = list = []; + } + + list.Add(new MoveLogin(id, provider)); + return id; + } + + /// + /// Issues a live bearer of for the bifrost app. A weak proof is a sign-in through + /// at before now; a step-up () adds step_up_at at before now. + /// + public string Issue(string account, string? authIdentityId = null, string? stepUpMethod = null, int authAgo = 0, int? stepUpAgo = null, string aud = App) + { + var at = now().ToUnixTimeSeconds(); + var token = "bearer-" + Guid.NewGuid().ToString("N"); + _tokens[token] = new Claims( + account, Guid.NewGuid().ToString(), aud, at - authAgo, authIdentityId, + stepUpMethod is null ? null : at - (stepUpAgo ?? 0), stepUpMethod, at + Lifetime); + return token; + } + + public bool IsRevoked(string token) => _revoked.Contains(_tokens[token].Sid); + + public HttpResponseMessage Route(HttpRequestMessage request, string body, string? requiredSecret) + { + var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; + Requests.Add((secret, request.Headers.Authorization?.ToString(), body)); + if (MoveDown is not null) + { + throw MoveDown; + } + + if (Parse(body) is not { } move) + { + return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}"""); + } + + if (MoveStatus is { } forced) + { + return forced == HttpStatusCode.NotFound + ? new HttpResponseMessage(forced) + : Json(forced, forced == HttpStatusCode.BadRequest + ? """{"statusCode":400,"error":"Bad Request"}""" + : """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""); + } + + if (secret != requiredSecret) + { + return Refuse(HttpStatusCode.Unauthorized, "app_auth_required"); + } + + if (!Granted) + { + return Refuse(HttpStatusCode.Forbidden, "not_allowed_for_app"); + } + + var applied = Decide(move); + if (applied.Refusal is { } refusal || MoveApplyThenFail is not { } lost) + { + return applied.Refusal ?? Ok(applied.Receipt!); + } + + return lost == HttpStatusCode.OK ? Json(HttpStatusCode.OK, "not json {") : Json(lost, """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""); + } + + 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 Move? Parse(string body) + { + try + { + var root = JsonDocument.Parse(body).RootElement; + string? Text(string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + string[]? List(string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array + ? v.EnumerateArray().Select(x => x.ValueKind == JsonValueKind.String ? x.GetString()! : null!).ToArray() + : null; + var from = List("fromProofs"); + var ids = List("identityIds"); + if (Text("moveId") is not { } id || !MoveIdShape.IsMatch(id) + || from is not { Length: >= 1 and <= 8 } || from.Any(x => x is null) + || ids is { Length: 0 or > 32 } || (ids is not null && ids.Any(x => x is null))) + { + return null; + } + + return new Move(id, from, Text("toProof"), ids, Text("reversesMoveId")); + } + catch (JsonException) + { + return null; + } + } + + private (HttpResponseMessage? Refusal, string? Receipt) Decide(Move move) + { + var at = now().ToUnixTimeSeconds(); + var reversal = move.ReversesMoveId is not null; + + // Replay: registry match of app and accounts, liveness not checked. + var known = move.FromProofs.Select(Known).ToArray(); + if (_moves.TryGetValue(move.MoveId, out var stored)) + { + if (known.Any(x => x is null) || known.Select(x => x!.Account).Distinct().Count() != 1) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "from"), null); + } + + var toKnown = move.ToProof is null ? null : Known(move.ToProof); + if (move.ToProof is not null && toKnown is null) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "to"), null); + } + + return known[0]!.Account == stored.From && toKnown?.Account == (reversal ? null : stored.To) && move.ReversesMoveId == stored.ReversesMoveId + ? (null, stored.Receipt) + : (Refuse(HttpStatusCode.Conflict, "move_id_conflict"), null); + } + + StoredMove? forward = null; + if (reversal && !(_moves.TryGetValue(move.ReversesMoveId!, out forward) && forward.ReversesMoveId is null && !forward.Reversed + && (known[0] is not { } holder || holder.Account == forward.To))) + { + return (Refuse(HttpStatusCode.Conflict, "not_reversible"), null); + } + + // proof_invalid: not a live bearer of this app, or fromProofs naming two accounts. + var from = move.FromProofs.Select(Live).ToArray(); + if (from.Any(x => x is null) || from.Select(x => x!.Account).Distinct().Count() != 1) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "from"), null); + } + + var to = move.ToProof is null ? null : Live(move.ToProof); + if (move.ToProof is not null && to is null) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "to"), null); + } + + var source = from[0]!.Account; + if (to is not null && to.Account == source) + { + return (Refuse(HttpStatusCode.Conflict, "same_account"), null); + } + + // proof_stale: weak 300 s by auth_at, strong 600 s by step_up_at, the host clock. + if (from.Any(x => Tier(x!, at) is null)) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_stale", proof: "from"), null); + } + + if (to is not null && Tier(to, at) is null) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_stale", proof: "to"), null); + } + + var held = Logins.GetValueOrDefault(source) ?? []; + var carried = reversal ? forward!.Carried.ToArray() : move.IdentityIds ?? held.Select(x => x.Id).ToArray(); + var whole = from.Any(x => Tier(x!, at) == "strong" && x!.StepUpMethod == "bifrost-passkey"); + if (!whole && carried.Any(id => from.All(x => x!.AuthIdentityId != id))) + { + return (Refuse(HttpStatusCode.Forbidden, "identity_not_proved"), null); + } + + var destination = reversal ? forward!.From : to!.Account; + if (!reversal && (IsAlias(source) || IsAlias(destination))) + { + return (Refuse(HttpStatusCode.Conflict, "already_moved"), null); + } + + if (carried.Any(id => held.All(x => x.Id != id))) + { + return (Refuse(HttpStatusCode.NotFound, "identity_not_found"), null); + } + + var moving = held.Where(x => carried.Contains(x.Id)).ToList(); + var there = Logins.GetValueOrDefault(destination) ?? []; + if (moving.FirstOrDefault(x => there.Any(y => y.Provider == x.Provider)) is { } clash) + { + return (Refuse(HttpStatusCode.Conflict, "provider_conflict", provider: clash.Provider), null); + } + + // The move: re-home, revoke the source's sessions, store the receipt. + held.RemoveAll(moving.Contains); + Logins[destination] = [.. there, .. moving]; + foreach (var claims in _tokens.Values.Where(x => x.Account == source)) + { + _revoked.Add(claims.Sid); + } + + var strong = from.Any(x => Tier(x!, at) == "strong"); + var receipt = JsonSerializer.Serialize(new Dictionary + { + ["moveId"] = move.MoveId, + ["accountId"] = destination, + ["movedIdentityIds"] = moving.Select(x => x.Id).ToArray(), + ["proof"] = new Dictionary { ["from"] = strong ? "strong" : "weak", ["to"] = to is null ? null : Tier(to, at) }, + ["reversesMoveId"] = move.ReversesMoveId + }.Where(x => x.Key != "reversesMoveId" || x.Value is not null).ToDictionary()); + _moves[move.MoveId] = new StoredMove(move.MoveId, source, destination, move.ReversesMoveId, moving.Select(x => x.Id).ToArray(), receipt); + if (forward is not null) + { + forward.Reversed = true; + } + + return (null, receipt); + } + + private bool IsAlias(string account) => + (Logins.GetValueOrDefault(account)?.Count ?? 0) == 0 && _moves.Values.Any(x => x.From == account && x.ReversesMoveId is null && !x.Reversed); + + private Claims? Known(string token) => _tokens.TryGetValue(token, out var claims) && claims.Aud == App ? claims : null; + + private Claims? Live(string token) => + Known(token) is { } claims && !_revoked.Contains(claims.Sid) && claims.Exp > now().ToUnixTimeSeconds() ? claims : null; + + private static string? Tier(Claims claims, long at) => + claims.StepUpAt is { } stepUp && at - stepUp <= StrongSeconds ? "strong" : at - claims.AuthAt <= WeakSeconds ? "weak" : null; + + private static HttpResponseMessage Json(HttpStatusCode status, string json) => + new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + + private static HttpResponseMessage Ok(string receipt) + { + var response = Json(HttpStatusCode.OK, receipt); + response.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoStore = true }; + return response; + } + + private static HttpResponseMessage Refuse(HttpStatusCode status, string error, string? proof = null, string? provider = null) => + Json(status, JsonSerializer.Serialize(new Dictionary { ["error"] = error, ["proof"] = proof, ["provider"] = provider } + .Where(x => x.Value is not null).ToDictionary())); +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 6ef98a3..688676c 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -200,6 +200,11 @@ public sealed class FakeHeimdall : HttpMessageHandler public Exception? JwksFailure { get; set; } + /// The account model and route of POST /v1/apps/bifrost/identity-moves (the host's clock is ). + public FakeMoves Moves { get; } + + public FakeHeimdall() => Moves = new FakeMoves(() => Now()); + public List StartBodies { get; } = []; public List StartSecrets { get; } = []; @@ -606,6 +611,11 @@ public sealed class FakeHeimdall : HttpMessageHandler return StepUpRoute(request, await request.Content!.ReadAsStringAsync(cancellationToken)); } + if (request.Method == HttpMethod.Post && path == "/v1/apps/bifrost/identity-moves") + { + return Moves.Route(request, await request.Content!.ReadAsStringAsync(cancellationToken), RequiredAppSecret); + } + if (path.StartsWith("/v1/me/identities", StringComparison.Ordinal)) { return MemberRoute(request, path); @@ -807,6 +817,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); + services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); }); } -- 2.47.3 From 84ff2c457816e60da255c62eeace34e1920d898f Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 02:31:51 +0000 Subject: [PATCH 04/20] Account merge ledger: AccountMerge receipt, reductions, alias standing, undo and final sweep AccountMergeLedger owns what a merge does to Bifrost records: PlanAsync (dry run, block codes), CommitAsync (one save, total, idempotent), UndoLocalAsync (journal replay), FinalizeDueAsync (AccountMergeSweeper) and StandingAsync/ResolveAsync. Votes and concept appraisals are voided by a stamp that a model query filter hides from every tally. The actor accessor no longer ORs several credentials under SingleOrDefault (a latent crash); a frozen or folded account resolves to no account on every route, the OIDC authorize and chat refusal deny it, and patron intake follows the alias. Migration AddAccountMerges is additive. No UI, no Heimdall call, nothing deployed. Co-Authored-By: Claude Sonnet 5.5 --- docs/account-merge.md | 49 ++ docs/linked-accounts.md | 2 + src/Bifrost.Web/Data/BifrostDbContext.cs | 36 + .../20261114120000_AddAccountMerges.cs | 150 ++++ .../BifrostDbContextModelSnapshot.cs | 140 ++++ src/Bifrost.Web/Domain/AccountMergeModels.cs | 93 +++ src/Bifrost.Web/Domain/BifrostModels.cs | 9 +- src/Bifrost.Web/Domain/ConceptVoteModels.cs | 5 + .../HeimdallPatronSupportIntakeService.cs | 4 +- .../Features/Membership/AccountMergeLedger.cs | 583 ++++++++++++++ .../Features/Oidc/OidcEndpoints.cs | 9 +- .../Features/Patronage/PatronageService.cs | 73 +- .../Features/Shared/CurrentBifrostActor.cs | 4 + .../Shared/CurrentBifrostActorAccessor.cs | 40 +- src/Bifrost.Web/Program.cs | 2 + .../AccountMergeHttpTests.cs | 150 ++++ .../AccountMergeLedgerTests.cs | 725 ++++++++++++++++++ .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/OidcIssuerTests.cs | 27 + tests/Bifrost.Web.Tests/OidcLogoutTests.cs | 28 + 20 files changed, 2090 insertions(+), 40 deletions(-) create mode 100644 docs/account-merge.md create mode 100644 src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs create mode 100644 src/Bifrost.Web/Domain/AccountMergeModels.cs create mode 100644 src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs create mode 100644 tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs create mode 100644 tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs diff --git a/docs/account-merge.md b/docs/account-merge.md new file mode 100644 index 0000000..da6adfe --- /dev/null +++ b/docs/account-merge.md @@ -0,0 +1,49 @@ +# Account merge ledger + +What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. This is the ledger only: no page, no Heimdall call, and nothing calls `CommitAsync` or `UndoLocalAsync` yet (the saga cut does). + +## Owner + +`Features/Membership/AccountMergeLedger.cs` is the one owner of merge effects. Outside it nothing writes `VoidedByMergeId`, `MembershipStatus.Merged`, `AccountMergeEntry`, or the states Committed, Final and Undone, and nothing but the ledger calls `IgnoreQueryFilters` on `Vote` or `ConceptAppraisal`. The ledger never reads an email, display name or login string; accounts are matched by id. + +## States + +`AccountMerge.State` (stored as a string) declares the whole lifecycle once: `Proposed`, `Confirmed`, `HeimdallMoved`, `Committed`, `Final`, `UndoRequested`, `HeimdallReversed`, `Undone`, `Failed`. Later cuts add columns, not states. + +An account's standing is derived from that state on every request; there is no `MergedInto` column and `Membership.Status = Merged` is never read to decide it. + +| State of the merge the account is folded in | `StandingAsync` | +| --- | --- | +| Confirmed, HeimdallMoved | `Frozen(mergeId)` | +| Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` | +| Proposed, Failed, Undone | none | + +`ResolveAsync(id)` follows `Folded` links to the end (at most eight hops; each account is folded once). Heimdall patron intake resolves through it, so an event for a folded account's Heimdall id records on the survivor. + +`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. + +## Plan, commit, undo, finalize + +`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_survivor_in_grace`, `sealed_ballot_pending`. + +`CommitAsync(mergeId)` needs `HeimdallMoved` (Committed and Final return the receipt; any other state throws). It is total: blocks were checked before the move, so it never refuses on them. It applies everything in one `SaveChanges` together with `State = Committed` and `GraceEndsUtc = CommittedUtc + 7 days`; a failed save leaves the merge `HeimdallMoved` and applies nothing, and a retry applies once. + +What it reduces: + +- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge )`. A survivor without a membership row is not given one. +- **Membership.** The folded membership becomes `Merged` (weight 0 by the not-Active rule). Roles and admin flags of the folded membership are never copied. +- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's weight and cast time; a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched. +- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:` if the survivor is Active, else it is voided. +- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history. + +Voiding stamps `VoidedByMergeId` and `VoidedUtc`; the model's query filter on `Vote` and `ConceptAppraisal` then removes the row from every tally, including future ones. Nothing is deleted. + +Left untouched (counted by the plan): consent receipts, passkeys, ledger entries, points, work and revenue rows. + +`UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to. + +`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`; `AccountMergeSweeper` runs it every five minutes. Undo is never allowed past Final (the saga reads `State` and `GraceEndsUtc`). + +## Not covered + +`MembershipSynchronizationService` still writes the profile of any GitHub-signed-in row it finds, and re-promotes a bootstrap admin; it is not a merge path and was not changed here. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. diff --git a/docs/linked-accounts.md b/docs/linked-accounts.md index 0da0b95..0ba3010 100644 --- a/docs/linked-accounts.md +++ b/docs/linked-accounts.md @@ -66,3 +66,5 @@ A link refused because the login already belongs to another account (`409 identi Several exits leave the held attempt cookie (`__Host-bifrost_heimdall_`, HttpOnly, so script cannot clear it) in the browser until its ten minutes end: the complete page's redirect when Discord or Patreon refuses a link because the login belongs to another account, every other `heimdall_status=error` redirect, and a return with no code or no attempt. The script redirects without posting anything; clearing the cookie would need a server route reached by GET or a post from the failure path, which the hardened script deliberately does not do. A start takes a free slot, else overwrites the slot that expires first, live or dead (`ChooseSlot`, two slots). So three member starts within ten minutes can cost a still-live older attempt, which then finds no cookie and is refused; the member starts again. + +See [account-merge.md](account-merge.md) for what a merge does to Bifrost records, the states it moves through and how a folded account stands. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index f710aa8..1c4757e 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -81,6 +81,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet ConceptAppraisals => Set(); + public DbSet AccountMerges => Set(); + + public DbSet AccountMergeEntries => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => @@ -301,6 +305,8 @@ public sealed class BifrostDbContext(DbContextOptions options) modelBuilder.Entity(entity => { entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique(); + // A merge voids a vote by stamping it; every tally reads through this filter, and only AccountMergeLedger ignores it. + entity.HasQueryFilter(x => x.VoidedByMergeId == null); entity.Property(x => x.Choice).HasConversion(); entity.Property(x => x.Weight).HasPrecision(18, 2); entity.HasOne(x => x.Motion) @@ -607,6 +613,7 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.NetworkTag).HasMaxLength(64); entity.Property(x => x.Stance).HasConversion().HasMaxLength(16); entity.Property(x => x.VoterClass).HasConversion().HasMaxLength(16); + entity.HasQueryFilter(x => x.VoidedByMergeId == null); entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique(); entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc }); entity.HasOne(x => x.Subject) @@ -618,5 +625,34 @@ public sealed class BifrostDbContext(DbContextOptions options) .HasForeignKey(x => x.UserAccountId) .OnDelete(DeleteBehavior.SetNull); }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.State).HasConversion().HasMaxLength(24); + entity.Property(x => x.FailureCode).HasMaxLength(64); + entity.HasIndex(x => new { x.FoldedUserAccountId, x.State }); + entity.HasIndex(x => new { x.SurvivorUserAccountId, x.State }); + entity.HasIndex(x => new { x.State, x.GraceEndsUtc }); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.SurvivorUserAccountId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.FoldedUserAccountId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.Property(x => x.Kind).HasConversion().HasMaxLength(24); + entity.Property(x => x.Action).HasConversion().HasMaxLength(24); + entity.Property(x => x.PriorText).HasMaxLength(120); + entity.HasIndex(x => new { x.MergeId, x.Seq }).IsUnique(); + entity.HasOne(x => x.Merge) + .WithMany(x => x.Entries) + .HasForeignKey(x => x.MergeId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs new file mode 100644 index 0000000..761ac60 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs @@ -0,0 +1,150 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Scaffolded by IMigrationsScaffolder against the previous snapshot (no dotnet-ef on the authoring host), the designer file dropped and + // the snapshot edited by hand to match, like AddPasskeyCredentials. Additive only; Down drops what Up adds. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261114120000_AddAccountMerges")] + public partial class AddAccountMerges : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "VoidedByMergeId", + table: "Votes", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "VoidedUtc", + table: "Votes", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "VoidedByMergeId", + table: "ConceptAppraisals", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "VoidedUtc", + table: "ConceptAppraisals", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "AccountMerges", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + State = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + SurvivorUserAccountId = table.Column(type: "uuid", nullable: false), + FoldedUserAccountId = table.Column(type: "uuid", nullable: false), + CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false), + CommittedUtc = table.Column(type: "timestamp with time zone", nullable: true), + GraceEndsUtc = table.Column(type: "timestamp with time zone", nullable: true), + FinalizedUtc = table.Column(type: "timestamp with time zone", nullable: true), + UndoneUtc = table.Column(type: "timestamp with time zone", nullable: true), + FailedUtc = table.Column(type: "timestamp with time zone", nullable: true), + FailureCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountMerges", x => x.Id); + table.ForeignKey( + name: "FK_AccountMerges_UserAccounts_FoldedUserAccountId", + column: x => x.FoldedUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_AccountMerges_UserAccounts_SurvivorUserAccountId", + column: x => x.SurvivorUserAccountId, + principalTable: "UserAccounts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "AccountMergeEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MergeId = table.Column(type: "uuid", nullable: false), + Seq = table.Column(type: "integer", nullable: false), + Kind = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + RecordId = table.Column(type: "uuid", nullable: false), + Action = table.Column(type: "character varying(24)", maxLength: 24, nullable: false), + PriorUserAccountId = table.Column(type: "uuid", nullable: true), + PriorText = table.Column(type: "character varying(120)", maxLength: 120, nullable: true), + PriorAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + AtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountMergeEntries", x => x.Id); + table.ForeignKey( + name: "FK_AccountMergeEntries_AccountMerges_MergeId", + column: x => x.MergeId, + principalTable: "AccountMerges", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccountMergeEntries_MergeId_Seq", + table: "AccountMergeEntries", + columns: new[] { "MergeId", "Seq" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AccountMerges_FoldedUserAccountId_State", + table: "AccountMerges", + columns: new[] { "FoldedUserAccountId", "State" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountMerges_State_GraceEndsUtc", + table: "AccountMerges", + columns: new[] { "State", "GraceEndsUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountMerges_SurvivorUserAccountId_State", + table: "AccountMerges", + columns: new[] { "SurvivorUserAccountId", "State" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountMergeEntries"); + + migrationBuilder.DropTable( + name: "AccountMerges"); + + migrationBuilder.DropColumn( + name: "VoidedByMergeId", + table: "Votes"); + + migrationBuilder.DropColumn( + name: "VoidedUtc", + table: "Votes"); + + migrationBuilder.DropColumn( + name: "VoidedByMergeId", + table: "ConceptAppraisals"); + + migrationBuilder.DropColumn( + name: "VoidedUtc", + table: "ConceptAppraisals"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index ab4ef16..6cae29d 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -22,6 +22,103 @@ namespace Bifrost.Web.Data.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommittedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FailureCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FinalizedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FoldedUserAccountId") + .HasColumnType("uuid"); + + b.Property("GraceEndsUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("SurvivorUserAccountId") + .HasColumnType("uuid"); + + b.Property("UndoneUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FoldedUserAccountId", "State"); + + b.HasIndex("State", "GraceEndsUtc"); + + b.HasIndex("SurvivorUserAccountId", "State"); + + b.ToTable("AccountMerges"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("AtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("MergeId") + .HasColumnType("uuid"); + + b.Property("PriorAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PriorText") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PriorUserAccountId") + .HasColumnType("uuid"); + + b.Property("RecordId") + .HasColumnType("uuid"); + + b.Property("Seq") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MergeId", "Seq") + .IsUnique(); + + b.ToTable("AccountMergeEntries"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => { b.Property("Id") @@ -437,6 +534,12 @@ namespace Bifrost.Web.Data.Migrations b.Property("UserAccountId") .HasColumnType("uuid"); + b.Property("VoidedByMergeId") + .HasColumnType("uuid"); + + b.Property("VoidedUtc") + .HasColumnType("timestamp with time zone"); + b.Property("VoterClass") .IsRequired() .HasMaxLength(16) @@ -1676,6 +1779,12 @@ namespace Bifrost.Web.Data.Migrations b.Property("UserAccountId") .HasColumnType("uuid"); + b.Property("VoidedByMergeId") + .HasColumnType("uuid"); + + b.Property("VoidedUtc") + .HasColumnType("timestamp with time zone"); + b.Property("Weight") .HasPrecision(18, 2) .HasColumnType("numeric(18,2)"); @@ -2063,6 +2172,32 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("OpenIddictTokens", (string)null); }); + modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b => + { + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("FoldedUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bifrost.Web.Domain.UserAccount", null) + .WithMany() + .HasForeignKey("SurvivorUserAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeEntry", b => + { + b.HasOne("Bifrost.Web.Domain.AccountMerge", "Merge") + .WithMany("Entries") + .HasForeignKey("MergeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Merge"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b => { b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount") @@ -2602,6 +2737,11 @@ namespace Bifrost.Web.Data.Migrations b.Navigation("Authorization"); }); + modelBuilder.Entity("Bifrost.Web.Domain.AccountMerge", b => + { + b.Navigation("Entries"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.Membership", b => { b.Navigation("RoleAssignments"); diff --git a/src/Bifrost.Web/Domain/AccountMergeModels.cs b/src/Bifrost.Web/Domain/AccountMergeModels.cs new file mode 100644 index 0000000..278360f --- /dev/null +++ b/src/Bifrost.Web/Domain/AccountMergeModels.cs @@ -0,0 +1,93 @@ +namespace Bifrost.Web.Domain; + +/// The whole lifecycle of an account merge, declared once; later cuts add columns, not states. Stored as a string. +public enum AccountMergeState +{ + Proposed, + Confirmed, + HeimdallMoved, + Committed, + Final, + UndoRequested, + HeimdallReversed, + Undone, + Failed +} + +public enum AccountMergeEntryKind +{ + Vote, + ConceptAppraisal, + Membership, + Standing, + PatronSupportEvent +} + +public enum AccountMergeEntryAction +{ + Voided, + Moved, + Closed, + Suspended, + Restored, + KeptAfterUndo +} + +/// +/// The merge receipt: two Heimdall-backed accounts, one survivor. Whether an account is folded or frozen is derived from this row's +/// State on every request (AccountMergeLedger.StandingAsync); no column on the account says so. +/// +public sealed class AccountMerge +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public AccountMergeState State { get; set; } = AccountMergeState.Proposed; + + public Guid SurvivorUserAccountId { get; set; } + + public Guid FoldedUserAccountId { get; set; } + + public DateTimeOffset CreatedUtc { get; set; } + + public DateTimeOffset? CommittedUtc { get; set; } + + public DateTimeOffset? GraceEndsUtc { get; set; } + + public DateTimeOffset? FinalizedUtc { get; set; } + + public DateTimeOffset? UndoneUtc { get; set; } + + public DateTimeOffset? FailedUtc { get; set; } + + public string FailureCode { get; set; } = string.Empty; + + public ICollection Entries { get; set; } = []; +} + +/// Append-only journal of what a merge did to one record; undo and the later preview read it, tallies and gates never do. +public sealed class AccountMergeEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid MergeId { get; set; } + + public int Seq { get; set; } + + public AccountMergeEntryKind Kind { get; set; } + + public Guid RecordId { get; set; } + + public AccountMergeEntryAction Action { get; set; } + + public Guid? PriorUserAccountId { get; set; } + + /// The prior status or prior voter key. + public string? PriorText { get; set; } + + /// The cast time of a moved vote or appraisal, so undo can tell whether the survivor re-cast it. + public DateTimeOffset? PriorAtUtc { get; set; } + + public DateTimeOffset AtUtc { get; set; } + + public AccountMerge Merge { get; set; } = null!; +} diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 7449a6b..ff57a66 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -7,7 +7,9 @@ public enum MembershipStatus Authenticated, PendingApproval, Active, - Suspended + Suspended, + /// Terminal status of a folded membership; written only by the account merge ledger, never read to decide standing. + Merged } public enum MemberRole @@ -776,6 +778,11 @@ public sealed class Vote public DateTimeOffset CastAtUtc { get; set; } + /// Set by the account merge ledger when a merge removes this vote from every tally; the model's query filter then hides the row. + public Guid? VoidedByMergeId { get; set; } + + public DateTimeOffset? VoidedUtc { get; set; } + public Motion Motion { get; set; } = null!; public UserAccount UserAccount { get; set; } = null!; diff --git a/src/Bifrost.Web/Domain/ConceptVoteModels.cs b/src/Bifrost.Web/Domain/ConceptVoteModels.cs index b86e051..b927c6d 100644 --- a/src/Bifrost.Web/Domain/ConceptVoteModels.cs +++ b/src/Bifrost.Web/Domain/ConceptVoteModels.cs @@ -46,5 +46,10 @@ public sealed class ConceptAppraisal public DateTimeOffset CastAtUtc { get; set; } + /// Set by the account merge ledger when a merge removes this appraisal from every tally; the model's query filter then hides the row. + public Guid? VoidedByMergeId { get; set; } + + public DateTimeOffset? VoidedUtc { get; set; } + public ConceptSubject? Subject { get; set; } } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs index 2e95ff3..21f21dc 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Patronage; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -14,6 +15,7 @@ namespace Bifrost.Web.Features.Heimdall; public sealed class HeimdallPatronSupportIntakeService( BifrostDbContext dbContext, PatronageService patronageService, + AccountMergeLedger mergeLedger, IOptions heimdallOptions) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) @@ -83,7 +85,7 @@ public sealed class HeimdallPatronSupportIntakeService( var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, - userAccountId: user.Id, + userAccountId: await mergeLedger.ResolveAsync(user.Id, cancellationToken), // a folded account's events record on the survivor request.Kind, request.Amount, request.CurrencyCode, diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs new file mode 100644 index 0000000..e2f7135 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -0,0 +1,583 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Patronage; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Membership; + +/// How an account stands under a merge: the folded account of an unfinished merge is Frozen, of a landed one Folded. +public abstract record MergeStanding(Guid MergeId) +{ + public sealed record Frozen(Guid MergeId) : MergeStanding(MergeId); + + public sealed record Folded(Guid MergeId, Guid SurvivorId) : MergeStanding(MergeId); +} + +public sealed record MergePlanEntry( + AccountMergeEntryKind Kind, + AccountMergeEntryAction Action, + Guid RecordId, + Guid? PriorUserAccountId = null, + string? PriorText = null, + DateTimeOffset? PriorAtUtc = null); + +/// A dry run: the block codes (never account names), the journal entries a commit would write, and the record counts left untouched. +public sealed record MergePlan( + IReadOnlyList Blocks, + IReadOnlyList Entries, + IReadOnlyDictionary Left); + +/// +/// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed +/// save, the undo save, the Final transition, StandingAsync and ResolveAsync. A merge only reduces: nothing is deleted, votes and +/// appraisals are stamped void (the model's query filter then removes them from every tally) or re-pointed, and every change is +/// journalled in AccountMergeEntry. Design: /opt/commons/handoff/heimdall-account-merge-design-20261113.md sections 3, 5, 7. +/// +public sealed class AccountMergeLedger( + BifrostDbContext dbContext, + PatronageService patronageService, + TimeProvider timeProvider) +{ + public static readonly TimeSpan Grace = TimeSpan.FromDays(7); + + private const int MaxHops = 8; + + private static readonly AccountMergeState[] FrozenStates = [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved]; + + private static readonly AccountMergeState[] FoldedStates = + [AccountMergeState.Committed, AccountMergeState.Final, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + private static readonly AccountMergeState[] InFlightStates = + [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + public static string SuspensionReason(Guid mergeId) => $"merged with a suspended account (merge {mergeId})"; + + public Task StandingAsync(Guid userAccountId, CancellationToken cancellationToken) => + StandingAsync(dbContext, userAccountId, cancellationToken); + + /// The single reading of standing, shared by the actor accessor and the OIDC refusal, which holds only a context. Derived from the merge state on every call. + public static async Task StandingAsync(BifrostDbContext db, Guid userAccountId, CancellationToken cancellationToken) + { + var merge = await db.AccountMerges.AsNoTracking() + .Where(x => x.FoldedUserAccountId == userAccountId && + (FrozenStates.Contains(x.State) || FoldedStates.Contains(x.State))) + .OrderByDescending(x => x.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + + return merge is null + ? null + : FrozenStates.Contains(merge.State) + ? new MergeStanding.Frozen(merge.Id) + : new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId); + } + + /// Follows Folded links to the account that now carries the person. Each account is folded once, so the hop cap is only a guard. + public async Task ResolveAsync(Guid userAccountId, CancellationToken cancellationToken) + { + var current = userAccountId; + for (var hop = 0; hop < MaxHops; hop++) + { + if (await StandingAsync(current, cancellationToken) is not MergeStanding.Folded folded) + { + return current; + } + + current = folded.SurvivorId; + } + + return current; + } + + /// Writes nothing. lets the saga re-plan a merge it already holds without it blocking itself. + public async Task PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null) + { + var blocks = await BlocksAsync(survivorId, foldedId, exceptMergeId, cancellationToken); + var entries = new List(); + var left = new Dictionary(); + + if (!blocks.Contains("account_missing")) + { + var reduction = await ReduceAsync(survivorId, foldedId, cancellationToken); + entries.AddRange(reduction.Entries); + left["ConsentReceipts"] = await dbContext.ConsentReceipts.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + left["PasskeyCredentials"] = await dbContext.PasskeyCredentials.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + left["LedgerEntries"] = await dbContext.LedgerEntries.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + } + + return new MergePlan(blocks, entries, left); + } + + private async Task> BlocksAsync(Guid survivorId, Guid foldedId, Guid? exceptMergeId, CancellationToken cancellationToken) + { + var blocks = new List(); + if (survivorId == foldedId) + { + blocks.Add("same_account"); + } + + var accounts = await dbContext.UserAccounts.AsNoTracking() + .Where(x => x.Id == survivorId || x.Id == foldedId) + .Select(x => new { x.Id, x.HeimdallAccountId }) + .ToListAsync(cancellationToken); + if (accounts.Count < (survivorId == foldedId ? 1 : 2)) + { + blocks.Add("account_missing"); + return blocks; + } + + if (await dbContext.MindPersonas.AnyAsync(x => x.UserAccountId == survivorId || x.UserAccountId == foldedId, cancellationToken)) + { + blocks.Add("mind_account"); + } + + if (accounts.Any(x => string.IsNullOrWhiteSpace(x.HeimdallAccountId))) + { + blocks.Add("native_account"); + } + + if (await dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && + InFlightStates.Contains(x.State) && + (x.SurvivorUserAccountId == survivorId || x.FoldedUserAccountId == survivorId || + x.SurvivorUserAccountId == foldedId || x.FoldedUserAccountId == foldedId), + cancellationToken)) + { + blocks.Add("merge_in_flight"); + } + + if (await FoldedLinkAsync(survivorId, exceptMergeId, cancellationToken)) + { + blocks.Add("survivor_is_folded"); + } + + if (await FoldedLinkAsync(foldedId, exceptMergeId, cancellationToken)) + { + blocks.Add("already_folded"); + } + + // Heimdall could no longer reverse a merge this account survived once it is folded into another. + if (await dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && x.State == AccountMergeState.Committed && x.SurvivorUserAccountId == foldedId, + cancellationToken)) + { + blocks.Add("folded_survivor_in_grace"); + } + + if (await dbContext.SealedBallots.AnyAsync( + x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open, + cancellationToken)) + { + blocks.Add("sealed_ballot_pending"); + } + + return blocks; + } + + private Task FoldedLinkAsync(Guid accountId, Guid? exceptMergeId, CancellationToken cancellationToken) => + dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && x.FoldedUserAccountId == accountId && FoldedStates.Contains(x.State), + cancellationToken); + + /// + /// Applies the merge to Bifrost records in one save, together with State=Committed. Total: blocks were checked before the move + /// and a refusal now would strand moved logins, so it recomputes the reductions from current rows and applies them. Committed + /// and Final are no-ops returning the receipt; any other state but HeimdallMoved is an error. + /// + public async Task CommitAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken); + if (merge.State is AccountMergeState.Committed or AccountMergeState.Final) + { + return merge; + } + + if (merge.State != AccountMergeState.HeimdallMoved) + { + throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallMoved merge can be committed."); + } + + try + { + var reduction = await ReduceAsync(merge.SurvivorUserAccountId, merge.FoldedUserAccountId, cancellationToken, mergeId); + var now = timeProvider.GetUtcNow(); + var seq = 0; + foreach (var entry in reduction.Entries) + { + dbContext.AccountMergeEntries.Add(Journal(merge.Id, ++seq, entry, now)); + } + + reduction.Apply(now); + if (reduction.MovedPatronEvents) + { + await RestageTiersAsync(reduction.Survivor, reduction.Folded, now, cancellationToken); + } + + merge.State = AccountMergeState.Committed; + merge.CommittedUtc = now; + merge.GraceEndsUtc = now + Grace; + await dbContext.SaveChangesAsync(cancellationToken); + return merge; + } + catch + { + // Nothing was saved; drop what was staged so a retry on this context starts from the stored rows. + dbContext.ChangeTracker.Clear(); + throw; + } + } + + /// Replays the journal backward in one save with State=Undone. Total, like commit. Undone is a no-op; any other state but HeimdallReversed is an error. + public async Task UndoLocalAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken); + if (merge.State == AccountMergeState.Undone) + { + return merge; + } + + if (merge.State != AccountMergeState.HeimdallReversed) + { + throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallReversed merge can be undone locally."); + } + + try + { + var now = timeProvider.GetUtcNow(); + var journal = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).OrderByDescending(x => x.Seq).ToListAsync(cancellationToken); + var seq = journal.Count == 0 ? 0 : journal.Max(x => x.Seq); + var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == merge.SurvivorUserAccountId, cancellationToken); + var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == merge.FoldedUserAccountId, cancellationToken); + var survivorKey = AppraisalKey(survivor.Id); + var restagePatron = false; + + void Record(AccountMergeEntry original, bool restored) => + dbContext.AccountMergeEntries.Add(new AccountMergeEntry + { + MergeId = mergeId, + Seq = ++seq, + Kind = original.Kind, + RecordId = original.RecordId, + Action = restored ? AccountMergeEntryAction.Restored : AccountMergeEntryAction.KeptAfterUndo, + PriorUserAccountId = original.PriorUserAccountId, + AtUtc = now + }); + + foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended)) + { + switch (entry.Kind, entry.Action) + { + case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided): + { + var vote = await dbContext.Votes.IgnoreQueryFilters().Include(x => x.Motion).SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = vote?.VoidedByMergeId == mergeId && vote.Motion.Status == MotionStatus.Open; + if (restore) + { + vote!.VoidedByMergeId = null; + vote.VoidedUtc = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved): + { + var vote = await dbContext.Votes.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = vote is not null && vote.UserAccountId == survivor.Id && vote.CastAtUtc == entry.PriorAtUtc; + if (restore) + { + vote!.UserAccountId = entry.PriorUserAccountId!.Value; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided): + { + var row = await dbContext.ConceptAppraisals.IgnoreQueryFilters().SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row?.VoidedByMergeId == mergeId; + if (restore) + { + row!.VoidedByMergeId = null; + row.VoidedUtc = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved): + { + var row = await dbContext.ConceptAppraisals.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row is not null && row.UserAccountId == survivor.Id && row.VoterKey == survivorKey && row.CastAtUtc == entry.PriorAtUtc; + if (restore) + { + row!.UserAccountId = entry.PriorUserAccountId!.Value; + row.VoterKey = entry.PriorText ?? row.VoterKey; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed): + { + var restore = folded.Membership is { Status: MembershipStatus.Merged } && Enum.TryParse(entry.PriorText, out _); + if (restore) + { + folded.Membership!.Status = Enum.Parse(entry.PriorText!); + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended): + { + // Only while the survivor still carries exactly this merge's suspension; an admin who changed it since owns the standing now. + var restore = survivor.Membership is { Status: MembershipStatus.Suspended } member && + member.SuspensionReason == SuspensionReason(mergeId) && + Enum.TryParse(entry.PriorText, out _); + if (restore) + { + survivor.Membership!.Status = Enum.Parse(entry.PriorText!); + survivor.Membership.SuspensionReason = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved): + { + var row = await dbContext.PatronSupportEvents.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row is not null && row.UserAccountId == survivor.Id; + if (restore) + { + row!.UserAccountId = entry.PriorUserAccountId!.Value; + restagePatron = true; + } + + Record(entry, restore); + break; + } + } + } + + if (restagePatron) + { + await RestageTiersAsync(survivor, folded, now, cancellationToken); + } + + merge.State = AccountMergeState.Undone; + merge.UndoneUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + return merge; + } + catch + { + dbContext.ChangeTracker.Clear(); + throw; + } + } + + /// Committed merges whose grace has ended become Final; returns how many. Undo is never allowed past Final. + public async Task FinalizeDueAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var due = await dbContext.AccountMerges + .Where(x => x.State == AccountMergeState.Committed && x.GraceEndsUtc <= now) + .ToListAsync(cancellationToken); + foreach (var merge in due) + { + merge.State = AccountMergeState.Final; + merge.FinalizedUtc = now; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return due.Count; + } + + private sealed class Reduction( + UserAccount survivor, + UserAccount folded, + List entries, + List> effects, + bool movedPatronEvents) + { + public UserAccount Survivor { get; } = survivor; + + public UserAccount Folded { get; } = folded; + + public IReadOnlyList Entries => entries; + + public bool MovedPatronEvents { get; } = movedPatronEvents; + + public Guid MergeId { get; set; } + + public void Apply(DateTimeOffset now) + { + foreach (var effect in effects) + { + effect(MergeId, now); + } + } + } + + /// + /// What a merge does to records, computed from current rows without writing: the journal entries and the effects that apply them. + /// Rules (design 5): suspension wins; the folded membership is closed and its roles never copied; open standard motions and + /// concept appraisals keep one voice per person (the survivor's), a lone folded one moving only to an Active survivor; patron + /// events follow the person. + /// + private async Task ReduceAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? mergeId = null) + { + var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == survivorId, cancellationToken); + var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == foldedId, cancellationToken); + + var entries = new List(); + var effects = new List>(); + + var foldedSuspended = folded.Membership?.Status == MembershipStatus.Suspended; + var survivorSuspendedNow = survivor.Membership?.Status == MembershipStatus.Suspended; + var survivorStanding = survivor.Membership?.Status; + if (foldedSuspended && !survivorSuspendedNow && survivor.Membership is { } survivorMembership) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString())); + effects.Add((id, _) => + { + survivorMembership.Status = MembershipStatus.Suspended; + survivorMembership.SuspensionReason = SuspensionReason(id); + }); + survivorStanding = MembershipStatus.Suspended; + } + + var survivorActive = survivorStanding == MembershipStatus.Active; + + if (folded.Membership is { } foldedMembership) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed, foldedMembership.Id, null, foldedMembership.Status.ToString())); + effects.Add((_, _) => foldedMembership.Status = MembershipStatus.Merged); + } + + var foldedVotes = await dbContext.Votes + .Where(x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard) + .ToListAsync(cancellationToken); + var survivorVotedMotions = (await dbContext.Votes + .Where(x => x.UserAccountId == survivorId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard) + .Select(x => x.MotionId) + .ToListAsync(cancellationToken)).ToHashSet(); + foreach (var vote in foldedVotes) + { + if (survivorVotedMotions.Contains(vote.MotionId) || !survivorActive) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided, vote.Id, vote.UserAccountId)); + effects.Add((id, now) => + { + vote.VoidedByMergeId = id; + vote.VoidedUtc = now; + }); + } + else + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, null, vote.CastAtUtc)); + effects.Add((_, _) => vote.UserAccountId = survivorId); + } + } + + var survivorKey = AppraisalKey(survivorId); + var foldedAppraisals = await dbContext.ConceptAppraisals.Where(x => x.UserAccountId == foldedId).ToListAsync(cancellationToken); + var survivorSubjects = (await dbContext.ConceptAppraisals + .Where(x => x.UserAccountId == survivorId || x.VoterKey == survivorKey) + .Select(x => x.SubjectId) + .ToListAsync(cancellationToken)).ToHashSet(); + foreach (var appraisal in foldedAppraisals) + { + if (survivorSubjects.Contains(appraisal.SubjectId) || !survivorActive) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided, appraisal.Id, appraisal.UserAccountId)); + effects.Add((id, now) => + { + appraisal.VoidedByMergeId = id; + appraisal.VoidedUtc = now; + }); + } + else + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, appraisal.VoterKey, appraisal.CastAtUtc)); + effects.Add((_, _) => + { + appraisal.UserAccountId = survivorId; + appraisal.VoterKey = survivorKey; + }); + } + } + + var foldedEvents = folded.PatronSupportEvents.ToList(); + foreach (var supportEvent in foldedEvents) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEvent.Id, supportEvent.UserAccountId)); + effects.Add((_, _) => supportEvent.UserAccountId = survivorId); + } + + var reduction = new Reduction(survivor, folded, entries, effects, foldedEvents.Count > 0); + if (mergeId is { } m) + { + reduction.MergeId = m; + } + + return reduction; + } + + // Tiers derive once from all the events now on each account, never tier A plus tier B. Ownership is read from the + // tracked rows, so it holds before the save as well as after it. + private async Task RestageTiersAsync(UserAccount survivor, UserAccount folded, DateTimeOffset now, CancellationToken cancellationToken) + { + var events = survivor.PatronSupportEvents.Concat(folded.PatronSupportEvents).DistinctBy(x => x.Id).ToList(); + await StageIfMembershipAsync(survivor.Membership, events.Where(x => x.UserAccountId == survivor.Id).ToList(), now, cancellationToken); + await StageIfMembershipAsync(folded.Membership, events.Where(x => x.UserAccountId == folded.Id).ToList(), now, cancellationToken); + } + + private async Task StageIfMembershipAsync(Bifrost.Web.Domain.Membership? membership, IEnumerable events, DateTimeOffset now, CancellationToken cancellationToken) + { + if (membership is not null) + { + await patronageService.StageTierSnapshotAsync(membership, events, now, cancellationToken); + } + } + + private static AccountMergeEntry Journal(Guid mergeId, int seq, MergePlanEntry entry, DateTimeOffset now) => new() + { + MergeId = mergeId, + Seq = seq, + Kind = entry.Kind, + RecordId = entry.RecordId, + Action = entry.Action, + PriorUserAccountId = entry.PriorUserAccountId, + PriorText = entry.PriorText, + PriorAtUtc = entry.PriorAtUtc, + AtUtc = now + }; + + private static string AppraisalKey(Guid accountId) => "account:" + accountId.ToString("N"); +} + +/// Finalizes merges past their grace every five minutes. Copies the SealedBallotCloser shape: a new scope per tick, a warning on failure. +public sealed class AccountMergeSweeper(IServiceScopeFactory scopes, TimeProvider clock, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + do + { + try + { + using var scope = scopes.CreateScope(); + await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(clock.GetUtcNow(), stoppingToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogWarning(error, "Account merge sweep failed; retrying next tick."); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index a789882..d142858 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -23,6 +23,7 @@ namespace Bifrost.Web.Features.Oidc; public static class OidcEndpoints { public const string SuspendedDescription = "The account is suspended."; + public const string MergedDescription = "The account is part of an account merge and can no longer sign in."; public const string NoIdentityDescription = "The account has no Bifrost identity."; public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room."; @@ -56,7 +57,7 @@ public static class OidcEndpoints // every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync). if (actor.UserAccount is null) { - return Deny(Errors.AccessDenied, NoIdentityDescription); + return Deny(Errors.AccessDenied, actor.Merge is not null ? MergedDescription : NoIdentityDescription); } if (actor.Membership?.Status == MembershipStatus.Suspended) @@ -176,6 +177,12 @@ public static class OidcEndpoints return SuspendedDescription; } + // A folded or frozen subject's chat sessions end: token, userinfo and the logout reconciler all read this one refusal. + if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null) + { + return MergedDescription; + } + return deskToken || await ConsentEndpoints.HasLiveReceiptAsync(db, accountId, cancellationToken) ? null : ConsentRequiredDescription; } diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 8301356..83f5539 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -146,35 +146,7 @@ public sealed class PatronageService( throw new InvalidOperationException("Cannot refresh patron tier for a user without membership."); } - var summary = CalculatePatronPoints(user.PatronSupportEvents, timeProvider.GetUtcNow()); - var tier = PatronTierPolicy.ResolveTier(summary.EffectivePoints); - var now = timeProvider.GetUtcNow(); - - var currentPatronSnapshots = await dbContext.TierSnapshots - .Where(x => x.MembershipId == user.Membership.Id && - x.Kind == TierSnapshotKind.Patron && - x.IsCurrent) - .ToListAsync(cancellationToken); - - foreach (var snapshot in currentPatronSnapshots) - { - snapshot.IsCurrent = false; - } - - if (tier is not null) - { - dbContext.TierSnapshots.Add(new TierSnapshot - { - MembershipId = user.Membership.Id, - Kind = TierSnapshotKind.Patron, - Label = PatronTierPolicy.SnapshotLabel(tier), - Weight = tier.VotingWeight, - IsCurrent = true, - EffectiveFromUtc = now, - CapturedAtUtc = now, - Notes = PatronTierPolicy.SnapshotNote(summary.EffectivePoints, now) - }); - } + var (summary, tier) = await StageTierSnapshotAsync(user.Membership, user.PatronSupportEvents, timeProvider.GetUtcNow(), cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( @@ -190,6 +162,49 @@ public sealed class PatronageService( return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m }; } + /// + /// Derives the membership's patron tier from the events once and stages it on the tracker: demotes the current Patron snapshots + /// and adds the new one. It never saves and never clears the tracker, so a caller can stage it inside a larger single save + /// (the account merge ledger does). + /// + public async Task<(PatronPointSummary Summary, PatronTierDefinition? Tier)> StageTierSnapshotAsync( + Bifrost.Web.Domain.Membership membership, + IEnumerable events, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var summary = CalculatePatronPoints(events, now); + var tier = PatronTierPolicy.ResolveTier(summary.EffectivePoints); + + var currentPatronSnapshots = await dbContext.TierSnapshots + .Where(x => x.MembershipId == membership.Id && + x.Kind == TierSnapshotKind.Patron && + x.IsCurrent) + .ToListAsync(cancellationToken); + + foreach (var snapshot in currentPatronSnapshots) + { + snapshot.IsCurrent = false; + } + + if (tier is not null) + { + dbContext.TierSnapshots.Add(new TierSnapshot + { + MembershipId = membership.Id, + Kind = TierSnapshotKind.Patron, + Label = PatronTierPolicy.SnapshotLabel(tier), + Weight = tier.VotingWeight, + IsCurrent = true, + EffectiveFromUtc = now, + CapturedAtUtc = now, + Notes = PatronTierPolicy.SnapshotNote(summary.EffectivePoints, now) + }); + } + + return (summary, tier); + } + public static PatronPointSummary CalculatePatronPoints( IEnumerable supportEvents, DateTimeOffset nowUtc) diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs index 648c662..412dada 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs @@ -1,9 +1,13 @@ using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; namespace Bifrost.Web.Features.Shared; public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? UserAccount) { + /// Set when the account behind this sign-in is frozen or folded by an account merge; the actor then has no account. + public MergeStanding? Merge { get; init; } + public static readonly CurrentBifrostActor Anonymous = new(false, null); public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership; diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs index 5d34fad..ae9839b 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs @@ -12,7 +12,8 @@ public interface ICurrentBifrostActorAccessor public sealed class CurrentBifrostActorAccessor( IHttpContextAccessor httpContextAccessor, - BifrostDbContext dbContext) : ICurrentBifrostActorAccessor + BifrostDbContext dbContext, + AccountMergeLedger ledger) : ICurrentBifrostActorAccessor { private const string CacheKey = "Bifrost.CurrentActor"; @@ -45,18 +46,41 @@ public sealed class CurrentBifrostActorAccessor( return Cache(httpContext, CurrentBifrostActor.Anonymous); } - var userAccount = await dbContext.UserAccounts + // One credential names one row: identity first, then Heimdall id, then GitHub id; the first hit wins. Two rows matching + // one principal (a merge can leave a login on either side) must never throw or pick by luck. + var accounts = dbContext.UserAccounts .AsNoTracking() .Include(x => x.MemberProfile) .Include(x => x.Membership!) .ThenInclude(x => x.RoleAssignments) .Include(x => x.Membership!) - .ThenInclude(x => x.TierSnapshots) - .SingleOrDefaultAsync( - x => (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity) && x.NormalizedBifrostIdentity == normalizedBifrostIdentity) || - (!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) || - (hasGitHubUserId && x.GitHubUserId == gitHubUserId), - cancellationToken); + .ThenInclude(x => x.TierSnapshots); + + Bifrost.Web.Domain.UserAccount? userAccount = null; + if (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity)) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.NormalizedBifrostIdentity == normalizedBifrostIdentity, cancellationToken); + } + + if (userAccount is null && !string.IsNullOrWhiteSpace(heimdallAccountId)) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, cancellationToken); + } + + if (userAccount is null && hasGitHubUserId) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken); + } + + // A folded or frozen account acts as nobody on every route: this is the one gate (merge ledger standing, derived per request). + if (userAccount is not null) + { + var standing = await ledger.StandingAsync(userAccount.Id, cancellationToken); + if (standing is not null) + { + return Cache(httpContext, new CurrentBifrostActor(true, null) { Merge = standing }); + } + } return Cache(httpContext, new CurrentBifrostActor(true, userAccount)); } diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 48c0f74..296cd3e 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -139,6 +139,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs new file mode 100644 index 0000000..d8f48c6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// A folded member's browser and Heimdall's patron intake, over HTTP: the merge ledger is the single gate behind both. +public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : IClassFixture +{ + private sealed record Seeded(Guid Survivor, Guid Folded, Guid Motion, string FoldedHeimdallId, long SurvivorGitHubId, long FoldedGitHubId); + + private async Task SeedAsync() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var seed = Random.Shared.NextInt64(1_000_000, 900_000_000); + UserAccount Make(string name, long gitHubId) => new() + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = name, GitHubUserId = gitHubId, GitHubLogin = name, NormalizedGitHubLogin = name, + HeimdallAccountId = "hd-" + name, + MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = DateTimeOffset.UtcNow }, + Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow } + }; + var survivor = Make($"surv-{seed}", seed); + var folded = Make($"fold-{seed}", seed + 1); + db.UserAccounts.AddRange(survivor, folded); + var motion = new Motion + { + CreatedByUserAccountId = survivor.Id, Title = "Open motion", Status = MotionStatus.Open, OpensAtUtc = DateTimeOffset.UtcNow.AddHours(-1), + ClosesAtUtc = DateTimeOffset.UtcNow.AddDays(2), CreatedAtUtc = DateTimeOffset.UtcNow + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return new Seeded(survivor.Id, folded.Id, motion.Id, folded.HeimdallAccountId, seed, seed + 1); + } + + private HttpClient ClientFor(long gitHubId, string login) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost"), HandleCookies = true }); + client.DefaultRequestHeaders.Add("X-Test-GitHub-Id", gitHubId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login); + return client; + } + + private static async Task TokenAsync(HttpClient client) + { + using var page = await client.GetAsync("/App/Motions"); + var html = await page.Content.ReadAsStringAsync(); + var token = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; + Assert.NotEmpty(token); + return token; + } + + private static Task VoteAsync(HttpClient client, Guid motion, string token) => + client.PostAsync("/App/Motions?handler=Vote", new FormUrlEncodedContent(new Dictionary + { + ["motionId"] = motion.ToString(), ["choice"] = "For", ["comment"] = "yes", ["__RequestVerificationToken"] = token + })); + + private async Task WithDb(Func> read) + { + using var scope = factory.Services.CreateScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } + + private Task VoteRowsAsync(Guid motion) => WithDb(db => db.Votes.IgnoreQueryFilters().CountAsync(x => x.MotionId == motion)); + + private async Task MergeAsync(Seeded s, AccountMergeState state) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var merge = new AccountMerge { SurvivorUserAccountId = s.Survivor, FoldedUserAccountId = s.Folded, State = state, CreatedUtc = DateTimeOffset.UtcNow }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + return merge.Id; + } + + private async Task SetStateAsync(Guid mergeId, AccountMergeState state) => + await WithDb(async db => + { + (await db.AccountMerges.SingleAsync(x => x.Id == mergeId)).State = state; + await db.SaveChangesAsync(); + return 0; + }); + + [Fact] + public async Task A_folded_session_cookie_acts_as_nobody_while_Frozen_and_after_Committed_and_the_survivor_still_votes() + { + var s = await SeedAsync(); + using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}"); + var token = await TokenAsync(folded); // a live, valid form from before the merge: the cookie outlives the merge + var merge = await MergeAsync(s, AccountMergeState.Confirmed); + + using (var whileFrozen = await VoteAsync(folded, s.Motion, token)) + { + Assert.NotEqual(HttpStatusCode.OK, whileFrozen.StatusCode); + } + + Assert.Equal(0, await VoteRowsAsync(s.Motion)); + using (var page = await folded.GetAsync("/App/Motions")) + { + Assert.NotEqual(HttpStatusCode.OK, page.StatusCode); + } + + await SetStateAsync(merge, AccountMergeState.HeimdallMoved); + await SetStateAsync(merge, AccountMergeState.Committed); + using (var afterCommit = await VoteAsync(folded, s.Motion, token)) + { + Assert.NotEqual(HttpStatusCode.OK, afterCommit.StatusCode); + } + + Assert.Equal(0, await VoteRowsAsync(s.Motion)); + + using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}"); + using var control = await VoteAsync(survivor, s.Motion, await TokenAsync(survivor)); + Assert.Equal(HttpStatusCode.Redirect, control.StatusCode); + Assert.Equal(1, await VoteRowsAsync(s.Motion)); + } + + [Fact] + public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor() + { + var s = await SeedAsync(); + await MergeAsync(s, AccountMergeState.Final); + var payload = JsonSerializer.Serialize(new + { + heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = $"WH-{Guid.NewGuid():N}", kind = "OneTimeDonation", + amount = 25m, currencyCode = "USD", externalSupportId = "CAPTURE-1", supportedAtUtc = DateTimeOffset.UtcNow, isCurrentRecurringSupport = false + }); + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("test-heimdall-intake-secret")); + using var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events"); + request.Headers.Add("X-Heimdall-Signature-256", "sha256=" + Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var client = factory.CreateClient(); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var owners = await WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync()); + Assert.Equal([s.Survivor], owners); + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs new file mode 100644 index 0000000..cd0f7a5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs @@ -0,0 +1,725 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Patronage; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// The merge ledger from its consumers' side: the motion state and close a member sees, the concept tallies a visitor sees, the actor +/// a sign-in resolves to, the patron weight, and the undo. Each operation opens its own context, as a request scope would. +/// +public sealed class AccountMergeLedgerTests +{ + private static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); + + private sealed class Clock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + + public override DateTimeOffset GetUtcNow() => Now; + } + + private sealed class World + { + private readonly string _name = $"merge-{Guid.NewGuid():N}"; + private readonly SaveChangesInterceptor[] _interceptors; + + public World(params SaveChangesInterceptor[] interceptors) => _interceptors = interceptors; + + public Clock Clock { get; } = new(T0); + + public BifrostDbContext Db() => new(new DbContextOptionsBuilder() + .UseInMemoryDatabase(_name) + .AddInterceptors(_interceptors) + .ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options); + + public AccountMergeLedger Ledger(BifrostDbContext db) => new(db, new PatronageService(db, new AuditTrailService(db, Clock), Clock), Clock); + + public MotionGovernanceService Motions(BifrostDbContext db) => new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Concepts(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, Options.Create(new ConceptVoteOptions())); + + public async Task AccountAsync(string name, MembershipStatus status = MembershipStatus.Active, bool heimdall = true, bool moderator = false, params MemberRole[] roles) + { + await using var db = Db(); + var account = new UserAccount + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name), + HeimdallAccountId = heimdall ? $"hd-{name}-{Guid.NewGuid():N}" : string.Empty, + CreatedAtUtc = T0, LastSeenAtUtc = T0 + }; + account.Membership = new Membership { UserAccountId = account.Id, Status = status, CanModerateMotions = moderator }; + foreach (var role in roles) + { + account.Membership.RoleAssignments.Add(new RoleAssignment { Role = role }); + } + + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account; + } + + public async Task MotionAsync(string title = "m", MotionStatus status = MotionStatus.Open, MotionKind kind = MotionKind.Standard) + { + await using var db = Db(); + var author = await AccountAsync("author-" + Guid.NewGuid().ToString("N")[..6]); + var motion = new Motion + { + CreatedByUserAccountId = author.Id, Title = title, Status = status, Kind = kind, OpensAtUtc = T0, + ClosesAtUtc = T0.AddDays(3), CreatedAtUtc = T0, ApprovalThreshold = 0.5m + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return motion; + } + + public async Task VoteAsync(Motion motion, UserAccount who, VoteChoice choice, decimal weight = 1m, DateTimeOffset? at = null) + { + await using var db = Db(); + db.Votes.Add(new Vote { MotionId = motion.Id, UserAccountId = who.Id, Choice = choice, Weight = weight, CastAtUtc = at ?? T0.AddHours(1) }); + await db.SaveChangesAsync(); + } + + public async Task MergeAsync(UserAccount survivor, UserAccount folded, AccountMergeState state = AccountMergeState.HeimdallMoved) + { + await using var db = Db(); + var merge = new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = state, CreatedUtc = T0 }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + return merge; + } + + public async Task CommitAsync(AccountMerge merge) + { + await using var db = Db(); + return await Ledger(db).CommitAsync(merge.Id, default); + } + + public async Task SetStateAsync(AccountMerge merge, AccountMergeState state) + { + await using var db = Db(); + (await db.AccountMerges.SingleAsync(x => x.Id == merge.Id)).State = state; + await db.SaveChangesAsync(); + } + + public async Task StateOfAsync(Motion motion, UserAccount viewer) + { + await using var db = Db(); + var actorAccount = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleAsync(x => x.Id == viewer.Id); + var state = await Motions(db).GetStateAsync(new CurrentBifrostActor(true, actorAccount), default); + return state.Motions.Single(x => x.Id == motion.Id); + } + + public async Task ActorAsync(Guid accountId) + { + await using var db = Db(); + var account = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleAsync(x => x.Id == accountId); + return new CurrentBifrostActor(true, account); + } + + public async Task CloseAsync(Motion motion, UserAccount moderator) + { + await using var db = Db(); + await Motions(db).CloseMotionAsync(await ActorAsync(moderator.Id), motion.Id, default); + await using var check = Db(); + return (await check.Motions.SingleAsync(x => x.Id == motion.Id)).Status; + } + + public async Task> AllVotesAsync(Motion motion) + { + await using var db = Db(); + return await db.Votes.IgnoreQueryFilters().AsNoTracking().Where(x => x.MotionId == motion.Id).ToListAsync(); + } + + public async Task> JournalAsync(AccountMerge merge) + { + await using var db = Db(); + return await db.AccountMergeEntries.AsNoTracking().Where(x => x.MergeId == merge.Id).OrderBy(x => x.Seq).ToListAsync(); + } + + public async Task MembershipOfAsync(UserAccount account) + { + await using var db = Db(); + return await db.Memberships.AsNoTracking().Include(x => x.TierSnapshots).Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == account.Id); + } + } + + // ---- votes ---- + + [Fact] + public async Task Both_voted_on_an_open_motion_only_the_survivors_weight_counts_in_state_and_close() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 3m); + var before = await world.StateOfAsync(motion, moderator); + Assert.Equal((3m, 1m), (before.VotesFor, before.VotesAgainst)); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var after = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (after.VotesFor, after.VotesAgainst)); + Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator)); + Assert.Equal(2, (await world.AllVotesAsync(motion)).Count); // nothing is deleted + } + + [Fact] + public async Task A_vote_only_the_folded_account_cast_moves_and_a_later_survivor_cast_replaces_it_never_doubling() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(1m, (await world.StateOfAsync(motion, moderator)).VotesFor); + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(await world.ActorAsync(survivor.Id), motion.Id, VoteChoice.Against, "changed my mind", default); + } + + var item = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst)); + var row = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal(survivor.Id, row.UserAccountId); + Assert.Null(row.VoidedByMergeId); + } + + [Fact] + public async Task A_vote_only_the_folded_account_cast_is_voided_when_the_survivor_is_not_active() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", MembershipStatus.PendingApproval); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(0m, (await world.StateOfAsync(motion, moderator)).VotesFor); + var row = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal(merge.Id, row.VoidedByMergeId); + Assert.Equal(folded.Id, row.UserAccountId); + } + + [Fact] + public async Task Closed_motions_keep_every_vote_and_tally() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(status: MotionStatus.Passed); + await world.VoteAsync(motion, survivor, VoteChoice.For, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 2m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(3m, (await world.StateOfAsync(motion, moderator)).VotesFor); + Assert.All(await world.AllVotesAsync(motion), v => Assert.Null(v.VoidedByMergeId)); + } + + // ---- concept appraisals ---- + + [Fact] + public async Task Appraisals_reduce_to_one_voice_per_person_and_the_tallies_count_the_person_once() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + Guid both, onlyFolded; + await using (var db = world.Db()) + { + var s1 = new ConceptSubject { Collection = "c", Key = "both", Title = "Both", RegisteredAtUtc = T0 }; + var s2 = new ConceptSubject { Collection = "c", Key = "only-folded", Title = "Only folded", RegisteredAtUtc = T0 }; + db.ConceptSubjects.AddRange(s1, s2); + ConceptAppraisal Row(ConceptSubject s, UserAccount who, ConceptStance stance) => new() + { + SubjectId = s.Id, UserAccountId = who.Id, VoterKey = "account:" + who.Id.ToString("N"), Stance = stance, + VoterClass = VoterClass.Member, CastAtUtc = T0 + }; + db.ConceptAppraisals.AddRange(Row(s1, survivor, ConceptStance.Up), Row(s1, folded, ConceptStance.Down), Row(s2, folded, ConceptStance.Down)); + await db.SaveChangesAsync(); + (both, onlyFolded) = (s1.Id, s2.Id); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using var read = world.Db(); + var tallies = await world.Concepts(read).GetTalliesAsync("c", default); + Assert.Equal((1, 0), (tallies["both"].Up, tallies["both"].Down)); + Assert.Equal((0, 1), (tallies["only-folded"].Up, tallies["only-folded"].Down)); + var moved = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal(survivor.Id, moved.UserAccountId); + Assert.Equal("account:" + survivor.Id.ToString("N"), moved.VoterKey); + Assert.Equal(1, await read.ConceptAppraisals.IgnoreQueryFilters().CountAsync(x => x.SubjectId == both && x.VoidedByMergeId != null)); + } + + // ---- membership, standing, patronage ---- + + [Fact] + public async Task The_folded_membership_becomes_Merged_with_weight_zero_and_no_role_or_admin_flag_is_carried() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", roles: MemberRole.StandardMember); + var folded = await world.AccountAsync("folded", roles: [MemberRole.PlatformAdmin, MemberRole.Producer]); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).IsPlatformAdmin = true; + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var foldedMembership = await world.MembershipOfAsync(folded); + Assert.Equal(MembershipStatus.Merged, foldedMembership.Status); + Assert.Equal(0m, foldedMembership.EffectiveVotingWeight); + var survivorMembership = await world.MembershipOfAsync(survivor); + Assert.Equal([MemberRole.StandardMember], survivorMembership.RoleAssignments.Select(x => x.Role)); + Assert.False(survivorMembership.IsPlatformAdmin); + Assert.Equal(MembershipStatus.Active, survivorMembership.Status); + } + + [Fact] + public async Task A_suspended_folded_account_suspends_the_survivor_and_undo_restores_it_only_if_unchanged() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var untouched = await world.MergeAsync(survivor, folded); + await world.CommitAsync(untouched); + + var suspended = await world.MembershipOfAsync(survivor); + Assert.Equal(MembershipStatus.Suspended, suspended.Status); + Assert.Equal(AccountMergeLedger.SuspensionReason(untouched.Id), suspended.SuspensionReason); + + await world.SetStateAsync(untouched, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(untouched.Id, default); + } + + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(survivor)).Status); + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(folded)).Status); + + // The same again, but a steward re-suspends the survivor for their own reason before the undo: that standing is theirs now. + var folded2 = await world.AccountAsync("folded2", MembershipStatus.Suspended); + var second = await world.MergeAsync(survivor, folded2); + await world.CommitAsync(second); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)).SuspensionReason = "steward decision"; + await db.SaveChangesAsync(); + } + + await world.SetStateAsync(second, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(second.Id, default); + } + + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(survivor)).Status); + Assert.Contains(await world.JournalAsync(second), e => e.Kind == AccountMergeEntryKind.Standing && e.Action == AccountMergeEntryAction.KeptAfterUndo); + } + + [Fact] + public async Task Patron_events_follow_the_person_and_the_survivors_tier_derives_once_from_all_events_not_tier_plus_tier() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await using (var db = world.Db()) + { + PatronSupportEvent Event(UserAccount who, decimal amount) => new() + { + UserAccountId = who.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = amount, + ProviderEventId = Guid.NewGuid().ToString("N"), SupportedAtUtc = T0, RecordedAtUtc = T0 + }; + db.PatronSupportEvents.AddRange(Event(survivor, 90m), Event(folded, 1000m)); + var memberships = await db.Memberships.ToListAsync(); + foreach (var (who, weight) in new[] { (survivor, 1m), (folded, 3m) }) + { + db.TierSnapshots.Add(new TierSnapshot { MembershipId = memberships.Single(m => m.UserAccountId == who.Id).Id, Kind = TierSnapshotKind.Patron, Label = "t", Weight = weight, IsCurrent = true, EffectiveFromUtc = T0, CapturedAtUtc = T0 }); + } + + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var survivorMembership = await world.MembershipOfAsync(survivor); + Assert.Equal(3m, survivorMembership.EffectiveVotingWeight); // 1090 points: Gold, not Bronze 1 + Gold 3 + Assert.Single(survivorMembership.TierSnapshots, x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent); + Assert.DoesNotContain(( await world.MembershipOfAsync(folded)).TierSnapshots, x => x.IsCurrent); + await using var db2 = world.Db(); + Assert.All(await db2.PatronSupportEvents.ToListAsync(), e => Assert.Equal(survivor.Id, e.UserAccountId)); + } + + // ---- who the actor is ---- + + [Fact] + public async Task A_principal_whose_identity_names_one_row_and_whose_heimdall_id_names_another_resolves_the_first_and_does_not_throw() + { + var world = new World(); + var byIdentity = await world.AccountAsync("by-identity"); + var byHeimdall = await world.AccountAsync("by-heimdall"); + var http = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + http.User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim(BifrostClaimTypes.BifrostIdentity, byIdentity.BifrostIdentity), + new System.Security.Claims.Claim(BifrostClaimTypes.HeimdallAccountId, byHeimdall.HeimdallAccountId) + ], "test")); + await using var db = world.Db(); + var accessor = new CurrentBifrostActorAccessor(new Microsoft.AspNetCore.Http.HttpContextAccessor { HttpContext = http }, db, world.Ledger(db)); + + var actor = await accessor.GetAsync(default); + + Assert.Equal(byIdentity.Id, actor.UserAccount!.Id); + } + + [Theory] + [InlineData(AccountMergeState.Confirmed, true)] + [InlineData(AccountMergeState.HeimdallMoved, true)] + [InlineData(AccountMergeState.Committed, false)] + [InlineData(AccountMergeState.Final, false)] + [InlineData(AccountMergeState.UndoRequested, false)] + [InlineData(AccountMergeState.HeimdallReversed, false)] + public async Task A_folded_account_is_frozen_then_folded_and_acts_as_nobody_while_the_survivor_acts_normally(AccountMergeState state, bool frozen) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", roles: MemberRole.PlatformAdmin); + var merge = await world.MergeAsync(survivor, folded, state); + + foreach (var (account, isFolded) in new[] { (folded, true), (survivor, false) }) + { + var http = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + http.User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( + [new System.Security.Claims.Claim(BifrostClaimTypes.HeimdallAccountId, account.HeimdallAccountId)], "test")); + await using var db = world.Db(); + var actor = await new CurrentBifrostActorAccessor(new Microsoft.AspNetCore.Http.HttpContextAccessor { HttpContext = http }, db, world.Ledger(db)).GetAsync(default); + + if (!isFolded) + { + Assert.Equal(survivor.Id, actor.UserAccount!.Id); + Assert.Null(actor.Merge); + continue; + } + + Assert.True(actor.IsAuthenticated); + Assert.Null(actor.UserAccount); + Assert.False(actor.IsPlatformAdmin); + Assert.False(actor.IsActiveMember); + Assert.Equal(merge.Id, actor.Merge!.MergeId); + Assert.Equal(frozen, actor.Merge is MergeStanding.Frozen); + Assert.Equal(!frozen, actor.Merge is MergeStanding.Folded f && f.SurvivorId == survivor.Id); + } + } + + [Theory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Failed)] + [InlineData(AccountMergeState.Undone)] + public async Task A_merge_that_never_reached_or_has_left_the_move_leaves_the_folded_account_standing_alone(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await world.MergeAsync(survivor, folded, state); + + await using var db = world.Db(); + Assert.Null(await world.Ledger(db).StandingAsync(folded.Id, default)); + Assert.Equal(folded.Id, await world.Ledger(db).ResolveAsync(folded.Id, default)); + } + + [Fact] + public async Task Resolve_follows_folded_links_to_the_end() + { + var world = new World(); + var a = await world.AccountAsync("acct-a"); + var b = await world.AccountAsync("acct-b"); + var c = await world.AccountAsync("acct-c"); + await world.MergeAsync(b, a, AccountMergeState.Final); + await world.MergeAsync(c, b, AccountMergeState.Committed); + + await using var db = world.Db(); + Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(a.Id, default)); + Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(c.Id, default)); + } + + // ---- plan ---- + + [Fact] + public async Task Plan_writes_nothing_and_says_what_a_commit_would_do() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + + await using (var db = world.Db()) + { + var plan = await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default); + Assert.Empty(plan.Blocks); + Assert.Contains(plan.Entries, e => e.Kind == AccountMergeEntryKind.Vote && e.Action == AccountMergeEntryAction.Moved); + Assert.Contains(plan.Entries, e => e.Kind == AccountMergeEntryKind.Membership && e.Action == AccountMergeEntryAction.Closed); + } + + await using var check = world.Db(); + Assert.Empty(check.AccountMerges); + Assert.Empty(check.AccountMergeEntries); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + Assert.Equal(folded.Id, Assert.Single(await world.AllVotesAsync(motion)).UserAccountId); + } + + public static IEnumerable BlockCases() => + new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_survivor_in_grace", "sealed_ballot_pending" } + .Select(x => new object[] { x }); + + [Theory] + [MemberData(nameof(BlockCases))] + public async Task Plan_reports_each_block_by_code(string code) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", heimdall: code != "native_account"); + var other = await world.AccountAsync("other"); + var (planSurvivor, planFolded) = (survivor.Id, folded.Id); + switch (code) + { + case "same_account": + planFolded = survivor.Id; + break; + case "mind_account": + await using (var db = world.Db()) + { + db.MindPersonas.Add(new MindPersona { PersonaId = "p", UserAccountId = folded.Id }); + await db.SaveChangesAsync(); + } + + break; + case "merge_in_flight": + await world.MergeAsync(other, folded, AccountMergeState.Confirmed); + break; + case "survivor_is_folded": + await world.MergeAsync(other, survivor, AccountMergeState.Final); + break; + case "already_folded": + await world.MergeAsync(other, folded, AccountMergeState.Committed); + break; + case "folded_survivor_in_grace": + await world.MergeAsync(folded, other, AccountMergeState.Committed); + break; + case "sealed_ballot_pending": + var motion = await world.MotionAsync(kind: MotionKind.SealedBallot); + await using (var db = world.Db()) + { + db.SealedBallots.Add(new SealedBallot { MotionId = motion.Id, UserAccountId = folded.Id, Commitment = "c", CommittedAtUtc = T0 }); + await db.SaveChangesAsync(); + } + + break; + } + + await using var read = world.Db(); + var plan = await world.Ledger(read).PlanAsync(planSurvivor, planFolded, default); + + Assert.Contains(code, plan.Blocks); + } + + // ---- commit, undo, finalize ---- + + [Fact] + public async Task Commit_twice_is_a_no_op_the_second_time_and_the_journal_is_unchanged() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.MergeAsync(survivor, folded); + + var first = await world.CommitAsync(merge); + var journal = await world.JournalAsync(merge); + world.Clock.Now = T0.AddDays(1); + var second = await world.CommitAsync(merge); + + Assert.Equal(AccountMergeState.Committed, second.State); + Assert.Equal(first.CommittedUtc, second.CommittedUtc); + Assert.Equal(first.GraceEndsUtc, T0 + TimeSpan.FromDays(7)); + Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count); + Assert.NotEmpty(journal); + } + + private sealed class FailOnce : SaveChangesInterceptor + { + public bool Armed { get; set; } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Armed) + { + Armed = false; + throw new DbUpdateException("simulated store failure"); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + } + + [Fact] + public async Task A_commit_whose_save_fails_applies_nothing_and_a_retry_applies_exactly_once() + { + var failure = new FailOnce(); + var world = new World(failure); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.MergeAsync(survivor, folded); + + failure.Armed = true; + await using (var db = world.Db()) + { + var ledger = world.Ledger(db); + await Assert.ThrowsAsync(() => ledger.CommitAsync(merge.Id, default)); + await using var check = world.Db(); + Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State); + Assert.Empty(check.AccountMergeEntries); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + + await ledger.CommitAsync(merge.Id, default); // the same context, retried + } + + var journal = await world.JournalAsync(merge); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); + } + + [Theory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Failed)] + public async Task Commit_from_any_state_but_HeimdallMoved_throws_and_undo_from_Committed_throws(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded, state); + + await using var db = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(db).CommitAsync(merge.Id, default)); + await world.SetStateAsync(merge, AccountMergeState.Committed); + await using var db2 = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(db2).UndoLocalAsync(merge.Id, default)); + } + + [Fact] + public async Task Undo_restores_open_motion_votes_keeps_voided_ones_on_since_closed_motions_and_a_survivor_recast_and_restores_membership_and_patron_events() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var bothOpen = await world.MotionAsync("both-open"); + var bothClosing = await world.MotionAsync("both-closing"); + var movedKept = await world.MotionAsync("moved-recast"); + var movedBack = await world.MotionAsync("moved-back"); + foreach (var motion in new[] { bothOpen, bothClosing }) + { + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + } + + await world.VoteAsync(movedKept, folded, VoteChoice.For, at: T0.AddHours(2)); + await world.VoteAsync(movedBack, folded, VoteChoice.For, at: T0.AddHours(3)); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = folded.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 100m, + ProviderEventId = "e1", SupportedAtUtc = T0, RecordedAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + Assert.Equal(2m, (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight); + + // After the merge: one motion closes, and the survivor re-casts on a moved vote. + await world.CloseAsync(bothClosing, moderator); + world.Clock.Now = T0.AddDays(1); + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(await world.ActorAsync(survivor.Id), movedKept.Id, VoteChoice.Against, null, default); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + Assert.Equal(AccountMergeState.Undone, (await world.Ledger(db).UndoLocalAsync(merge.Id, default)).State); + } + + var open = (await world.AllVotesAsync(bothOpen)).Single(v => v.UserAccountId == folded.Id); + Assert.Null(open.VoidedByMergeId); + var closed = (await world.AllVotesAsync(bothClosing)).Single(v => v.UserAccountId == folded.Id); + Assert.Equal(merge.Id, closed.VoidedByMergeId); + var recast = Assert.Single(await world.AllVotesAsync(movedKept)); + Assert.Equal((survivor.Id, VoteChoice.Against), (recast.UserAccountId, recast.Choice)); + Assert.Equal(folded.Id, Assert.Single(await world.AllVotesAsync(movedBack)).UserAccountId); + + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + Assert.Equal(2m, (await world.MembershipOfAsync(folded)).EffectiveVotingWeight); + Assert.Equal(1m, (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight); + await using var read = world.Db(); + Assert.Equal(folded.Id, (await read.PatronSupportEvents.SingleAsync()).UserAccountId); + var journal = await world.JournalAsync(merge); + Assert.Contains(journal, e => e.Action == AccountMergeEntryAction.KeptAfterUndo && e.Kind == AccountMergeEntryKind.Vote); + Assert.Contains(journal, e => e.Action == AccountMergeEntryAction.Restored && e.Kind == AccountMergeEntryKind.Membership); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(AccountMergeState.Undone, (await world.Ledger(read).UndoLocalAsync(merge.Id, default)).State); // a second undo is a no-op + } + + [Fact] + public async Task Finalize_moves_Committed_merges_past_their_grace_to_Final_and_leaves_the_rest() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var due = await world.AccountAsync("due"); + var young = await world.AccountAsync("young"); + var dueMerge = await world.CommitAsync(await world.MergeAsync(survivor, due)); + world.Clock.Now = T0.AddDays(5); + var youngMerge = await world.CommitAsync(await world.MergeAsync(survivor, young)); + + world.Clock.Now = T0.AddDays(7); + await using var db = world.Db(); + Assert.Equal(1, await world.Ledger(db).FinalizeDueAsync(world.Clock.Now, default)); + + await using var read = world.Db(); + var final = await read.AccountMerges.SingleAsync(x => x.Id == dueMerge.Id); + Assert.Equal((AccountMergeState.Final, T0.AddDays(7)), (final.State, final.FinalizedUtc)); + Assert.Equal(AccountMergeState.Committed, (await read.AccountMerges.SingleAsync(x => x.Id == youngMerge.Id)).State); + } +} diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 0451330..d2f625f 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -29,6 +29,7 @@ public sealed class MigrationDiscoveryTests "20261111110000_AddMembershipSuspensionReason", "20261111120000_ClearLegacyConsentReceipts", "20261113120000_AddPasskeyCredentials", + "20261114120000_AddAccountMerges", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 2c3b9a5..dfc200a 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -121,6 +121,33 @@ public sealed class OidcIssuerTests : IClassFixture Assert.False(redirect.ContainsKey("code")); } + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task A_folded_or_frozen_account_is_refused_with_access_denied_and_no_code(AccountMergeState state) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Folded One"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = Guid.NewGuid(), FoldedUserAccountId = _factory.AccountId(identity), State = state, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + var request = NewRequest(); + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.MergedDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + [Theory] [InlineData(MembershipStatus.Authenticated)] [InlineData(MembershipStatus.PendingApproval)] diff --git a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c2e5b0f..d3736b8 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -95,6 +95,34 @@ public sealed class OidcLogoutTests Assert.True(factory.ValidAuthorizations(factory.AccountId(agreed).ToString("D")) > 0); } + [Fact] + public async Task A_folded_subject_gets_one_signed_logout_and_loses_the_authorizations_while_the_survivor_keeps_them() + { + using var factory = new OidcLogoutFactory(); + var (folded, _) = await factory.GrantAsync(MembershipStatus.Active); + var (survivor, _) = await factory.GrantAsync(MembershipStatus.Active); + var foldedSubject = factory.AccountId(folded).ToString("D"); + var survivorSubject = factory.AccountId(survivor).ToString("D"); + Assert.True(factory.ValidAuthorizations(foldedSubject) > 0); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = factory.AccountId(survivor), FoldedUserAccountId = factory.AccountId(folded), + State = AccountMergeState.Committed, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())); + Assert.Equal(foldedSubject, token.Subject); + Assert.Equal(0, factory.ValidAuthorizations(foldedSubject)); + Assert.True(factory.ValidAuthorizations(survivorSubject) > 0); + } + [Fact] public async Task Two_logouts_for_one_subject_carry_different_jti() { -- 2.47.3 From 13ab2e3be7ced98e25d2345da0f14db77822f153 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:13:14 +0000 Subject: [PATCH 05/20] Fake mover: port Heimdall 4fe367a's route schema and decision order 400 for every body the route schema rejects (oneOf toProof/reversesMoveId, duplicate or empty identityIds, proof bounds), 404 identity_not_found for a source holding nothing, coverage-all-null 403 before the store, store order not_reversible/already_moved/not_found/not_proved/provider_conflict, replay by signature without liveness (409 move_id_conflict for unsigned proofs), future-dated proofs stale. Soul's P1-P4 are committed as FakeHeimdallMoverTests. Co-Authored-By: Claude Sonnet 5.5 --- .../FakeHeimdallMoverTests.cs | 165 +++++++++++++++++ tests/Bifrost.Web.Tests/Support/FakeMoves.cs | 174 ++++++++++++------ 2 files changed, 286 insertions(+), 53 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs diff --git a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs new file mode 100644 index 0000000..b1311a5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Text; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +/// +/// The fake mover is the spec the client is tested against, so it must refuse what Heimdall 4fe367a refuses, with the same status and code, in the +/// same order (app.ts moveIdentitiesRoute and its schema, identity-move.ts decideMove). Each case sends a raw body to the fake. +/// +public sealed class FakeHeimdallMoverTests +{ + private static string Id() => "move-" + Guid.NewGuid().ToString("N"); + + private static string Account() => HeimdallShapes.NewAccountId(); + + private static async Task<(HttpStatusCode Status, string Error)> Post(HeimdallHost host, string json) + { + using var client = new HttpClient(host.Heimdall, disposeHandler: false) { BaseAddress = new Uri("https://heimdall.test") }; + client.DefaultRequestHeaders.Add("x-heimdall-app-secret", HeimdallHost.AppSecret); + using var response = await client.PostAsync("/v1/apps/bifrost/identity-moves", new StringContent(json, Encoding.UTF8, "application/json")); + var body = await response.Content.ReadAsStringAsync(); + var error = System.Text.Json.JsonDocument.Parse(body).RootElement.TryGetProperty("error", out var e) ? e.GetString()! : ""; + return (response.StatusCode, error); + } + + private static string Move(string moveId, string[] from, string? to = null, string[]? ids = null, string? reverses = null) => + System.Text.Json.JsonSerializer.Serialize(new Dictionary + { + ["moveId"] = moveId, + ["fromProofs"] = from, + ["toProof"] = to, + ["identityIds"] = ids, + ["reversesMoveId"] = reverses + }.Where(x => x.Value is not null).ToDictionary()); + + [Fact] + public async Task A_body_the_routes_schema_rejects_is_a_400_and_moves_nothing() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + var forward = Id(); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(forward, [moves.Issue(s, login, "bifrost-passkey")], moves.Issue(d)))).Status); + var (s2, d2) = (Account(), Account()); + var login2 = moves.AddLogin(s2, "patreon"); + moves.AddLogin(d2, "twitch"); + var strong = () => moves.Issue(s2, login2, "bifrost-passkey"); + + var bad = new[] + { + Move(Id(), [moves.Issue(d)], moves.Issue(s), reverses: forward), + Move(Id(), [strong()]), + Move(Id(), [strong()], reverses: "x"), + Move(Id(), [strong()], moves.Issue(d2), [login2, login2]), + Move(Id(), [""], "x"), + Move(Id(), [strong()], "x", [""]), + Move(Id(), [strong()], "x", []), + Move(Id(), [moves.Issue(d)], ids: [login], reverses: forward), + Move(Id(), [], "x"), + Move(Id(), Enumerable.Repeat("p", 9).ToArray(), "x"), + Move(Id(), [new string('p', 8193)], "x"), + Move("short", [strong()], "x"), + """{"moveId":"move-0123456789abcdef","fromProofs":"p","toProof":"x"}""", + "[]" + }; + foreach (var json in bad) + { + Assert.Equal(HttpStatusCode.BadRequest, (await Post(host, json)).Status); + } + + Assert.Single(moves.Stored); + Assert.Single(moves.Logins[s2]); + } + + [Fact] + public async Task A_source_holding_no_login_is_404_identity_not_found_not_an_empty_receipt() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + moves.AddLogin(d, "twitch"); + + var answer = await Post(host, Move(Id(), [moves.Issue(s, Guid.NewGuid().ToString(), "bifrost-passkey")], moves.Issue(d))); + + Assert.Equal((HttpStatusCode.NotFound, "identity_not_found"), answer); + } + + [Fact] + public async Task An_unheld_login_is_404_before_coverage_is_judged() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + + var answer = await Post(host, Move(Id(), [moves.Issue(s, login)], moves.Issue(d), [login, Guid.NewGuid().ToString()])); + + Assert.Equal((HttpStatusCode.NotFound, "identity_not_found"), answer); + } + + [Fact] + public async Task A_proof_that_covers_no_login_at_all_is_403_identity_not_proved_before_the_store_looks() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + moves.AddLogin(d, "twitch"); + + // No auth_identity_id and no passkey: nothing is covered, even though the source holds no login either. + Assert.Equal((HttpStatusCode.Forbidden, "identity_not_proved"), await Post(host, Move(Id(), [moves.Issue(s)], moves.Issue(d)))); + } + + [Fact] + public async Task A_future_dated_proof_proves_nothing() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + + Assert.Equal((HttpStatusCode.Forbidden, "proof_stale"), await Post(host, Move(Id(), [moves.Issue(s, login, authAgo: -60)], moves.Issue(d)))); + } + + [Fact] + public async Task A_replay_whose_proofs_are_not_signed_for_the_stored_accounts_is_409_move_id_conflict_not_proof_invalid() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + var id = Id(); + var to = moves.Issue(d); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(id, [moves.Issue(s, login)], to))).Status); + + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, ["forged-bearer"], to))); + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, [moves.Issue(d)], to))); + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, [moves.Issue(s)], reverses: Id()))); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(id, [moves.Issue(s)], to))).Status); + } + + [Fact] + public async Task A_reversal_by_the_wrong_account_or_of_a_reversed_move_is_409_not_reversible_in_Heimdalls_order() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d, other) = (Account(), Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + moves.AddLogin(other, "google"); + var forward = Id(); + await Post(host, Move(forward, [moves.Issue(s, login, "bifrost-passkey")], moves.Issue(d))); + + // The wrong account proves the reversal: not_reversible only after its proof was judged live. + Assert.Equal((HttpStatusCode.Forbidden, "proof_invalid"), await Post(host, Move(Id(), ["unknown"], reverses: forward))); + Assert.Equal((HttpStatusCode.Conflict, "not_reversible"), await Post(host, Move(Id(), [moves.Issue(other, stepUpMethod: "bifrost-passkey")], reverses: forward))); + Assert.Equal((HttpStatusCode.OK, ""), await Post(host, Move(Id(), [moves.Issue(d, login, "bifrost-passkey")], reverses: forward))); + Assert.Equal((HttpStatusCode.Conflict, "not_reversible"), await Post(host, Move(Id(), [moves.Issue(d, stepUpMethod: "bifrost-passkey")], reverses: forward))); + } +} diff --git a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs index b28a0c7..e1df159 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs @@ -137,25 +137,65 @@ public sealed class FakeMoves(Func now) private static readonly System.Text.RegularExpressions.Regex MoveIdShape = new("^[A-Za-z0-9_-]{16,64}$"); + /// + /// 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 + /// unique non-empty identityIds, reversesMoveId pattern; and oneOf: a forward move has toProof and no reversesMoveId, a reversal has + /// reversesMoveId and neither toProof nor identityIds. Unknown properties are stripped. Anything else is a 400. + /// private static Move? Parse(string body) { try { var root = JsonDocument.Parse(body).RootElement; - string? Text(string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; - string[]? List(string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array - ? v.EnumerateArray().Select(x => x.ValueKind == JsonValueKind.String ? x.GetString()! : null!).ToArray() - : null; - var from = List("fromProofs"); - var ids = List("identityIds"); - if (Text("moveId") is not { } id || !MoveIdShape.IsMatch(id) - || from is not { Length: >= 1 and <= 8 } || from.Any(x => x is null) - || ids is { Length: 0 or > 32 } || (ids is not null && ids.Any(x => x is null))) + if (root.ValueKind != JsonValueKind.Object) { return null; } - return new Move(id, from, Text("toProof"), ids, Text("reversesMoveId")); + var bad = false; + string? Text(string name, int min, int max, bool pattern) + { + if (!root.TryGetProperty(name, out var v)) + { + return null; + } + + if (v.ValueKind != JsonValueKind.String || v.GetString() is not { } t || t.Length < min || t.Length > max || (pattern && !MoveIdShape.IsMatch(t))) + { + bad = true; + return null; + } + + return t; + } + + string[]? List(string name, int max, int maxLength, bool unique) + { + if (!root.TryGetProperty(name, out var v)) + { + return null; + } + + if (v.ValueKind != JsonValueKind.Array || v.GetArrayLength() is 0 || v.GetArrayLength() > max + || v.EnumerateArray().Any(x => x.ValueKind != JsonValueKind.String || x.GetString() is not { Length: > 0 } t || t.Length > maxLength)) + { + bad = true; + return null; + } + + var items = v.EnumerateArray().Select(x => x.GetString()!).ToArray(); + bad |= unique && items.Distinct().Count() != items.Length; + return items; + } + + var id = Text("moveId", 16, 64, true); + var from = List("fromProofs", 8, 8192, false); + var to = Text("toProof", 1, 8192, false); + var ids = List("identityIds", 32, int.MaxValue, true); + var reverses = Text("reversesMoveId", 16, 64, true); + var forward = to is not null && reverses is null; + var reversal = reverses is not null && to is null && ids is null; + return bad || id is null || from is null || !(forward || reversal) ? null : new Move(id, from, to, ids, reverses); } catch (JsonException) { @@ -163,59 +203,62 @@ public sealed class FakeMoves(Func now) } } + /// moveIdentitiesRoute then decideMove at 4fe367a, in their order; each comment names the check. private (HttpResponseMessage? Refusal, string? Receipt) Decide(Move move) { var at = now().ToUnixTimeSeconds(); var reversal = move.ReversesMoveId is not null; - // Replay: registry match of app and accounts, liveness not checked. - var known = move.FromProofs.Select(Known).ToArray(); + // Replay: the stored move answers before any proof is judged live; proofs need only be signed for the stored accounts. if (_moves.TryGetValue(move.MoveId, out var stored)) { - if (known.Any(x => x is null) || known.Select(x => x!.Account).Distinct().Count() != 1) - { - return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "from"), null); - } - - var toKnown = move.ToProof is null ? null : Known(move.ToProof); - if (move.ToProof is not null && toKnown is null) - { - return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "to"), null); - } - - return known[0]!.Account == stored.From && toKnown?.Account == (reversal ? null : stored.To) && move.ReversesMoveId == stored.ReversesMoveId - ? (null, stored.Receipt) - : (Refuse(HttpStatusCode.Conflict, "move_id_conflict"), null); + var same = move.ReversesMoveId == stored.ReversesMoveId + && move.FromProofs.All(x => Known(x)?.Account == stored.From) + && (reversal ? move.ToProof is null : Known(move.ToProof!)?.Account == stored.To); + return same ? (null, stored.Receipt) : (Refuse(HttpStatusCode.Conflict, "move_id_conflict"), null); } StoredMove? forward = null; - if (reversal && !(_moves.TryGetValue(move.ReversesMoveId!, out forward) && forward.ReversesMoveId is null && !forward.Reversed - && (known[0] is not { } holder || holder.Account == forward.To))) + if (reversal && !(_moves.TryGetValue(move.ReversesMoveId!, out forward) && forward.ReversesMoveId is null)) { return (Refuse(HttpStatusCode.Conflict, "not_reversible"), null); } - // proof_invalid: not a live bearer of this app, or fromProofs naming two accounts. - var from = move.FromProofs.Select(Live).ToArray(); - if (from.Any(x => x is null) || from.Select(x => x!.Account).Distinct().Count() != 1) + var from = new List(); + foreach (var token in move.FromProofs) + { + if (Live(token) is not { } claims) + { + return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "from"), null); + } + + from.Add(claims); + } + + var source = from[0].Account; + if (from.Any(x => x.Account != source)) { return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "from"), null); } - var to = move.ToProof is null ? null : Live(move.ToProof); - if (move.ToProof is not null && to is null) + Claims? to = null; + if (!reversal && (to = Live(move.ToProof!)) is null) { return (Refuse(HttpStatusCode.Forbidden, "proof_invalid", proof: "to"), null); } - var source = from[0]!.Account; - if (to is not null && to.Account == source) + if (reversal && source != forward!.To) + { + return (Refuse(HttpStatusCode.Conflict, "not_reversible"), null); + } + + var destination = to?.Account ?? forward!.From; + if (source == destination) { return (Refuse(HttpStatusCode.Conflict, "same_account"), null); } - // proof_stale: weak 300 s by auth_at, strong 600 s by step_up_at, the host clock. - if (from.Any(x => Tier(x!, at) is null)) + if (from.Any(x => Tier(x, at) is null)) { return (Refuse(HttpStatusCode.Forbidden, "proof_stale", proof: "from"), null); } @@ -225,26 +268,44 @@ public sealed class FakeMoves(Func now) return (Refuse(HttpStatusCode.Forbidden, "proof_stale", proof: "to"), null); } - var held = Logins.GetValueOrDefault(source) ?? []; - var carried = reversal ? forward!.Carried.ToArray() : move.IdentityIds ?? held.Select(x => x.Id).ToArray(); - var whole = from.Any(x => Tier(x!, at) == "strong" && x!.StepUpMethod == "bifrost-passkey"); - if (!whole && carried.Any(id => from.All(x => x!.AuthIdentityId != id))) + // proofCovers: a strong passkey proof speaks for the account, else the login the sign-in came through, else nothing. + var coverage = from.Select(x => Tier(x, at) == "strong" && x.StepUpMethod == "bifrost-passkey" ? "account" : x.AuthIdentityId).ToArray(); + if (coverage.All(x => x is null)) { return (Refuse(HttpStatusCode.Forbidden, "identity_not_proved"), null); } - var destination = reversal ? forward!.From : to!.Account; - if (!reversal && (IsAlias(source) || IsAlias(destination))) + // decideMove: reversal or alias, the logins chosen (held ones only), coverage of them, then the destination's providers. + var held = Logins.GetValueOrDefault(source) ?? []; + if (reversal) + { + if (forward!.Reversed || forward.Carried.Any(id => held.All(x => x.Id != id))) + { + return (Refuse(HttpStatusCode.Conflict, "not_reversible"), null); + } + } + else if (IsAlias(source) || IsAlias(destination)) { return (Refuse(HttpStatusCode.Conflict, "already_moved"), null); } - if (carried.Any(id => held.All(x => x.Id != id))) + var wanted = reversal ? forward!.Carried : move.IdentityIds; + if (wanted is not null && wanted.Any(id => held.All(x => x.Id != id))) { return (Refuse(HttpStatusCode.NotFound, "identity_not_found"), null); } - var moving = held.Where(x => carried.Contains(x.Id)).ToList(); + var moving = held.Where(x => wanted is null || wanted.Contains(x.Id)).ToList(); + if (moving.Count == 0) + { + return (Refuse(HttpStatusCode.NotFound, "identity_not_found"), null); + } + + if (!coverage.Contains("account") && moving.Any(x => !coverage.Contains(x.Id))) + { + return (Refuse(HttpStatusCode.Forbidden, "identity_not_proved"), null); + } + var there = Logins.GetValueOrDefault(destination) ?? []; if (moving.FirstOrDefault(x => there.Any(y => y.Provider == x.Provider)) is { } clash) { @@ -259,22 +320,27 @@ public sealed class FakeMoves(Func now) _revoked.Add(claims.Sid); } - var strong = from.Any(x => Tier(x!, at) == "strong"); - var receipt = JsonSerializer.Serialize(new Dictionary + var strong = from.Any(x => Tier(x, at) == "strong"); + var receipt = new Dictionary { ["moveId"] = move.MoveId, ["accountId"] = destination, ["movedIdentityIds"] = moving.Select(x => x.Id).ToArray(), - ["proof"] = new Dictionary { ["from"] = strong ? "strong" : "weak", ["to"] = to is null ? null : Tier(to, at) }, - ["reversesMoveId"] = move.ReversesMoveId - }.Where(x => x.Key != "reversesMoveId" || x.Value is not null).ToDictionary()); - _moves[move.MoveId] = new StoredMove(move.MoveId, source, destination, move.ReversesMoveId, moving.Select(x => x.Id).ToArray(), receipt); + ["proof"] = new Dictionary { ["from"] = strong ? "strong" : "weak", ["to"] = to is null ? null : Tier(to, at) } + }; + if (move.ReversesMoveId is not null) + { + receipt["reversesMoveId"] = move.ReversesMoveId; + } + + var json = JsonSerializer.Serialize(receipt); + _moves[move.MoveId] = new StoredMove(move.MoveId, source, destination, move.ReversesMoveId, moving.Select(x => x.Id).ToArray(), json); if (forward is not null) { forward.Reversed = true; } - return (null, receipt); + return (null, json); } private bool IsAlias(string account) => @@ -285,8 +351,10 @@ public sealed class FakeMoves(Func now) private Claims? Live(string token) => Known(token) is { } claims && !_revoked.Contains(claims.Sid) && claims.Exp > now().ToUnixTimeSeconds() ? claims : null; + /// proofTier: strong inside 600 s of step_up_at, else weak inside 300 s of auth_at; a future-dated claim proves nothing. private static string? Tier(Claims claims, long at) => - claims.StepUpAt is { } stepUp && at - stepUp <= StrongSeconds ? "strong" : at - claims.AuthAt <= WeakSeconds ? "weak" : null; + claims.StepUpAt is { } stepUp && stepUp <= at && at - stepUp <= StrongSeconds ? "strong" + : claims.AuthAt <= at && at - claims.AuthAt <= WeakSeconds ? "weak" : null; private static HttpResponseMessage Json(HttpStatusCode status, string json) => new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; -- 2.47.3 From baa70bcef08ddaf659cde3c10f85229e85e294d4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:13:14 +0000 Subject: [PATCH 06/20] Move client: no redirects, one deadline over body reads, 64 KiB receipt cap, 409 move_id_conflict is Unknown A 3xx is Unknown and never carries the secret or proofs off origin (registration owns the handler); the HttpClient timeout now bounds the body read and any IOException or stall is Unknown; receipts over 64 KiB are Unknown; the request's ToString hides its proofs; move_id_conflict is Unknown because a committed move answers it when its proofs no longer verify, which keeps the sealed proofs resumable; identity ids need not be GUIDs, proofs are bounded to 8192 and identityIds unique. Co-Authored-By: Claude Sonnet 5.5 --- .../Heimdall/HeimdallIdentityMoveClient.cs | 89 +++++-- src/Bifrost.Web/Program.cs | 9 +- .../HeimdallIdentityMoveClientTests.cs | 239 +++++++++++++++++- 3 files changed, 303 insertions(+), 34 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index b670aee..94b7853 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -13,7 +13,15 @@ namespace Bifrost.Web.Features.Heimdall; /// never names an account: the proofs, live Heimdall access tokens, say which accounts are meant. /// public sealed record HeimdallMoveRequest( - string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null); + string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null) +{ + // The proofs are live bearers: the generated ToString would print them into any log or exception that holds the request. + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"MoveId = {MoveId}, FromProofs = {FromProofs?.Count}, ToProof = {(ToProof is null ? "none" : "present")}, ReversesMoveId = {ReversesMoveId}"); + return true; + } +} public enum HeimdallProofTier { @@ -37,7 +45,6 @@ public enum HeimdallMoveRefusal IdentityNotProved, AlreadyMoved, ProviderConflict, - MoveIdConflict, IdentityNotFound, RequestInvalid, Unrecognized @@ -51,7 +58,8 @@ public enum HeimdallProofSide /// /// The closed answer to a move. Moved: the receipt. Refused: Heimdall answered a 4xx before applying anything, so nothing changed. -/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one): the caller replays the same request. +/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one, and so may a 409 move_id_conflict: Heimdall +/// answers it to a replay of a committed move whose proofs no longer verify): the caller keeps its proofs and replays the same request. /// public abstract record HeimdallMoveOutcome { @@ -74,9 +82,13 @@ public abstract record HeimdallMoveOutcome public sealed partial class HeimdallIdentityMoveClient( HttpClient httpClient, IOptions heimdallOptions, ILogger logger) { + /// The deadline of one call, headers and body together, set on the HttpClient by . public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); private const int MaxMovedIdentities = 256; + private const int MaxIdentityIdLength = 128; + private const int MaxReceiptBytes = 64 * 1024; + private const int MaxProofLength = 8192; [GeneratedRegex("^[A-Za-z0-9_-]{16,64}$")] private static partial Regex MoveIdShape(); @@ -91,6 +103,10 @@ public sealed partial class HeimdallIdentityMoveClient( private async Task SendAsync(HeimdallMoveRequest request, string body, CancellationToken cancellationToken) { + // One deadline over the whole call: HttpClient.Timeout ends at the headers, and a stalled body must not outlive it. + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(httpClient.Timeout); + var token = deadline.Token; try { using var message = new HttpRequestMessage( @@ -98,11 +114,11 @@ public sealed partial class HeimdallIdentityMoveClient( { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token); var status = (int)response.StatusCode; if (response.StatusCode == HttpStatusCode.OK) { - return await ReadReceiptAsync(response, request, cancellationToken) is { } receipt + return await ReadReceiptAsync(response, request, token) is { } receipt ? new HeimdallMoveOutcome.Moved(receipt) : new HeimdallMoveOutcome.Unknown("malformed or mismatched receipt"); } @@ -112,18 +128,24 @@ public sealed partial class HeimdallIdentityMoveClient( return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); } - var refusal = await HeimdallRefusal.ReadAsync(response, cancellationToken); + var refusal = await HeimdallRefusal.ReadAsync(response, token); return status == 400 ? new HeimdallMoveOutcome.Refused(HeimdallMoveRefusal.RequestInvalid) : Refused(refusal); } - catch (Exception error) when (error is HttpRequestException or JsonException or NotSupportedException or TaskCanceledException + catch (Exception error) when (error is HttpRequestException or IOException or JsonException or NotSupportedException or OperationCanceledException && !cancellationToken.IsCancellationRequested) { return new HeimdallMoveOutcome.Unknown("transport"); } } - private static HeimdallMoveOutcome.Refused Refused(HeimdallRefusalBody? refusal) + private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal) { + // 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"); + } + var code = refusal?.Code switch { "app_auth_required" => HeimdallMoveRefusal.AppAuthRequired, @@ -135,20 +157,19 @@ public sealed partial class HeimdallIdentityMoveClient( "identity_not_proved" => HeimdallMoveRefusal.IdentityNotProved, "already_moved" => HeimdallMoveRefusal.AlreadyMoved, "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, - "move_id_conflict" => HeimdallMoveRefusal.MoveIdConflict, "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, _ => HeimdallMoveRefusal.Unrecognized }; return code switch { - HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new(code, Side: refusal?.Proof switch + HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(code, Side: refusal?.Proof switch { "from" => HeimdallProofSide.From, "to" => HeimdallProofSide.To, _ => null }), - HeimdallMoveRefusal.ProviderConflict => new(code, Provider: refusal?.Provider), - _ => new(code) + HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(code, Provider: refusal?.Provider), + _ => new HeimdallMoveOutcome.Refused(code) }; } @@ -158,7 +179,20 @@ public sealed partial class HeimdallIdentityMoveClient( /// private static async Task ReadReceiptAsync(HttpResponseMessage response, HeimdallMoveRequest request, CancellationToken cancellationToken) { - using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (await stream.ReadAsync(chunk, cancellationToken) is > 0 and var read) + { + if (buffer.Length + read > MaxReceiptBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + using var document = JsonDocument.Parse(buffer.ToArray()); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Object || Text(root, "moveId") != request.MoveId @@ -177,7 +211,7 @@ public sealed partial class HeimdallIdentityMoveClient( var ids = new List(); foreach (var item in moved.EnumerateArray()) { - if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out _)) + if (item.ValueKind != JsonValueKind.String || item.GetString() is not { Length: > 0 and <= MaxIdentityIdLength }) { return null; } @@ -212,6 +246,8 @@ public sealed partial class HeimdallIdentityMoveClient( private static string? Text(JsonElement item, string name) => item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static bool IsBadProof(string? proof) => string.IsNullOrEmpty(proof) || proof.Length > MaxProofLength; + /// The contract's closed body, or ArgumentException before any I/O when the request breaks it. private static string Serialize(HeimdallMoveRequest request) { @@ -220,7 +256,7 @@ public sealed partial class HeimdallIdentityMoveClient( throw new ArgumentException("moveId must match ^[A-Za-z0-9_-]{16,64}$", nameof(request)); } - if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(string.IsNullOrWhiteSpace)) + if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(IsBadProof)) { throw new ArgumentException("fromProofs must hold 1 to 8 proofs", nameof(request)); } @@ -230,7 +266,7 @@ public sealed partial class HeimdallIdentityMoveClient( throw new ArgumentException("exactly one of toProof and reversesMoveId is required", nameof(request)); } - if (request.ToProof is not null && string.IsNullOrWhiteSpace(request.ToProof)) + if (request.ToProof is not null && IsBadProof(request.ToProof)) { throw new ArgumentException("toProof is empty", nameof(request)); } @@ -241,9 +277,10 @@ public sealed partial class HeimdallIdentityMoveClient( } if (request.IdentityIds is not null - && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrWhiteSpace))) + && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrEmpty) + || request.IdentityIds.Distinct().Count() != request.IdentityIds.Count)) { - throw new ArgumentException("identityIds are 1 to 32 and only with toProof", nameof(request)); + throw new ArgumentException("identityIds are 1 to 32 unique ids and only with toProof", nameof(request)); } var body = new Dictionary { ["moveId"] = request.MoveId, ["fromProofs"] = request.FromProofs }; @@ -265,3 +302,19 @@ public sealed partial class HeimdallIdentityMoveClient( return JsonSerializer.Serialize(body); } } + +public static class HeimdallIdentityMoveClientServices +{ + /// + /// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the + /// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction. + /// + public static IHttpClientBuilder AddHeimdallIdentityMoveClient(this IServiceCollection services) => + services.AddHttpClient((serviceProvider, client) => + { + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); + client.Timeout = HeimdallIdentityMoveClient.Timeout; + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 2590f77..38f73cc 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -158,13 +158,8 @@ builder.Services.AddHttpClient((serviceProvider, client) => builder.Services.AddHttpClient((serviceProvider, client) => HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); -// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction. -builder.Services.AddHttpClient((serviceProvider, client) => - { - HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); - client.Timeout = HeimdallIdentityMoveClient.Timeout; - }) - .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction, no redirects. +builder.Services.AddHeimdallIdentityMoveClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index 8b312ba..8157cd1 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -259,14 +259,16 @@ public sealed class HeimdallIdentityMoveClientTests var (d1, _) = AccountWith(moves, "discord"); Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), await Move(host, Forward(moves, s1, d1))); - // move_id_conflict: a used id under other accounts + // move_id_conflict is not a refusal: a committed move answers it when its proofs no longer verify, so the caller keeps its proofs. var (s2, _) = AccountWith(moves, "patreon"); var (d2, _) = AccountWith(moves, "twitch"); var used = Forward(moves, s2, d2); Receipt(await Move(host, used)); var (s3, _) = AccountWith(moves, "patreon"); - Assert.Equal(new Refused(HeimdallMoveRefusal.MoveIdConflict), + Assert.Equal(new Unknown("move_id_conflict"), await Move(host, new(used.MoveId, [moves.Issue(s3, stepUpMethod: Passkey)], moves.Issue(d2)))); + Assert.Equal(new Unknown("move_id_conflict"), + await Move(host, new(used.MoveId, ["forged-bearer"], used.ToProof))); // already_moved: s2 now holds nothing and an unreversed move left it Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s2, d0))); @@ -348,16 +350,82 @@ public sealed class HeimdallIdentityMoveClientTests await Assert.ThrowsAnyAsync(() => ClientOf(host).MoveAsync(request, cancelled.Token)); } - private sealed class Stub(string json) : HttpMessageHandler + private sealed class Stub(Func content, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler { + public Stub(string json, HttpStatusCode status = HttpStatusCode.OK) + : this(() => new StringContent(json, Encoding.UTF8, "application/json"), status) + { + } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }); + Task.FromResult(new HttpResponseMessage(status) { Content = content() }); } - private static Task Answered(string json, HeimdallMoveRequest request) => - new HeimdallIdentityMoveClient( - new HttpClient(new Stub(json)) { BaseAddress = new Uri("https://heimdall.test") }, - Options.Create(new HeimdallOptions()), NullLogger.Instance).MoveAsync(request, CancellationToken.None); + /// A body that serves a prefix and then stalls, resets, or streams endless filler. + private sealed class Body(string prefix, bool reset = false, long endless = 0) : Stream + { + private readonly byte[] _prefix = Encoding.UTF8.GetBytes(prefix); + + public long Served { get; private set; } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position { get => Served; set => throw new NotSupportedException(); } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (Served < _prefix.Length) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length - Served); + _prefix.AsMemory((int)Served, n).CopyTo(buffer); + Served += n; + return n; + } + + if (endless > 0 && Served < _prefix.Length + endless) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length + endless - Served); + buffer.Span[..n].Fill((byte)'a'); + Served += n; + return n; + } + + if (reset) + { + throw new IOException("connection reset mid-body"); + } + + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } + + private static HeimdallIdentityMoveClient Client(HttpMessageHandler handler, TimeSpan? timeout = null) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://heimdall.test"), Timeout = timeout ?? TimeSpan.FromSeconds(30) }, + Options.Create(new HeimdallOptions()), NullLogger.Instance); + + private static Task Answered(string json, HeimdallMoveRequest request, HttpStatusCode status = HttpStatusCode.OK) => + Client(new Stub(json, status)).MoveAsync(request, CancellationToken.None); + + private static readonly HeimdallMoveRequest Plain = new("move-0123456789abcdef", ["p"], "q"); [Fact] public async Task A_200_that_is_not_a_whole_receipt_for_this_request_is_Unknown() @@ -370,6 +438,7 @@ public sealed class HeimdallIdentityMoveClientTests // The well formed shapes are accepted, so each refusal below is about its one flaw. Assert.IsType(await Answered(Body(), forward)); + Assert.IsType(await Answered(Body(ids: "\"not-a-guid-but-an-id\""), forward)); Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":null}"""), reversal)); Assert.IsType(await Answered(Body("move-someoneelse12345"), forward)); @@ -380,7 +449,7 @@ public sealed class HeimdallIdentityMoveClientTests Assert.IsType(await Answered(Body(proof: """{"from":"strong","to":null}"""), forward)); Assert.IsType(await Answered(Body(proof: """{"from":"maybe","to":"weak"}"""), forward)); Assert.IsType(await Answered(Body(ids: ""), forward)); - Assert.IsType(await Answered(Body(ids: "\"not-a-guid\""), forward)); + Assert.IsType(await Answered(Body(ids: "123"), forward)); Assert.IsType(await Answered("""{"moveId":"move-0123456789abcdef"}""", forward)); Assert.IsType(await Answered("[]", forward)); } @@ -417,6 +486,156 @@ public sealed class HeimdallIdentityMoveClientTests Assert.Empty(moves.Requests); } + // ---- the wire: bounds, deadline, redirects, secrets ---- + + [Fact] + public async Task A_body_that_stalls_after_the_headers_is_Unknown_within_the_clients_deadline_for_a_receipt_and_a_refusal() + { + foreach (var (status, prefix) in new[] { (HttpStatusCode.OK, """{"moveId":"move-0123456789abcdef","""), (HttpStatusCode.Conflict, """{"error":"pro""") }) + { + var client = Client(new Stub(() => new StreamContent(new Body(prefix)), status), TimeSpan.FromMilliseconds(500)); + var task = client.MoveAsync(Plain, CancellationToken.None); + Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10)))); + Assert.IsType(await task); + } + } + + [Fact] + public async Task A_connection_reset_while_reading_the_body_is_Unknown_not_an_exception() + { + var client = Client(new Stub(() => new StreamContent(new Body("""{"moveId":"move-0123456789abcdef",""", reset: true)))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + } + + [Fact] + public async Task A_receipt_body_past_64_KiB_is_refused_unread_as_Unknown() + { + var body = new Body("""{"moveId":"move-0123456789abcdef","accountId":" """, endless: 64L * 1024 * 1024); + var client = Client(new Stub(() => new StreamContent(body))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + Assert.True(body.Served < 1024 * 1024, $"read {body.Served} bytes"); + } + + [Fact] + public async Task Refusal_qualifiers_are_kept_only_for_the_codes_that_carry_them() + { + Assert.Equal(new Refused(HeimdallMoveRefusal.SameAccount), + await Answered("""{"error":"same_account","proof":"from","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), + await Answered("""{"error":"already_moved","proof":"to"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), + await Answered("""{"error":"provider_conflict","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.To), + await Answered("""{"error":"proof_stale","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Forbidden)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid), + await Answered("""{"error":"proof_invalid","proof":"sideways"}""", Plain, HttpStatusCode.Forbidden)); + } + + [Fact] + public async Task The_largest_legal_request_is_sent_and_moves_what_it_names() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var source = NewAccount(); + var ids = Enumerable.Range(0, 32).Select(_ => moves.AddLogin(source, "discord")).ToArray(); + 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))); + + Assert.Equal(ids, receipt.MovedIdentityIds); + Assert.Equal(8, JsonDocument.Parse(moves.Requests[0].Body).RootElement.GetProperty("fromProofs").GetArrayLength()); + } + + [Fact] + public void A_request_does_not_print_its_proofs() + { + var request = new HeimdallMoveRequest("move-0123456789abcdef", ["FROM-BEARER-SECRET"], "TO-BEARER-SECRET"); + + Assert.DoesNotContain("BEARER-SECRET", request.ToString()); + Assert.Contains("move-0123456789abcdef", request.ToString()); + } + + private static ServiceProvider RealRegistration(string baseUrl) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = baseUrl, AppSecret = "APP-SECRET-VALUE" })); + services.AddHeimdallIdentityMoveClient(); + return services.BuildServiceProvider(); + } + + [Fact] + public void The_registered_client_carries_the_app_secret_and_the_15_second_deadline() + { + using var provider = RealRegistration("https://heimdall.test"); + var http = provider.GetRequiredService().CreateClient(nameof(HeimdallIdentityMoveClient)); + + Assert.Equal(TimeSpan.FromSeconds(15), http.Timeout); + Assert.Equal(new Uri("https://heimdall.test"), http.BaseAddress); + Assert.Equal("APP-SECRET-VALUE", Assert.Single(http.DefaultRequestHeaders.GetValues(HeimdallAuthService.AppSecretHeader))); + } + + [Theory] + [InlineData(307)] + [InlineData(302)] + public async Task A_redirect_is_Unknown_and_nothing_reaches_the_other_origin(int code) + { + static int FreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + int first = FreePort(), second = FreePort(); + using var origin = new HttpListener(); + origin.Prefixes.Add($"http://127.0.0.1:{first}/"); + origin.Start(); + using var other = new HttpListener(); + other.Prefixes.Add($"http://127.0.0.1:{second}/"); + other.Start(); + var redirected = Task.Run(async () => + { + var context = await origin.GetContextAsync(); + context.Response.StatusCode = code; + context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal"; + context.Response.Close(); + }); + var stolen = new List(); + _ = Task.Run(async () => + { + try + { + var context = await other.GetContextAsync(); + stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + new StreamReader(context.Request.InputStream).ReadToEnd()); + var forged = Encoding.UTF8.GetBytes($$$"""{"moveId":"move-0123456789abcdef","accountId":"attacker","movedIdentityIds":["{{{Guid.NewGuid()}}}"],"proof":{"from":"weak","to":"weak"}}"""); + context.Response.StatusCode = 200; + await context.Response.OutputStream.WriteAsync(forged); + context.Response.Close(); + } + catch (HttpListenerException) + { + } + catch (ObjectDisposedException) + { + } + }); + using var provider = RealRegistration($"http://127.0.0.1:{first}"); + + var outcome = await provider.GetRequiredService() + .MoveAsync(new("move-0123456789abcdef", ["FROM-BEARER"], "TO-BEARER"), CancellationToken.None); + await redirected; + await Task.Delay(300); + + Assert.IsType(outcome); + Assert.Empty(stolen); + } + // ---- what the logs hold ---- [Fact] @@ -434,6 +653,8 @@ public sealed class HeimdallIdentityMoveClientTests Assert.Contains("Heimdall identity move: Moved", logs); Assert.Contains("Heimdall identity move: Refused", logs); Assert.Contains("identity-moves", logs); + // Only the secret and bearer headers are redacted: an ordinary header's value is logged, so the shared redaction is what is in force. + Assert.Contains("application/json", logs); foreach (var secret in new[] { HeimdallHost.AppSecret, source, destination, request.FromProofs[0], request.ToProof!, request.MoveId }.Concat(ids)) { Assert.DoesNotContain(secret, logs); -- 2.47.3 From f402eb13cc5b11dcfa0819fcf2f66af97cda17b3 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:37:48 +0000 Subject: [PATCH 07/20] Account merge ledger: fix the first Soul pass (suspension carried into a memberless survivor, merged status terminal, standing at the desk door, linked accounts and write time, journalled grace intake, survivor weight on moved votes, one-hop alias, State token and database guards for overlapping merges, live-Postgres tests) Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 8 + docs/account-merge.md | 26 +- src/Bifrost.Web/Data/BifrostDbContext.cs | 9 +- .../20261114120000_AddAccountMerges.cs | 46 ++ .../BifrostDbContextModelSnapshot.cs | 6 + src/Bifrost.Web/Domain/AccountMergeModels.cs | 3 +- src/Bifrost.Web/Domain/BifrostModels.cs | 4 + .../ConceptVotes/ConceptAppraisalService.cs | 8 + .../Features/Heimdall/HeimdallAuthService.cs | 4 +- .../HeimdallPatronSupportIntakeService.cs | 23 +- .../Features/Membership/AccountMergeLedger.cs | 167 +++++-- .../Membership/MembershipSuspensionService.cs | 6 + .../MembershipSynchronizationService.cs | 5 + .../Motions/MotionGovernanceService.cs | 8 + .../Features/Oidc/OidcEndpoints.cs | 6 + .../Shared/ApplicationBootstrapper.cs | 8 +- .../Pages/App/Members/Index.cshtml.cs | 8 + .../Pages/Membership/LinkedAccounts.cshtml.cs | 17 + src/Bifrost.Web/Program.cs | 5 +- .../AccountMergeHttpTests.cs | 155 ++++++- .../AccountMergeLedgerTests.cs | 415 ++++++++++++++++-- .../AccountMergePostgresTests.cs | 405 +++++++++++++++++ .../HeimdallCompletionTests.cs | 28 ++ 23 files changed, 1273 insertions(+), 97 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs diff --git a/AGENTS.md b/AGENTS.md index 0b17617..b0c6e04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,3 +80,11 @@ Then start the first implementation milestone: - work items - motions - ledgers + +## Live Postgres tests + +Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the migration's rollback guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: + +```bash +BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests +``` diff --git a/docs/account-merge.md b/docs/account-merge.md index da6adfe..3d444c5 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -18,23 +18,23 @@ An account's standing is derived from that state on every request; there is no ` | Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` | | Proposed, Failed, Undone | none | -`ResolveAsync(id)` follows `Folded` links to the end (at most eight hops; each account is folded once). Heimdall patron intake resolves through it, so an event for a folded account's Heimdall id records on the survivor. +An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Heimdall patron intake reads the standing: an event for a folded account's Heimdall id records on the survivor and is journalled on the merge (`JournalIntakeAsync`, idempotent for a provider's retry), so an undo moves it back. -`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. +`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and the vote and appraisal writers read it again at the write, so an actor resolved before the merge froze the account writes nothing after it. ## Plan, commit, undo, finalize -`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_survivor_in_grace`, `sealed_ballot_pending`. +`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_is_survivor`, `sealed_ballot_pending`. The database backs the two overlap blocks the plan checks first (check-then-act): a partial unique index lets an account be folded by one live merge only (Confirmed through HeimdallReversed), and a trigger refuses a live merge whose survivor is another live merge's folded account, taking advisory locks on both accounts so racing transactions serialise. `CommitAsync(mergeId)` needs `HeimdallMoved` (Committed and Final return the receipt; any other state throws). It is total: blocks were checked before the move, so it never refuses on them. It applies everything in one `SaveChanges` together with `State = Committed` and `GraceEndsUtc = CommittedUtc + 7 days`; a failed save leaves the merge `HeimdallMoved` and applies nothing, and a retry applies once. What it reduces: -- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge )`. A survivor without a membership row is not given one. +- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge )`. A survivor without a membership row (the chat tier holds none) is given a Suspended one, journalled as `Standing`/`Created`, because an absent membership chats and the next member-access claim would create an Active one; undo removes it unless an admin has taken it over since. - **Membership.** The folded membership becomes `Merged` (weight 0 by the not-Active rule). Roles and admin flags of the folded membership are never copied. -- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's weight and cast time; a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched. -- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:` if the survivor is Active, else it is voided. -- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history. +- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's cast time and takes the survivor's weight, derived from the survivor's tiers after the patron events moved (the folded weight is journalled for undo); a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched. +- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:` as a Member voter if the survivor is Active, else it is voided (the prior class and key are journalled). +- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history (only `PatronageService` writes Patron snapshots, so a merge with no folded events leaves both tiers as they were). Voiding stamps `VoidedByMergeId` and `VoidedUtc`; the model's query filter on `Vote` and `ConceptAppraisal` then removes the row from every tally, including future ones. Nothing is deleted. @@ -42,8 +42,16 @@ Left untouched (counted by the plan): consent receipts, passkeys, ledger entries `UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to. -`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`; `AccountMergeSweeper` runs it every five minutes. Undo is never allowed past Final (the saga reads `State` and `GraceEndsUtc`). +`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every five minutes. `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final. + +## Merged is terminal + +Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): GitHub sign-in sync leaves the account untouched, the bootstrap-admin promotion (startup and sign-in) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status. + +## Rollback + +Voiding lives in the new build's query filter and the `VoidedByMergeId` columns. `Down` refuses to run while any vote or appraisal is voided (dropping the columns would count them again), and an older build run over the migrated schema also counts voided rows, because it has no filter. Rolling the code back while merges have landed is therefore unsafe; whether voided votes should instead leave the `Votes` table (so an older build cannot see them) is an open question for the operator, raised on the cut. ## Not covered -`MembershipSynchronizationService` still writes the profile of any GitHub-signed-in row it finds, and re-promotes a bootstrap admin; it is not a merge path and was not changed here. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. +`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. A vote written by a request that passed the standing read at the write and saved after the commit read the votes (microseconds, inside one request) is the one window left; such a vote stays live as a second voice until an operator voids it. Closing it fully needs the tally-time voiding of design section 7, which is not built. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 1c4757e..3e8ce39 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -628,9 +628,16 @@ public sealed class BifrostDbContext(DbContextOptions options) modelBuilder.Entity(entity => { - entity.Property(x => x.State).HasConversion().HasMaxLength(24); + // State is the token: a transition computed from a stale read (the sweeper's Final over a saga's UndoRequested) fails its save. + entity.Property(x => x.State).HasConversion().HasMaxLength(24).IsConcurrencyToken(); entity.Property(x => x.FailureCode).HasMaxLength(64); entity.HasIndex(x => new { x.FoldedUserAccountId, x.State }); + // An account is folded once: the DB refuses a second live merge folding it. The cross-role case (a survivor that is also + // folded) cannot be one unique index; the migration's trigger refuses it. + entity.HasIndex(x => x.FoldedUserAccountId) + .IsUnique() + .HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live") + .HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')"); entity.HasIndex(x => new { x.SurvivorUserAccountId, x.State }); entity.HasIndex(x => new { x.State, x.GraceEndsUtc }); entity.HasOne() diff --git a/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs index 761ac60..67cfc82 100644 --- a/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs +++ b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs @@ -105,6 +105,13 @@ namespace Bifrost.Web.Data.Migrations columns: new[] { "MergeId", "Seq" }, unique: true); + migrationBuilder.CreateIndex( + name: "UX_AccountMerges_FoldedUserAccountId_Live", + table: "AccountMerges", + column: "FoldedUserAccountId", + unique: true, + filter: "\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')"); + migrationBuilder.CreateIndex( name: "IX_AccountMerges_FoldedUserAccountId_State", table: "AccountMerges", @@ -119,11 +126,50 @@ namespace Bifrost.Web.Data.Migrations name: "IX_AccountMerges_SurvivorUserAccountId_State", table: "AccountMerges", columns: new[] { "SurvivorUserAccountId", "State" }); + + // The cross-role guard no single unique index can express: a live merge's survivor is never another live merge's folded + // account (no chains), whichever order they arrive in. Advisory locks on both accounts, taken in a fixed order, serialise two + // transactions racing over the same account, so the second one's check sees the first's committed row. + migrationBuilder.Sql(@" +CREATE FUNCTION ""AccountMerges_refuse_chain""() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed') THEN + PERFORM pg_advisory_xact_lock(hashtextextended(LEAST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0)); + PERFORM pg_advisory_xact_lock(hashtextextended(GREATEST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0)); + IF EXISTS ( + SELECT 1 FROM ""AccountMerges"" m + WHERE m.""Id"" <> NEW.""Id"" + AND m.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed') + AND (m.""SurvivorUserAccountId"" = NEW.""FoldedUserAccountId"" OR m.""FoldedUserAccountId"" = NEW.""SurvivorUserAccountId"")) + THEN + RAISE EXCEPTION 'account merge chain refused' USING ERRCODE = '23P01'; + END IF; + END IF; + RETURN NEW; +END +$$; +CREATE TRIGGER ""AccountMerges_refuse_chain"" BEFORE INSERT OR UPDATE OF ""State"", ""SurvivorUserAccountId"", ""FoldedUserAccountId"" ON ""AccountMerges"" + FOR EACH ROW EXECUTE FUNCTION ""AccountMerges_refuse_chain""();"); } /// protected override void Down(MigrationBuilder migrationBuilder) { + // Voiding lives only in the new build's query filter and these columns: dropping them (or running an older build over them) + // counts every voided vote again, and a close in that window records a wrong result for good. Refuse while any is voided; + // see docs/account-merge.md (Rollback). + migrationBuilder.Sql(@" +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM ""Votes"" WHERE ""VoidedByMergeId"" IS NOT NULL) + OR EXISTS (SELECT 1 FROM ""ConceptAppraisals"" WHERE ""VoidedByMergeId"" IS NOT NULL) THEN + RAISE EXCEPTION 'AddAccountMerges cannot be reverted while merged votes or appraisals are voided'; + END IF; +END +$$;"); + + migrationBuilder.Sql(@"DROP TRIGGER ""AccountMerges_refuse_chain"" ON ""AccountMerges""; DROP FUNCTION ""AccountMerges_refuse_chain""();"); + migrationBuilder.DropTable( name: "AccountMergeEntries"); diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 6cae29d..83bc319 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -52,6 +52,7 @@ namespace Bifrost.Web.Data.Migrations .HasColumnType("timestamp with time zone"); b.Property("State") + .IsConcurrencyToken() .IsRequired() .HasMaxLength(24) .HasColumnType("character varying(24)"); @@ -64,6 +65,11 @@ namespace Bifrost.Web.Data.Migrations b.HasKey("Id"); + b.HasIndex("FoldedUserAccountId") + .IsUnique() + .HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live") + .HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')"); + b.HasIndex("FoldedUserAccountId", "State"); b.HasIndex("State", "GraceEndsUtc"); diff --git a/src/Bifrost.Web/Domain/AccountMergeModels.cs b/src/Bifrost.Web/Domain/AccountMergeModels.cs index 278360f..619d45b 100644 --- a/src/Bifrost.Web/Domain/AccountMergeModels.cs +++ b/src/Bifrost.Web/Domain/AccountMergeModels.cs @@ -29,6 +29,7 @@ public enum AccountMergeEntryAction Moved, Closed, Suspended, + Created, Restored, KeptAfterUndo } @@ -81,7 +82,7 @@ public sealed class AccountMergeEntry public Guid? PriorUserAccountId { get; set; } - /// The prior status or prior voter key. + /// What the record held before: the prior status; for a moved vote its weight; for a moved appraisal its voter class and key (class|key). public string? PriorText { get; set; } /// The cast time of a moved vote or appraisal, so undo can tell whether the survivor re-cast it. diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index ff57a66..85d43e7 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -359,6 +359,10 @@ public sealed class Membership public ICollection TierSnapshots { get; set; } = []; + /// The folded membership of a landed merge. Terminal: only AccountMergeLedger writes it or leaves it, so every other status writer (sign-in sync, bootstrap, claim, approval, suspension) refuses a merged row. + [NotMapped] + public bool IsMerged => Status == MembershipStatus.Merged; + [NotMapped] public decimal EffectiveVotingWeight => Status != MembershipStatus.Active diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index ac96eb8..e290c82 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -2,6 +2,7 @@ using System.Net; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -64,6 +65,13 @@ public sealed class ConceptAppraisalService( return CastResult.NoVoter; } + // A voter resolved before a merge froze the account must not write an appraisal after it: standing is read again at the write. + if (voter.UserAccountId is { } voterAccountId && + await AccountMergeLedger.StandingAsync(dbContext, voterAccountId, cancellationToken) is not null) + { + return CastResult.NoVoter; + } + var subject = await FindSubjectAsync(collection, key, cancellationToken); if (subject is null) { diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index 4741fe1..e52cc75 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -845,8 +845,8 @@ public sealed class HeimdallAuthService( UserAccountId = userAccount.Id, CreatedAtUtc = now }; - // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. - if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended) + // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. A merged row is terminal: the claim never reactivates it. + if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended && !userAccount.Membership.IsMerged) { userAccount.Membership.Status = MembershipStatus.Active; userAccount.Membership.ApprovedAtUtc ??= now; diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs index 21f21dc..fe88cae 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -66,17 +66,25 @@ public sealed class HeimdallPatronSupportIntakeService( x.ProviderEventId == request.ProviderEventId.Trim(), cancellationToken); + var user = await dbContext.UserAccounts + .AsNoTracking() + .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); + + // A folded account's events record on the survivor and are journalled on the merge, so an undo moves them back. + var folded = user is null ? null : await mergeLedger.StandingAsync(user.Id, cancellationToken) as MergeStanding.Folded; + if (existingEvent is not null) { + if (folded is not null && user is not null) + { + await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, existingEvent.Id, cancellationToken); + } + return HeimdallPatronSupportIntakeResult.Processed( $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", existingEvent.Id); } - var user = await dbContext.UserAccounts - .AsNoTracking() - .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); - if (user is null) { return HeimdallPatronSupportIntakeResult.BadRequest( @@ -85,7 +93,7 @@ public sealed class HeimdallPatronSupportIntakeService( var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, - userAccountId: await mergeLedger.ResolveAsync(user.Id, cancellationToken), // a folded account's events record on the survivor + userAccountId: folded?.SurvivorId ?? user.Id, request.Kind, request.Amount, request.CurrencyCode, @@ -99,6 +107,11 @@ public sealed class HeimdallPatronSupportIntakeService( request.Notes, cancellationToken); + if (folded is not null) + { + await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, supportEvent.Id, cancellationToken); + } + return HeimdallPatronSupportIntakeResult.Processed( $"Processed {request.Provider} patron support event {request.ProviderEventId}.", supportEvent.Id); diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs index e2f7135..3e964e3 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Patronage; @@ -29,7 +30,7 @@ public sealed record MergePlan( /// /// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed -/// save, the undo save, the Final transition, StandingAsync and ResolveAsync. A merge only reduces: nothing is deleted, votes and +/// save, the undo save, the Final transition and StandingAsync. A merge only reduces: nothing is deleted, votes and /// appraisals are stamped void (the model's query filter then removes them from every tally) or re-pointed, and every change is /// journalled in AccountMergeEntry. Design: /opt/commons/handoff/heimdall-account-merge-design-20261113.md sections 3, 5, 7. /// @@ -40,8 +41,6 @@ public sealed class AccountMergeLedger( { public static readonly TimeSpan Grace = TimeSpan.FromDays(7); - private const int MaxHops = 8; - private static readonly AccountMergeState[] FrozenStates = [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved]; private static readonly AccountMergeState[] FoldedStates = @@ -71,23 +70,6 @@ public sealed class AccountMergeLedger( : new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId); } - /// Follows Folded links to the account that now carries the person. Each account is folded once, so the hop cap is only a guard. - public async Task ResolveAsync(Guid userAccountId, CancellationToken cancellationToken) - { - var current = userAccountId; - for (var hop = 0; hop < MaxHops; hop++) - { - if (await StandingAsync(current, cancellationToken) is not MergeStanding.Folded folded) - { - return current; - } - - current = folded.SurvivorId; - } - - return current; - } - /// Writes nothing. lets the saga re-plan a merge it already holds without it blocking itself. public async Task PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null) { @@ -155,12 +137,12 @@ public sealed class AccountMergeLedger( blocks.Add("already_folded"); } - // Heimdall could no longer reverse a merge this account survived once it is folded into another. + // One hop only: a landed merge's survivor never folds into a third account, so an alias is never a chain. if (await dbContext.AccountMerges.AnyAsync( - x => x.Id != exceptMergeId && x.State == AccountMergeState.Committed && x.SurvivorUserAccountId == foldedId, + x => x.Id != exceptMergeId && FoldedStates.Contains(x.State) && x.SurvivorUserAccountId == foldedId, cancellationToken)) { - blocks.Add("folded_survivor_in_grace"); + blocks.Add("folded_is_survivor"); } if (await dbContext.SealedBallots.AnyAsync( @@ -212,6 +194,7 @@ public sealed class AccountMergeLedger( await RestageTiersAsync(reduction.Survivor, reduction.Folded, now, cancellationToken); } + reduction.ReweighMovedVotes(); merge.State = AccountMergeState.Committed; merge.CommittedUtc = now; merge.GraceEndsUtc = now + Grace; @@ -240,6 +223,11 @@ public sealed class AccountMergeLedger( throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallReversed merge can be undone locally."); } + if (merge.FinalizedUtc is not null) + { + throw new InvalidOperationException($"Merge {mergeId} is Final; a merge is never undone past Final."); + } + try { var now = timeProvider.GetUtcNow(); @@ -264,7 +252,7 @@ public sealed class AccountMergeLedger( AtUtc = now }); - foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended)) + foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended or AccountMergeEntryAction.Created)) { switch (entry.Kind, entry.Action) { @@ -288,6 +276,10 @@ public sealed class AccountMergeLedger( if (restore) { vote!.UserAccountId = entry.PriorUserAccountId!.Value; + if (decimal.TryParse(entry.PriorText, NumberStyles.Number, CultureInfo.InvariantCulture, out var priorWeight)) + { + vote.Weight = priorWeight; + } } Record(entry, restore); @@ -313,7 +305,9 @@ public sealed class AccountMergeLedger( if (restore) { row!.UserAccountId = entry.PriorUserAccountId!.Value; - row.VoterKey = entry.PriorText ?? row.VoterKey; + var (priorClass, priorKey) = ParseAppraisalPrior(entry.PriorText); + row.VoterKey = priorKey ?? row.VoterKey; + row.VoterClass = priorClass ?? row.VoterClass; } Record(entry, restore); @@ -345,6 +339,19 @@ public sealed class AccountMergeLedger( Record(entry, restore); break; } + case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created): + { + // The membership this merge made to carry a suspension goes with the merge, unless an admin has taken it over since. + var created = await dbContext.Memberships.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = created is { Status: MembershipStatus.Suspended } && created.SuspensionReason == SuspensionReason(mergeId); + if (restore) + { + dbContext.Memberships.Remove(created!); + } + + Record(entry, restore); + break; + } case (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved): { var row = await dbContext.PatronSupportEvents.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); @@ -378,20 +385,62 @@ public sealed class AccountMergeLedger( } } - /// Committed merges whose grace has ended become Final; returns how many. Undo is never allowed past Final. + /// + /// Committed merges whose grace has ended become Final; returns how many this call finalized. Undo is never allowed past Final. + /// State is the merge's concurrency token, so a merge another writer moved first (UndoRequested) fails its save and is left to that + /// transition: a stale Committed read can never overwrite it. + /// public async Task FinalizeDueAsync(DateTimeOffset now, CancellationToken cancellationToken) { - var due = await dbContext.AccountMerges + var dueIds = await dbContext.AccountMerges.AsNoTracking() .Where(x => x.State == AccountMergeState.Committed && x.GraceEndsUtc <= now) + .Select(x => x.Id) .ToListAsync(cancellationToken); - foreach (var merge in due) + var finalized = 0; + foreach (var id in dueIds) { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == id, cancellationToken); + if (merge.State != AccountMergeState.Committed) + { + continue; + } + merge.State = AccountMergeState.Final; merge.FinalizedUtc = now; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + finalized++; + } + catch (DbUpdateConcurrencyException) + { + dbContext.ChangeTracker.Clear(); + } } + return finalized; + } + + /// + /// Journals a patron event that landed on a folded account's survivor during grace, so undo moves it back with the rest. Idempotent: + /// the intake calls it again when the provider retries an event it already processed. + /// + public async Task JournalIntakeAsync(Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, CancellationToken cancellationToken) + { + if (await dbContext.AccountMergeEntries.AnyAsync( + x => x.MergeId == mergeId && x.Kind == AccountMergeEntryKind.PatronSupportEvent && x.RecordId == supportEventId, + cancellationToken)) + { + return; + } + + var seq = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0; + dbContext.AccountMergeEntries.Add(Journal( + mergeId, + seq + 1, + new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEventId, foldedUserAccountId), + timeProvider.GetUtcNow())); await dbContext.SaveChangesAsync(cancellationToken); - return due.Count; } private sealed class Reduction( @@ -399,6 +448,7 @@ public sealed class AccountMergeLedger( UserAccount folded, List entries, List> effects, + List movedVotes, bool movedPatronEvents) { public UserAccount Survivor { get; } = survivor; @@ -411,6 +461,15 @@ public sealed class AccountMergeLedger( public Guid MergeId { get; set; } + /// A moved vote carries the survivor's weight, derived once from the survivor's tiers as they stand after the restage, never the folded account's. + public void ReweighMovedVotes() + { + foreach (var vote in movedVotes) + { + vote.Weight = Survivor.Membership!.EffectiveVotingWeight; + } + } + public void Apply(DateTimeOffset now) { foreach (var effect in effects) @@ -428,25 +487,43 @@ public sealed class AccountMergeLedger( /// private async Task ReduceAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? mergeId = null) { - var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + var survivor = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents) .SingleAsync(x => x.Id == survivorId, cancellationToken); - var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + var folded = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents) .SingleAsync(x => x.Id == foldedId, cancellationToken); var entries = new List(); var effects = new List>(); + var movedVotes = new List(); var foldedSuspended = folded.Membership?.Status == MembershipStatus.Suspended; var survivorSuspendedNow = survivor.Membership?.Status == MembershipStatus.Suspended; var survivorStanding = survivor.Membership?.Status; - if (foldedSuspended && !survivorSuspendedNow && survivor.Membership is { } survivorMembership) + if (foldedSuspended && !survivorSuspendedNow) { - entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString())); - effects.Add((id, _) => + if (survivor.Membership is { } survivorMembership) { - survivorMembership.Status = MembershipStatus.Suspended; - survivorMembership.SuspensionReason = SuspensionReason(id); - }); + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString())); + effects.Add((id, _) => + { + survivorMembership.Status = MembershipStatus.Suspended; + survivorMembership.SuspensionReason = SuspensionReason(id); + }); + } + else + { + // The chat tier holds no membership, and an absent one chats: without a row the suspension would be gone. The row is journalled so undo removes it. + var created = new Bifrost.Web.Domain.Membership { UserAccountId = survivor.Id, Status = MembershipStatus.Suspended }; + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created, created.Id)); + effects.Add((id, now) => + { + created.SuspensionReason = SuspensionReason(id); + created.CreatedAtUtc = now; + created.Notes = "Created by an account merge to carry the folded account's suspension."; + dbContext.Memberships.Add(created); + }); + } + survivorStanding = MembershipStatus.Suspended; } @@ -478,7 +555,8 @@ public sealed class AccountMergeLedger( } else { - entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, null, vote.CastAtUtc)); + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, vote.Weight.ToString(CultureInfo.InvariantCulture), vote.CastAtUtc)); + movedVotes.Add(vote); effects.Add((_, _) => vote.UserAccountId = survivorId); } } @@ -502,11 +580,12 @@ public sealed class AccountMergeLedger( } else { - entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, appraisal.VoterKey, appraisal.CastAtUtc)); + entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, $"{appraisal.VoterClass}|{appraisal.VoterKey}", appraisal.CastAtUtc)); effects.Add((_, _) => { appraisal.UserAccountId = survivorId; appraisal.VoterKey = survivorKey; + appraisal.VoterClass = VoterClass.Member; // the survivor is Active here, which is what makes an account a Member voter }); } } @@ -518,7 +597,7 @@ public sealed class AccountMergeLedger( effects.Add((_, _) => supportEvent.UserAccountId = survivorId); } - var reduction = new Reduction(survivor, folded, entries, effects, foldedEvents.Count > 0); + var reduction = new Reduction(survivor, folded, entries, effects, movedVotes, foldedEvents.Count > 0); if (mergeId is { } m) { reduction.MergeId = m; @@ -538,7 +617,7 @@ public sealed class AccountMergeLedger( private async Task StageIfMembershipAsync(Bifrost.Web.Domain.Membership? membership, IEnumerable events, DateTimeOffset now, CancellationToken cancellationToken) { - if (membership is not null) + if (membership is not null && dbContext.Entry(membership).State != EntityState.Deleted) { await patronageService.StageTierSnapshotAsync(membership, events, now, cancellationToken); } @@ -558,6 +637,12 @@ public sealed class AccountMergeLedger( }; private static string AppraisalKey(Guid accountId) => "account:" + accountId.ToString("N"); + + private static (VoterClass? Class, string? Key) ParseAppraisalPrior(string? prior) + { + var split = prior?.IndexOf('|') ?? -1; + return split > 0 && Enum.TryParse(prior![..split], out var voterClass) ? (voterClass, prior[(split + 1)..]) : (null, null); + } } /// Finalizes merges past their grace every five minutes. Copies the SealedBallotCloser shape: a new scope per tick, a warning on failure. diff --git a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs index 870ca4f..06e5622 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs @@ -49,6 +49,12 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi return SuspensionOutcome.NoSuchAccount; } + // A merged account is terminal: only the merge ledger leaves that status, so neither suspending nor lifting applies. + if (membership.IsMerged) + { + return SuspensionOutcome.NotApplicable; + } + // An admin cannot suspend themselves or another platform admin here (the same derivation that makes an actor an admin): // bootstrap admins are re-promoted to Active at sign-in by configuration, so the suspension would be undone without a receipt. if (suspend && (user.Id == actor.UserAccount.Id || new CurrentBifrostActor(true, user).IsPlatformAdmin)) diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs index 6d41d73..8add391 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs @@ -67,6 +67,11 @@ public sealed class MembershipSynchronizationService( dbContext.UserAccounts.Add(userAccount); } + else if (userAccount.Membership is { IsMerged: true }) + { + // A folded account is terminal: sign-in rewrites nothing on it (the actor gate already makes it act as nobody). + return; + } else { userAccount.GitHubLogin = gitHubLogin; diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index bbb1d6b..48ccb77 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; @@ -52,6 +53,13 @@ public sealed class MotionGovernanceService( CancellationToken cancellationToken) { var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); + + // An actor resolved before a merge froze this account must not write a vote after it: standing is read again at the write. + if (await AccountMergeLedger.StandingAsync(dbContext, actorUser.Id, cancellationToken) is not null) + { + return; + } + var motion = await dbContext.Motions .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index d142858..1fc0628 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -194,6 +194,12 @@ public static class OidcEndpoints return null; } + // A folded or frozen subject's live token acts as nobody on the desk too: the same standing the cookie gate and ChatRefusalAsync read. + if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null) + { + return null; + } + var account = await db.UserAccounts.AsNoTracking() .Include(x => x.Membership) .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); diff --git a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs index 04879e2..f7cf59b 100644 --- a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs +++ b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs @@ -55,12 +55,18 @@ public sealed class ApplicationBootstrapper( } /// - /// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's + /// The one bootstrap-admin promotion, used at startup and at sign-in. A Merged or Suspended account is left exactly as it is (an admin's /// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted. /// public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger) { var membership = account.Membership!; + if (membership.IsMerged) + { + logger.LogWarning("Bootstrap admin promotion skipped for merged account {UserAccountId}.", account.Id); + return false; + } + if (membership.Status == MembershipStatus.Suspended) { logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id); diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index a4bb901..caa536b 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -37,6 +37,8 @@ public sealed class IndexModel( internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend."; /// Admin-only text for a Suspended row whose reason predates the SuspensionReason column; the old text is never read back from the audit trail. internal const string LegacySuspensionText = "Suspended before reasons were recorded"; + internal const string ApproveMergedNotice = "Nothing changed: that account was merged into another and cannot be approved."; + internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet."; internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role."; internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended."; @@ -136,6 +138,12 @@ public sealed class IndexModel( return RedirectToPage(); } + if (user.Membership.IsMerged) + { + Notice = ApproveMergedNotice; // only the merge ledger leaves Merged + return RedirectToPage(); + } + user.Membership.Status = MembershipStatus.Active; user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow(); ApplicationBootstrapper.EnsureRole( diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs index e70d4e1..a5681aa 100644 --- a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -6,6 +6,7 @@ using Bifrost.Web.Features.Passkeys; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Options; @@ -23,6 +24,7 @@ public sealed class LinkedAccountsModel( HeimdallAuthService heimdallAuthService, LinkedAccountsSnapshots snapshots, PasskeyService passkeys, + ICurrentBifrostActorAccessor actors, IOptions hostOptions, ILogger logger) : PageModel { @@ -33,6 +35,21 @@ public sealed class LinkedAccountsModel( ["google"] = "Google" }; + /// + /// A folded or frozen account manages no logins and enrols no passkey: the page's handlers all begin a Heimdall flow on the + /// account's raw claim, so the one gate is here, on the actor accessor's standing, before any handler runs. + /// + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + if ((await actors.GetAsync(context.HttpContext.RequestAborted)).Merge is not null) + { + context.Result = Forbid(); + return; + } + + await base.OnPageHandlerExecutionAsync(context, next); + } + private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null; /// The wording of the outcome the page was sent back with: a closed set, null for anything else. diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 296cd3e..045a3b4 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -856,7 +856,10 @@ app.MapPost("/auth/heimdall/complete", async ( held.Value.Value, form?["code"].ToString(), form?["pendingLinkId"].ToString(), - httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId), + // A folded or frozen account links nothing: the actor accessor's standing decides, not the raw claim. + (await httpContext.RequestServices.GetRequiredService().GetAsync(cancellationToken)).Merge is null + ? httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) + : null, cancellationToken); if (step.Next is not null) { diff --git a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs index d8f48c6..800cd33 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -125,14 +125,11 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I Assert.Equal(1, await VoteRowsAsync(s.Motion)); } - [Fact] - public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor() + private async Task IntakeAsync(Seeded s, string eventId) { - var s = await SeedAsync(); - await MergeAsync(s, AccountMergeState.Final); var payload = JsonSerializer.Serialize(new { - heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = $"WH-{Guid.NewGuid():N}", kind = "OneTimeDonation", + heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = eventId, kind = "OneTimeDonation", amount = 25m, currencyCode = "USD", externalSupportId = "CAPTURE-1", supportedAtUtc = DateTimeOffset.UtcNow, isCurrentRecurringSupport = false }); using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("test-heimdall-intake-secret")); @@ -140,11 +137,151 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I request.Headers.Add("X-Heimdall-Signature-256", "sha256=" + Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()); request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); using var client = factory.CreateClient(); - using var response = await client.SendAsync(request); + return response.StatusCode; + } - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - var owners = await WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync()); - Assert.Equal([s.Survivor], owners); + private Task> EventOwnersAsync(Seeded s) => + WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync()); + + /// Lands the merge through the ledger's own commit, as the saga will: the move done, then the Bifrost records reduced. + private async Task CommitMergeAsync(Seeded s) + { + var merge = await MergeAsync(s, AccountMergeState.HeimdallMoved); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().CommitAsync(merge, default); + return 0; + }); + return merge; + } + + [Fact] + public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor() + { + var s = await SeedAsync(); + await MergeAsync(s, AccountMergeState.Final); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}")); + + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_patron_event_that_lands_during_grace_is_journalled_once_and_undo_moves_it_back_to_the_folded_account() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); // the provider retries: nothing doubles + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + Assert.Equal(1, await WithDb(db => db.AccountMergeEntries.CountAsync(x => x.MergeId == merge && x.Kind == AccountMergeEntryKind.PatronSupportEvent))); + + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + + Assert.Equal([s.Folded], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_folded_bootstrap_admins_sign_in_does_not_reactivate_the_merged_membership_and_an_approval_is_refused() + { + var s = await SeedAsync(); + await CommitMergeAsync(s); + using var folded = ClientFor(s.FoldedGitHubId, "test-admin"); // the bootstrap login: every request would re-promote it + + using (await folded.GetAsync("/")) + { + } + + Assert.Equal((MembershipStatus.Merged, false), await WithDb(async db => + { + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded); + return (membership.Status, membership.IsPlatformAdmin); + })); + + using var admin = ClientFor(Random.Shared.NextInt64(1_000_000_000, 2_000_000_000), "test-admin"); + var token = await TokenAsync(admin); + using var approve = await admin.PostAsync("/App/Members?handler=Approve", new FormUrlEncodedContent(new Dictionary + { + ["userAccountId"] = s.Folded.ToString(), ["__RequestVerificationToken"] = token + })); + Assert.Equal(HttpStatusCode.Redirect, approve.StatusCode); + Assert.Equal(MembershipStatus.Merged, await WithDb(async db => (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded)).Status)); + } + + /// Forbid answers 403 or sends the browser to the access-denied page; either way the page and its Heimdall flows are not served. + private static void AssertRefused(HttpResponseMessage response) + { + Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString()); + Assert.DoesNotContain("heimdall", response.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_frozen_or_folded_cookie_cannot_reach_the_linked_accounts_page_or_start_one_of_its_flows(AccountMergeState state) + { + var s = await SeedAsync(); + using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}"); + var token = await TokenAsync(folded); + using (var control = await folded.GetAsync("/Membership/LinkedAccounts")) + { + Assert.Equal(HttpStatusCode.OK, control.StatusCode); // before the merge the page is theirs + } + + await MergeAsync(s, state); + + using var page = await folded.GetAsync("/Membership/LinkedAccounts"); + AssertRefused(page); + using var refresh = await folded.PostAsync("/Membership/LinkedAccounts?handler=Refresh", new FormUrlEncodedContent(new Dictionary + { + ["via"] = "google", ["__RequestVerificationToken"] = token + })); + AssertRefused(refresh); + + using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}"); + using var other = await survivor.GetAsync("/Membership/LinkedAccounts"); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + } +} + +/// The desk's bearer door judges standing the way the cookie gate and the chat refusal do. +public sealed class AccountMergeDeskTests +{ + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_frozen_or_folded_subjects_live_desk_token_is_forbidden(AccountMergeState state) + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"); + Task ListAsync() => factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token); + + Assert.Equal(200, (await ListAsync()).Status); // the live member, before the merge + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var survivor = new UserAccount { BifrostIdentity = "desk-survivor", NormalizedBifrostIdentity = "desk-survivor", GitHubLogin = "desk-survivor", NormalizedGitHubLogin = "desk-survivor" }; + db.UserAccounts.Add(survivor); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = Guid.Parse(DeskWebApplicationFactory.Alice), State = state, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + Assert.Equal(403, (await ListAsync()).Status); } } diff --git a/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs index cd0f7a5..6f7d6b0 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs @@ -18,29 +18,44 @@ namespace Bifrost.Web.Tests; /// public sealed class AccountMergeLedgerTests { - private static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); + public static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); - private sealed class Clock(DateTimeOffset start) : TimeProvider + public sealed class Clock(DateTimeOffset start) : TimeProvider { public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now; } - private sealed class World + /// The merge fixtures. In memory by default; points the same fixtures at a real database, where unique indexes, transactions, tokens and triggers exist. + public sealed class World { private readonly string _name = $"merge-{Guid.NewGuid():N}"; private readonly SaveChangesInterceptor[] _interceptors; + private readonly string? _postgres; public World(params SaveChangesInterceptor[] interceptors) => _interceptors = interceptors; + private World(string postgres, SaveChangesInterceptor[] interceptors) : this(interceptors) => _postgres = postgres; + + public static World OnPostgres(string connectionString, params SaveChangesInterceptor[] interceptors) => new(connectionString, interceptors); + public Clock Clock { get; } = new(T0); - public BifrostDbContext Db() => new(new DbContextOptionsBuilder() - .UseInMemoryDatabase(_name) - .AddInterceptors(_interceptors) - .ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - .Options); + public BifrostDbContext Db() + { + var options = new DbContextOptionsBuilder().AddInterceptors(_interceptors); + if (_postgres is null) + { + options.UseInMemoryDatabase(_name).ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)); + } + else + { + options.UseNpgsql(_postgres); + } + + return new BifrostDbContext(options.Options); + } public AccountMergeLedger Ledger(BifrostDbContext db) => new(db, new PatronageService(db, new AuditTrailService(db, Clock), Clock), Clock); @@ -449,22 +464,6 @@ public sealed class AccountMergeLedgerTests await using var db = world.Db(); Assert.Null(await world.Ledger(db).StandingAsync(folded.Id, default)); - Assert.Equal(folded.Id, await world.Ledger(db).ResolveAsync(folded.Id, default)); - } - - [Fact] - public async Task Resolve_follows_folded_links_to_the_end() - { - var world = new World(); - var a = await world.AccountAsync("acct-a"); - var b = await world.AccountAsync("acct-b"); - var c = await world.AccountAsync("acct-c"); - await world.MergeAsync(b, a, AccountMergeState.Final); - await world.MergeAsync(c, b, AccountMergeState.Committed); - - await using var db = world.Db(); - Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(a.Id, default)); - Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(c.Id, default)); } // ---- plan ---- @@ -494,7 +493,7 @@ public sealed class AccountMergeLedgerTests } public static IEnumerable BlockCases() => - new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_survivor_in_grace", "sealed_ballot_pending" } + new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_is_survivor", "sealed_ballot_pending" } .Select(x => new object[] { x }); [Theory] @@ -528,8 +527,8 @@ public sealed class AccountMergeLedgerTests case "already_folded": await world.MergeAsync(other, folded, AccountMergeState.Committed); break; - case "folded_survivor_in_grace": - await world.MergeAsync(folded, other, AccountMergeState.Committed); + case "folded_is_survivor": + await world.MergeAsync(folded, other, AccountMergeState.Final); break; case "sealed_ballot_pending": var motion = await world.MotionAsync(kind: MotionKind.SealedBallot); @@ -722,4 +721,366 @@ public sealed class AccountMergeLedgerTests Assert.Equal((AccountMergeState.Final, T0.AddDays(7)), (final.State, final.FinalizedUtc)); Assert.Equal(AccountMergeState.Committed, (await read.AccountMerges.SingleAsync(x => x.Id == youngMerge.Id)).State); } + + // ---- verdict cut-merge-ledger.s1: the rules each fix decides ---- + + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.UndoRequested)] + [InlineData(AccountMergeState.HeimdallReversed)] + public async Task Plan_blocks_while_either_account_is_in_a_merge_in_flight(AccountMergeState state) + { + foreach (var survivorSide in new[] { true, false }) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var other = await world.AccountAsync("other"); + await (survivorSide ? world.MergeAsync(survivor, other, state) : world.MergeAsync(other, folded, state)); + + await using var read = world.Db(); + Assert.Contains("merge_in_flight", (await world.Ledger(read).PlanAsync(survivor.Id, folded.Id, default)).Blocks); + } + } + + [Theory] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task A_landed_merges_survivor_cannot_itself_fold_so_an_alias_is_never_a_chain(AccountMergeState state) + { + var world = new World(); + var a = await world.AccountAsync("acct-a"); + var b = await world.AccountAsync("acct-b"); + var c = await world.AccountAsync("acct-c"); + await world.MergeAsync(b, a, state); + + await using var read = world.Db(); + Assert.Contains("folded_is_survivor", (await world.Ledger(read).PlanAsync(c.Id, b.Id, default)).Blocks); + Assert.Empty((await world.Ledger(read).PlanAsync(b.Id, c.Id, default)).Blocks); // the other direction is an ordinary merge + Assert.Equal(b.Id, ((MergeStanding.Folded)(await world.Ledger(read).StandingAsync(a.Id, default))!).SurvivorId); + } + + private static async Task DropMembershipAsync(World world, UserAccount account) + { + await using var db = world.Db(); + db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == account.Id)); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task A_suspension_is_not_laundered_into_a_survivor_without_a_membership_and_undo_removes_the_row_it_made() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + await DropMembershipAsync(world, survivor); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var merge = await world.MergeAsync(survivor, folded); + + await using (var db = world.Db()) + { + var plan = await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default); + Assert.Contains(plan.Entries, e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Created }); + Assert.Empty(db.Memberships.Where(x => x.UserAccountId == survivor.Id)); // a plan writes nothing + } + + await world.CommitAsync(merge); + + var carried = await world.MembershipOfAsync(survivor); + Assert.Equal(MembershipStatus.Suspended, carried.Status); + Assert.Equal(AccountMergeLedger.SuspensionReason(merge.Id), carried.SuspensionReason); + Assert.False((await world.ActorAsync(survivor.Id)).IsActiveMember); + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + await using var read = world.Db(); + Assert.Empty(read.Memberships.Where(x => x.UserAccountId == survivor.Id)); + Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Restored }); + } + + [Fact] + public async Task Undo_keeps_the_membership_a_merge_made_for_a_suspension_once_an_admin_has_taken_it_over() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + await DropMembershipAsync(world, survivor); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)).SuspensionReason = "steward decision"; + await db.SaveChangesAsync(); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(survivor)).Status); + Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.KeptAfterUndo }); + } + + [Fact] + public async Task A_moved_vote_carries_the_survivors_weight_not_the_folded_accounts_and_undo_restores_it() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 5m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var moved = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal((survivor.Id, 1m), (moved.UserAccountId, moved.Weight)); + Assert.Equal(1m, (await world.StateOfAsync(motion, moderator)).VotesFor); + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + var back = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal((folded.Id, 5m), (back.UserAccountId, back.Weight)); + } + + [Fact] + public async Task A_moved_vote_takes_the_survivors_weight_after_the_patron_tiers_were_derived_again() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = folded.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 100m, + ProviderEventId = "e1", SupportedAtUtc = T0, RecordedAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var weight = (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight; + Assert.Equal(2m, weight); + Assert.Equal(weight, Assert.Single(await world.AllVotesAsync(motion)).Weight); + } + + private static async Task<(Guid Both, Guid OnlyFolded)> SeedAppraisalsAsync(World world, UserAccount survivor, UserAccount folded, VoterClass foldedClass) + { + await using var db = world.Db(); + var both = new ConceptSubject { Collection = "c", Key = "both", Title = "Both", RegisteredAtUtc = T0 }; + var only = new ConceptSubject { Collection = "c", Key = "only-folded", Title = "Only folded", RegisteredAtUtc = T0 }; + db.ConceptSubjects.AddRange(both, only); + ConceptAppraisal Row(ConceptSubject subject, UserAccount who, ConceptStance stance, VoterClass voterClass) => new() + { + SubjectId = subject.Id, UserAccountId = who.Id, VoterKey = "account:" + who.Id.ToString("N"), Stance = stance, VoterClass = voterClass, CastAtUtc = T0 + }; + db.ConceptAppraisals.AddRange(Row(both, survivor, ConceptStance.Up, VoterClass.Member), Row(both, folded, ConceptStance.Down, foldedClass), Row(only, folded, ConceptStance.Down, foldedClass)); + await db.SaveChangesAsync(); + return (both.Id, only.Id); + } + + [Fact] + public async Task A_lone_folded_appraisal_is_voided_when_the_survivor_is_not_active() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", MembershipStatus.PendingApproval); + var folded = await world.AccountAsync("folded"); + var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Member); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using var read = world.Db(); + var row = await read.ConceptAppraisals.IgnoreQueryFilters().SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((merge.Id, folded.Id), (row.VoidedByMergeId, row.UserAccountId)); + Assert.Equal(0, (await world.Concepts(read).GetTalliesAsync("c", default))["only-folded"].Down); + } + + [Fact] + public async Task A_moved_appraisal_takes_the_member_class_and_undo_gives_back_the_class_and_key_unless_the_survivor_recast_it() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Account); + var survivorKey = "account:" + survivor.Id.ToString("N"); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using (var read = world.Db()) + { + var moved = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((survivor.Id, survivorKey, VoterClass.Member), (moved.UserAccountId, moved.VoterKey, moved.VoterClass)); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + await using (var read = world.Db()) + { + var back = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((folded.Id, "account:" + folded.Id.ToString("N"), VoterClass.Account), (back.UserAccountId, back.VoterKey, back.VoterClass)); + } + + // Again, but the survivor re-casts the moved appraisal before the undo: it is theirs now and stays. + var folded2 = await world.AccountAsync("folded2"); + await using (var db = world.Db()) + { + db.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = onlyFolded, UserAccountId = folded2.Id, VoterKey = "account:" + folded2.Id.ToString("N"), Stance = ConceptStance.Down, + VoterClass = VoterClass.Account, CastAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + var second = await world.CommitAsync(await world.MergeAsync(survivor, folded2)); + world.Clock.Now = T0.AddDays(1); + await using (var db = world.Db()) + { + var voter = new VoterIdentity(survivorKey, VoterClass.Member, survivor.Id); + Assert.Equal(CastResult.Changed, await world.Concepts(db).CastAsync("c", "only-folded", voter, ConceptStance.Up, null, default)); + } + + await world.SetStateAsync(second, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(second.Id, default); + } + + await using var after = world.Db(); + var kept = await after.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded && x.UserAccountId == survivor.Id); + Assert.Equal((survivorKey, ConceptStance.Up), (kept.VoterKey, kept.Stance)); + Assert.Contains(await world.JournalAsync(second), e => e is { Kind: AccountMergeEntryKind.ConceptAppraisal, Action: AccountMergeEntryAction.KeptAfterUndo }); + } + + [Fact] + public async Task Closed_motions_keep_their_votes_with_their_owners_and_journal_nothing_for_them() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var closed = await world.MotionAsync(status: MotionStatus.Passed); + await world.VoteAsync(closed, survivor, VoteChoice.For, 1m); + await world.VoteAsync(closed, folded, VoteChoice.For, 2m); + var lone = await world.MotionAsync(status: MotionStatus.Failed); + await world.VoteAsync(lone, folded, VoteChoice.Against, 2m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(new[] { folded.Id, survivor.Id }.Order(), (await world.AllVotesAsync(closed)).Select(x => x.UserAccountId).Order()); + var loneVote = Assert.Single(await world.AllVotesAsync(lone)); + Assert.Equal((folded.Id, 2m), (loneVote.UserAccountId, loneVote.Weight)); // not re-pointed, not re-weighed + Assert.DoesNotContain(await world.JournalAsync(merge), e => e.Kind == AccountMergeEntryKind.Vote); + } + + [Theory] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task Commit_on_a_landed_merge_returns_the_receipt_and_writes_nothing(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded, state); + + var receipt = await world.CommitAsync(merge); + + Assert.Equal((merge.Id, state), (receipt.Id, receipt.State)); + Assert.Empty(await world.JournalAsync(merge)); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + } + + [Fact] + public async Task Undo_is_refused_for_a_merge_that_was_ever_finalized() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + await using (var db = world.Db()) + { + var row = await db.AccountMerges.SingleAsync(x => x.Id == merge.Id); + row.State = AccountMergeState.HeimdallReversed; + row.FinalizedUtc = T0.AddDays(8); + await db.SaveChangesAsync(); + } + + await using var undo = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(undo).UndoLocalAsync(merge.Id, default)); + Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status); + } + + [Fact] + public async Task A_vote_and_an_appraisal_from_an_actor_resolved_before_the_account_froze_write_nothing() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + var staleActor = await world.ActorAsync(folded.Id); // a request resolved this before the merge was confirmed + await using (var db = world.Db()) + { + db.ConceptSubjects.Add(new ConceptSubject { Collection = "c", Key = "k", Title = "K", RegisteredAtUtc = T0 }); + await db.SaveChangesAsync(); + } + + await world.MergeAsync(survivor, folded, AccountMergeState.Confirmed); + + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(staleActor, motion.Id, VoteChoice.For, null, default); + Assert.Equal(CastResult.NoVoter, await world.Concepts(db).CastAsync("c", "k", VoterIdentity.For(staleActor, null), ConceptStance.Up, null, default)); + } + + Assert.Empty(await world.AllVotesAsync(motion)); + await using var read = world.Db(); + Assert.Empty(read.ConceptAppraisals.IgnoreQueryFilters()); + + // The survivor, not frozen, still writes. + await using var control = world.Db(); + await world.Motions(control).CastVoteAsync(await world.ActorAsync(survivor.Id), motion.Id, VoteChoice.For, null, default); + Assert.Single(await world.AllVotesAsync(motion)); + } + + [Fact] + public async Task A_merged_membership_is_never_rewritten_by_the_bootstrap_promotion_or_by_suspension() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var admin = await world.AccountAsync("admin", roles: MemberRole.PlatformAdmin); + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using (var db = world.Db()) + { + var account = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == folded.Id); + Assert.False(ApplicationBootstrapper.TryPromoteBootstrapAdmin(account, T0, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + Assert.Equal((MembershipStatus.Merged, false), (account.Membership!.Status, account.Membership.IsPlatformAdmin)); + } + + await using (var db = world.Db()) + { + var suspension = new MembershipSuspensionService(db, new AuditTrailService(db, world.Clock)); + var actor = await world.ActorAsync(admin.Id); + Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.SuspendAsync(actor, folded.Id, "reason", default)); + Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.UnsuspendAsync(actor, folded.Id, "reason", default)); + } + + Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status); + } } diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs new file mode 100644 index 0000000..32925eb --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -0,0 +1,405 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; + +namespace Bifrost.Web.Tests; + +/// +/// The merge tests that need a real server run only when BIFROST_TEST_PG holds a Postgres connection string (no Database=; a role that +/// may create databases). Every other merge test runs on the in-memory provider, which enforces no unique index, transaction, +/// concurrency token or trigger, so the rules that rest on those are proven here, on a database each test creates, builds through the +/// real migrations and the class drops. See AGENTS.md (Live Postgres tests). +/// +public static class PostgresGate +{ + public const string Variable = "BIFROST_TEST_PG"; + + public static string? Skip => + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(Variable)) + ? $"live Postgres tests: set {Variable} to a connection string without Database= (see AGENTS.md, Live Postgres tests)" + : null; +} + +public sealed class PostgresFactAttribute : FactAttribute +{ + public PostgresFactAttribute() => Skip = PostgresGate.Skip; +} + +public sealed class PostgresTheoryAttribute : TheoryAttribute +{ + public PostgresTheoryAttribute() => Skip = PostgresGate.Skip; +} + +public sealed class MergePostgres : IAsyncLifetime +{ + private readonly List _databases = []; + + private static string? Admin => Environment.GetEnvironmentVariable(PostgresGate.Variable); + + public Task InitializeAsync() => Task.CompletedTask; + + /// A fresh database, built by the real migrations, and a world over it. + public async Task NewWorldAsync() + { + var name = $"bifrost_merge_{Guid.NewGuid():N}"; + await using (var admin = new NpgsqlConnection(Admin)) + { + await admin.OpenAsync(); + await using var create = new NpgsqlCommand($"CREATE DATABASE \"{name}\"", admin); + await create.ExecuteNonQueryAsync(); + } + + _databases.Add(name); + var world = AccountMergeLedgerTests.World.OnPostgres(new NpgsqlConnectionStringBuilder(Admin) { Database = name }.ConnectionString); + await using var db = world.Db(); + await db.Database.MigrateAsync(); + return world; + } + + public async Task DisposeAsync() + { + if (_databases.Count == 0) + { + return; + } + + NpgsqlConnection.ClearAllPools(); + await using var admin = new NpgsqlConnection(Admin); + await admin.OpenAsync(); + foreach (var name in _databases) + { + await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", admin); + await drop.ExecuteNonQueryAsync(); + } + } +} + +public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFixture +{ + private static PostgresException Pg(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + if (current is PostgresException pg) + { + return pg; + } + } + + throw new Xunit.Sdk.XunitException($"no PostgresException under {error.GetType().Name}"); + } + + /// Runs an action once, just before the first save of a context: the rival writer that lands inside a read-then-save window. + private sealed class BeforeFirstSave(Func rival) : SaveChangesInterceptor + { + private int _fired; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _fired, 1) == 0) + { + await rival(); + } + + return result; + } + } + + /// Holds each of two contexts at its save until both have arrived, so both have read before either writes. + private sealed class Rendezvous : SaveChangesInterceptor + { + private readonly Barrier _barrier = new(2); + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken); + return result; + } + } + + private static string ConnectionOf(AccountMergeLedgerTests.World world) + { + using var db = world.Db(); + return db.Database.GetConnectionString()!; + } + + private static async Task ExecuteAsync(AccountMergeLedgerTests.World world, string sql) + { + await using var db = world.Db(); + await db.Database.ExecuteSqlRawAsync(sql); + } + + // ---- commit atomicity ---- + + [PostgresFact] + public async Task A_commit_that_fails_midway_leaves_nothing_applied_and_the_retry_commits_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + var merge = await world.MergeAsync(survivor, folded); + await ExecuteAsync(world, """ + CREATE FUNCTION fail_merged() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW."Status" = 'Merged' THEN RAISE EXCEPTION 'injected fault'; END IF; RETURN NEW; END $$; + CREATE TRIGGER fail_merged BEFORE UPDATE ON "Memberships" FOR EACH ROW EXECUTE FUNCTION fail_merged(); + """); + + var fault = await Assert.ThrowsAsync(() => world.CommitAsync(merge)); + Assert.Contains("injected fault", Pg(fault).MessageText); + + await using (var check = world.Db()) + { + Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State); + Assert.Empty(check.AccountMergeEntries); + Assert.All(await check.Votes.IgnoreQueryFilters().ToListAsync(), v => Assert.Null(v.VoidedByMergeId)); + Assert.Equal(MembershipStatus.Active, (await check.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status); + } + + await ExecuteAsync(world, "DROP TRIGGER fail_merged ON \"Memberships\""); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); + var journal = await world.JournalAsync(merge); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); // a second commit is a no-op + Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count); + } + + [PostgresFact] + public async Task The_journal_refuses_a_second_entry_with_the_same_merge_and_seq() + { + var world = await postgres.NewWorldAsync(); + var merge = await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded")); + await using var db = world.Db(); + AccountMergeEntry Entry() => new() { MergeId = merge.Id, Seq = 1, Kind = AccountMergeEntryKind.Vote, Action = AccountMergeEntryAction.Voided, RecordId = Guid.NewGuid(), AtUtc = AccountMergeLedgerTests.T0 }; + db.AccountMergeEntries.Add(Entry()); + await db.SaveChangesAsync(); + db.AccountMergeEntries.Add(Entry()); + + var error = await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + + Assert.Equal(("23505", "IX_AccountMergeEntries_MergeId_Seq"), (Pg(error).SqlState, Pg(error).ConstraintName)); + } + + [PostgresFact] + public async Task Two_commits_of_the_same_merge_racing_apply_it_exactly_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + var merge = await world.MergeAsync(survivor, folded); + var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous()); + + async Task TryCommit() + { + try + { + await committers.CommitAsync(merge); + return null; + } + catch (Exception error) + { + return error; + } + } + + var results = await Task.WhenAll(Task.Run(TryCommit), Task.Run(TryCommit)); + + Assert.Equal(1, results.Count(x => x is null)); + Assert.All(results.OfType(), e => Assert.IsAssignableFrom(e)); // the loser: the unique index or the token + var journal = await world.JournalAsync(merge); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Single((await world.AllVotesAsync(motion)).Where(v => v.VoidedByMergeId == merge.Id)); + } + + [PostgresFact] + public async Task A_voided_vote_leaves_every_tally_on_a_real_database() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 3m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var item = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst)); + Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator)); + } + + // ---- the sweeper against the saga ---- + + [PostgresFact] + public async Task The_sweep_does_not_overwrite_an_UndoRequested_written_after_it_read_the_merge() + { + var plain = await postgres.NewWorldAsync(); + var merge = await plain.CommitAsync(await plain.MergeAsync(await plain.AccountAsync("survivor"), await plain.AccountAsync("folded"))); + var sweeper = AccountMergeLedgerTests.World.OnPostgres( + ConnectionOf(plain), new BeforeFirstSave(() => plain.SetStateAsync(merge, AccountMergeState.UndoRequested))); + + await using (var db = sweeper.Db()) + { + Assert.Equal(0, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + await using var read = plain.Db(); + var row = await read.AccountMerges.SingleAsync(); + Assert.Equal(AccountMergeState.UndoRequested, row.State); + Assert.Null(row.FinalizedUtc); + } + + [PostgresFact] + public async Task An_UndoRequested_written_from_a_stale_Committed_read_cannot_overwrite_Final() + { + var world = await postgres.NewWorldAsync(); + var merge = await world.CommitAsync(await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"))); + await using var saga = world.Db(); + var stale = await saga.AccountMerges.SingleAsync(x => x.Id == merge.Id); // the saga read Committed + + await using (var sweep = world.Db()) + { + Assert.Equal(1, await world.Ledger(sweep).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + stale.State = AccountMergeState.UndoRequested; + await Assert.ThrowsAsync(() => saga.SaveChangesAsync()); + await using var read = world.Db(); + Assert.Equal(AccountMergeState.Final, (await read.AccountMerges.SingleAsync()).State); + } + + [PostgresFact] + public async Task A_sweep_that_loses_one_merge_to_the_saga_still_finalizes_the_others() + { + var plain = await postgres.NewWorldAsync(); + var survivor = await plain.AccountAsync("survivor"); + await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-a"))); + await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-b"))); + var sweeper = AccountMergeLedgerTests.World.OnPostgres( + ConnectionOf(plain), new BeforeFirstSave(async () => + { + // Whichever merge the sweep saves first, the saga takes one of the two before it does. + await using var rival = plain.Db(); + var taken = await rival.AccountMerges.Where(x => x.State == AccountMergeState.Committed).OrderBy(x => x.Id).FirstAsync(); + taken.State = AccountMergeState.UndoRequested; + await rival.SaveChangesAsync(); + })); + + await using (var db = sweeper.Db()) + { + Assert.Equal(1, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + await using var read = plain.Db(); + Assert.Equal([AccountMergeState.Final, AccountMergeState.UndoRequested], (await read.AccountMerges.ToListAsync()).Select(x => x.State).Order()); + } + + // ---- overlapping merges, refused by the database ---- + + [PostgresFact] + public async Task A_second_live_merge_folding_the_same_account_is_refused_and_a_finished_one_frees_it() + { + var world = await postgres.NewWorldAsync(); + var s1 = await world.AccountAsync("acct-s1"); + var s2 = await world.AccountAsync("acct-s2"); + var folded = await world.AccountAsync("folded"); + + var second = await world.MergeAsync(s2, folded, AccountMergeState.Proposed); // proposals may coexist + var first = await world.MergeAsync(s1, folded, AccountMergeState.Confirmed); + + var refused = await Assert.ThrowsAsync(() => world.SetStateAsync(second, AccountMergeState.Confirmed)); + Assert.Equal(("23505", "UX_AccountMerges_FoldedUserAccountId_Live"), (Pg(refused).SqlState, Pg(refused).ConstraintName)); + var inserted = await Assert.ThrowsAsync(() => world.MergeAsync(s2, folded, AccountMergeState.Final)); + Assert.Equal("23505", Pg(inserted).SqlState); + + await world.SetStateAsync(first, AccountMergeState.Undone); + await world.SetStateAsync(second, AccountMergeState.Confirmed); // the slot is free again + } + + [PostgresTheory] + [InlineData(AccountMergeState.Confirmed, AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Final, AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed, AccountMergeState.HeimdallMoved)] + public async Task A_chain_is_refused_in_either_arrival_order_and_the_same_survivor_twice_is_fine(AccountMergeState earlier, AccountMergeState later) + { + var world = await postgres.NewWorldAsync(); + foreach (var survivorFirst in new[] { true, false }) + { + var tag = survivorFirst ? "a" : "b"; + var s = await world.AccountAsync("acct-s" + tag); + var f = await world.AccountAsync("acct-f" + tag); + var g = await world.AccountAsync("acct-g" + tag); + var other = await world.AccountAsync("other" + tag); + + await world.MergeAsync(s, other, AccountMergeState.Confirmed); // one survivor may carry two folded accounts + await world.MergeAsync(s, f, earlier); + // f survives a live merge and folds into g, or s folds into g while it survives one: both are chains. + var chain = await Assert.ThrowsAsync(() => survivorFirst ? world.MergeAsync(f, g, later) : world.MergeAsync(g, s, later)); + Assert.Equal("23P01", Pg(chain).SqlState); + } + } + + [PostgresFact] + public async Task Two_transactions_racing_to_chain_the_same_account_cannot_both_commit() + { + var world = await postgres.NewWorldAsync(); + var s = await world.AccountAsync("acct-s"); + var f = await world.AccountAsync("acct-f"); + var g = await world.AccountAsync("acct-g"); + await using var first = world.Db(); + await using var firstTransaction = await first.Database.BeginTransactionAsync(); + first.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = s.Id, FoldedUserAccountId = f.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 }); + await first.SaveChangesAsync(); + + await using var second = world.Db(); + await using var secondTransaction = await second.Database.BeginTransactionAsync(); + second.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = f.Id, FoldedUserAccountId = g.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 }); + var racing = second.SaveChangesAsync(); + + await Task.Delay(1500); + Assert.False(racing.IsCompleted); // it waits on the first, which holds the lock on f + await firstTransaction.CommitAsync(); + + var refused = await Assert.ThrowsAsync(() => racing.WaitAsync(TimeSpan.FromSeconds(30))); + Assert.Equal("23P01", Pg(refused).SqlState); + } + + // ---- rollback ---- + + [PostgresFact] + public async Task Reverting_the_migration_is_refused_while_a_merge_has_voided_a_vote_and_allowed_after() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using (var db = world.Db()) + { + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); + Assert.Contains("cannot be reverted", Pg(error).MessageText); + Assert.Equal("20261114120000_AddAccountMerges", (await db.Database.GetAppliedMigrationsAsync()).Last()); + } + + await ExecuteAsync(world, "UPDATE \"Votes\" SET \"VoidedByMergeId\" = NULL, \"VoidedUtc\" = NULL"); + await using var down = world.Db(); + await down.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); + Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last()); + await down.GetService().MigrateAsync(); // and up again, trigger and all + Assert.Equal("20261114120000_AddAccountMerges", (await down.Database.GetAppliedMigrationsAsync()).Last()); + } +} diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index c92d99c..3bb7714 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Bifrost.Web.Domain; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Authentication.OAuth; @@ -888,6 +889,33 @@ public sealed class HeimdallCompletionTests Assert.Equal(rolesBefore, after.RoleAssignments.Count); } + [Fact] + public async Task A_merged_account_stays_merged_through_a_sign_in_with_the_member_claim() + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"])).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: true), survivorId, ["member_access"])).SignedIn); + await host.Db(async db => + { + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == foldedId); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorId); + var merge = new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = AccountMergeState.HeimdallMoved, CreatedUtc = DateTimeOffset.UtcNow }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + var clock = TimeProvider.System; + await new AccountMergeLedger(db, new PatronageService(db, new AuditTrailService(db, clock), clock), clock).CommitAsync(merge.Id, default); + return 0; + }); + Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status); + + var again = await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.True(again.SignedIn); // the login still completes; it is the actor gate that makes it act as nobody + Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status); + } + [Fact] public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member() { -- 2.47.3 From 1ffb8886c07074305a9cd143bd82e61a51189bc4 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 03:50:42 +0000 Subject: [PATCH 08/20] 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 -- 2.47.3 From aa348d3b43b0bb9e0bdcf82bdd5a517357a8a10f Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 04:13:54 +0000 Subject: [PATCH 09/20] Account merge ledger: link step refuses a frozen account (test), drop the redundant sync guard, accept a deadlock victim as the racing commit's loser Co-Authored-By: Claude Sonnet 5.5 --- docs/account-merge.md | 2 +- .../MembershipSynchronizationService.cs | 5 ---- .../AccountMergePostgresTests.cs | 11 +++++++- .../HeimdallGoogleLinkTests.cs | 26 +++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/docs/account-merge.md b/docs/account-merge.md index 3d444c5..c8a1d80 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -46,7 +46,7 @@ Left untouched (counted by the plan): consent receipts, passkeys, ledger entries ## Merged is terminal -Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): GitHub sign-in sync leaves the account untouched, the bootstrap-admin promotion (startup and sign-in) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status. +Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): the bootstrap-admin promotion (startup and every GitHub sign-in, which is the one status write sign-in sync makes on an existing account) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status. ## Rollback diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs index 8add391..6d41d73 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs @@ -67,11 +67,6 @@ public sealed class MembershipSynchronizationService( dbContext.UserAccounts.Add(userAccount); } - else if (userAccount.Membership is { IsMerged: true }) - { - // A folded account is terminal: sign-in rewrites nothing on it (the actor gate already makes it act as nobody). - return; - } else { userAccount.GitHubLogin = gitHubLogin; diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index 32925eb..da58e4f 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -121,6 +121,14 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi } } + private static IEnumerable Chain(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + yield return current; + } + } + private static string ConnectionOf(AccountMergeLedgerTests.World world) { using var db = world.Db(); @@ -214,7 +222,8 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi var results = await Task.WhenAll(Task.Run(TryCommit), Task.Run(TryCommit)); Assert.Equal(1, results.Count(x => x is null)); - Assert.All(results.OfType(), e => Assert.IsAssignableFrom(e)); // the loser: the unique index or the token + // The loser is refused by the unique index, the token, or Postgres picking it as the deadlock victim. + Assert.All(results.OfType(), e => Assert.True(Chain(e).Any(x => x is DbUpdateException), e.ToString())); var journal = await world.JournalAsync(merge); Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs index e1c90ca..6a13cfc 100644 --- a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -5,6 +5,7 @@ using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Bifrost.Web.Tests; @@ -242,6 +243,31 @@ public sealed class HeimdallGoogleLinkTests AssertAttemptCleared(b.Response, start); } + [Fact] + public async Task A_link_begun_before_a_merge_froze_the_account_does_not_continue_after_it() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var survivorAccount = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + await host.SignInSessionAsync(survivorAccount); + var start = await host.StartLinkAsync(session); + await host.Db(async db => + { + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == account); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorAccount); + db.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = AccountMergeState.Confirmed, CreatedUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + return 0; + }); + var starts = host.Heimdall.StartBodies.Count; + + var b = await StepB(host, session, start, account); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); // no Google link was asked for on the frozen account + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); + } + [Fact] public async Task A_session_of_another_account_cannot_continue_the_link() { -- 2.47.3 From b7b33bc5db1f104488f966f7b4d8308dd1e9990d Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 04:14:38 +0000 Subject: [PATCH 10/20] Move client: a 400 is RequestInvalid only with Heimdall's invalid_request body An nginx HTML 400, an empty 400 or any other 400 is Unknown. The fake answers its schema 400 with Heimdall's error handler body. Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Heimdall/HeimdallIdentityMoveClient.cs | 12 ++++-------- tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs | 2 +- .../HeimdallIdentityMoveClientTests.cs | 7 ++++++- tests/Bifrost.Web.Tests/Support/FakeMoves.cs | 4 ++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index e485b37..f285ae2 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 one local class: Heimdall's schema 400. +/// Heimdall's fixed refusal codes for a move, plus invalid_request, Heimdall's own schema 400. public enum HeimdallMoveRefusal { AppAuthRequired, @@ -128,12 +128,6 @@ public sealed partial class HeimdallIdentityMoveClient( return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); } - // 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) when (!cancellationToken.IsCancellationRequested) @@ -144,7 +138,7 @@ public sealed partial class HeimdallIdentityMoveClient( } /// - /// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute). A 4xx without one of those codes + /// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute, and its error handler's invalid_request 400). 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. /// @@ -167,6 +161,8 @@ public sealed partial class HeimdallIdentityMoveClient( "already_moved" => HeimdallMoveRefusal.AlreadyMoved, "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, + // Heimdall's error handler gives its own 400 this code; an nginx or other edge 400 has none, so it is not Heimdall's refusal. + "invalid_request" when status == 400 => HeimdallMoveRefusal.RequestInvalid, _ => (HeimdallMoveRefusal?)null }; if (code is not { } known) diff --git a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs index b49c8be..c856c8f 100644 --- a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs +++ b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs @@ -71,7 +71,7 @@ public sealed class FakeHeimdallMoverTests }; foreach (var json in bad) { - Assert.Equal(HttpStatusCode.BadRequest, (await Post(host, json)).Status); + Assert.Equal((HttpStatusCode.BadRequest, "invalid_request"), await Post(host, json)); } Assert.Single(moves.Stored); diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index 8055c6d..a2cc873 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -292,7 +292,7 @@ public sealed class HeimdallIdentityMoveClientTests } [Fact] - public async Task A_400_is_RequestInvalid_a_definitive_refusal_that_moves_nothing() + public async Task A_400_carrying_Heimdalls_invalid_request_code_is_RequestInvalid_a_definitive_refusal_that_moves_nothing() { using var host = new HeimdallHost(); var moves = host.Heimdall.Moves; @@ -307,6 +307,11 @@ public sealed class HeimdallIdentityMoveClientTests [Theory] [InlineData(HttpStatusCode.NotFound, "404 Not Found")] [InlineData(HttpStatusCode.NotFound, "")] + [InlineData(HttpStatusCode.BadRequest, "400 Bad Requestnginx")] + [InlineData(HttpStatusCode.BadRequest, "")] + [InlineData(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"x"}""")] + [InlineData(HttpStatusCode.BadRequest, """{"message":"no error field"}""")] + [InlineData(HttpStatusCode.Conflict, """{"error":"invalid_request"}""")] [InlineData(HttpStatusCode.RequestTimeout, "")] [InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")] [InlineData(HttpStatusCode.Gone, "")] diff --git a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs index ef3eb35..1a87171 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs @@ -102,7 +102,7 @@ public sealed class FakeMoves(Func now) if (Parse(body) is not { } move) { - return Json(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"body does not match the schema"}"""); + return Json(HttpStatusCode.BadRequest, """{"error":"invalid_request","detail":"body: must match the schema"}"""); } if (MoveStatus is { } forced) @@ -110,7 +110,7 @@ public sealed class FakeMoves(Func now) return forced == HttpStatusCode.NotFound ? new HttpResponseMessage(forced) : Json(forced, forced == HttpStatusCode.BadRequest - ? """{"statusCode":400,"error":"Bad Request"}""" + ? """{"error":"invalid_request","detail":"body: must match the schema"}""" : """{"error":"server_error","detail":"HEIMDALL FREE TEXT"}"""); } -- 2.47.3 From 537a477dc3e6b22503145fa92893b176ebff292e Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 04:58:26 +0000 Subject: [PATCH 11/20] Account merge ledger: roll-forward-only Down guard, standing-at-write through one owner for every writer, one current Patron snapshot per membership, memberless Heimdall sign-in, intake and Stripe routing through one owner Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 6 +- docs/account-merge.md | 6 +- src/Bifrost.Web/Data/BifrostDbContext.cs | 4 + .../20261114120000_AddAccountMerges.cs | 32 +++- .../BifrostDbContextModelSnapshot.cs | 5 + .../ConceptVotes/ConceptAppraisalService.cs | 15 +- .../Features/Heimdall/HeimdallAuthService.cs | 10 +- .../HeimdallPatronSupportIntakeService.cs | 16 +- .../Features/Membership/AccountMergeLedger.cs | 22 +++ .../Motions/MotionGovernanceService.cs | 12 +- .../Features/Motions/SealedBallotService.cs | 12 ++ .../Features/Patronage/PatronageService.cs | 43 ++++-- .../Patronage/StripeWebhookService.cs | 20 ++- .../AccountMergeHttpTests.cs | 73 +++++++++ .../AccountMergePostgresTests.cs | 138 ++++++++++++++++-- .../HeimdallCompletionTests.cs | 20 +++ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 39 +++++ 17 files changed, 412 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b0c6e04..2f0fd40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,8 +83,12 @@ Then start the first implementation milestone: ## Live Postgres tests -Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the migration's rollback guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: +Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the one-current-Patron-snapshot index under racing restages, the intake journal `Seq`, the migration's roll-forward-only guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: ```bash BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests ``` + +## Account merges are roll-forward only + +Once any `AccountMerges` row exists, do not deploy a build older than the `AddAccountMerges` migration, and do not revert that migration: an older build cannot read the `Merged` membership status and counts voided votes again. `Down` refuses while any merge row exists. Fix forward (`heimdall-body:ruling:merge-roll-forward-only`, `docs/account-merge.md` Rollback). Any deploy list that names a rollback must say so. diff --git a/docs/account-merge.md b/docs/account-merge.md index c8a1d80..5dc1068 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -50,8 +50,10 @@ Only the ledger writes `Membership.Status = Merged` or leaves it. The other stat ## Rollback -Voiding lives in the new build's query filter and the `VoidedByMergeId` columns. `Down` refuses to run while any vote or appraisal is voided (dropping the columns would count them again), and an older build run over the migrated schema also counts voided rows, because it has no filter. Rolling the code back while merges have landed is therefore unsafe; whether voided votes should instead leave the `Votes` table (so an older build cannot see them) is an open question for the operator, raised on the cut. +Bifrost is **roll-forward only** once a merge exists (`heimdall-body:ruling:merge-roll-forward-only`). A build older than `AddAccountMerges` cannot read the `Merged` membership status (EF throws on `/App/Members` and on the folded account's sign-in) and has no query filter, so it counts voided votes and appraisals again. Deploy the merge migration knowing that a code rollback past it is not available after the first merge lands; fix forward. The migration's `Down` refuses while any `AccountMerges` row exists, whatever state, so it cannot drop the merge record and journal under a `Merged` membership. No tombstone table is built: it would fix only the recount, not the enum. ## Not covered -`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. A vote written by a request that passed the standing read at the write and saved after the commit read the votes (microseconds, inside one request) is the one window left; such a vote stays live as a second voice until an operator voids it. Closing it fully needs the tally-time voiding of design section 7, which is not built. +`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. Every writer that acts for an account (votes, concept appraisals, sealed-ballot commit and reveal) reads standing through `AccountMergeLedger.MayWriteAsync` after loading what it changes and before it mutates, and an account with a standing writes nothing. Patron events (the Heimdall intake and the Stripe webhook) route through `AccountMergeLedger.SupportTargetAsync` immediately before they record: a folded account's event lands on the survivor and is journalled, so undo moves it back. Each of these is a read followed by a save, so a request whose read precedes the commit by microseconds and whose save follows it lands on the folded account: that window is not closed. Closing it needs a lock held across the read and the save, or the tally-time voiding of design section 7; neither is built, and no operator void tool exists. A patron event or vote that falls in it stays on the folded account until it is corrected by hand in the database. + +`UX_TierSnapshots_MembershipId_CurrentPatron` allows one current Patron snapshot per membership, so two restages of one survivor racing cannot both leave a current snapshot: the loser's save fails on the unique index, a merge commit rolls back whole and is retried by the saga, and a patron refresh re-reads and derives again. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 3e8ce39..544d770 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -141,6 +141,10 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.Property(x => x.Label).HasMaxLength(120); entity.Property(x => x.Weight).HasPrecision(18, 2); entity.HasIndex(x => new { x.MembershipId, x.Kind, x.IsCurrent }); + entity.HasIndex(x => x.MembershipId) + .IsUnique() + .HasDatabaseName("UX_TierSnapshots_MembershipId_CurrentPatron") + .HasFilter("\"Kind\" = 'Patron' AND \"IsCurrent\""); entity.HasOne(x => x.Membership) .WithMany(x => x.TierSnapshots) .HasForeignKey(x => x.MembershipId); diff --git a/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs index 67cfc82..7cd6143 100644 --- a/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs +++ b/src/Bifrost.Web/Data/Migrations/20261114120000_AddAccountMerges.cs @@ -127,6 +127,23 @@ namespace Bifrost.Web.Data.Migrations table: "AccountMerges", columns: new[] { "SurvivorUserAccountId", "State" }); + // One current Patron snapshot per membership: two restages racing (two merges into one survivor, a merge and an intake) would + // each demote the old row and add their own, and EffectiveVotingWeight sums the current ones. A pre-existing duplicate keeps + // only its newest row current so the index can be built. + migrationBuilder.Sql(@" +UPDATE ""TierSnapshots"" t SET ""IsCurrent"" = false +WHERE t.""Kind"" = 'Patron' AND t.""IsCurrent"" AND EXISTS ( + SELECT 1 FROM ""TierSnapshots"" o + WHERE o.""MembershipId"" = t.""MembershipId"" AND o.""Kind"" = 'Patron' AND o.""IsCurrent"" + AND (o.""CapturedAtUtc"" > t.""CapturedAtUtc"" OR (o.""CapturedAtUtc"" = t.""CapturedAtUtc"" AND o.""Id"" > t.""Id"")));"); + + migrationBuilder.CreateIndex( + name: "UX_TierSnapshots_MembershipId_CurrentPatron", + table: "TierSnapshots", + column: "MembershipId", + unique: true, + filter: "\"Kind\" = 'Patron' AND \"IsCurrent\""); + // The cross-role guard no single unique index can express: a live merge's survivor is never another live merge's folded // account (no chains), whichever order they arrive in. Advisory locks on both accounts, taken in a fixed order, serialise two // transactions racing over the same account, so the second one's check sees the first's committed row. @@ -155,19 +172,22 @@ CREATE TRIGGER ""AccountMerges_refuse_chain"" BEFORE INSERT OR UPDATE OF ""State /// protected override void Down(MigrationBuilder migrationBuilder) { - // Voiding lives only in the new build's query filter and these columns: dropping them (or running an older build over them) - // counts every voided vote again, and a close in that window records a wrong result for good. Refuse while any is voided; - // see docs/account-merge.md (Rollback). + // Roll-forward only (heimdall-body:ruling:merge-roll-forward-only): once any merge exists the Merged membership status + // cannot be read by an older build, and voided votes and appraisals would be counted again. Refuse while any merge row + // exists; see docs/account-merge.md (Rollback). migrationBuilder.Sql(@" DO $$ BEGIN - IF EXISTS (SELECT 1 FROM ""Votes"" WHERE ""VoidedByMergeId"" IS NOT NULL) - OR EXISTS (SELECT 1 FROM ""ConceptAppraisals"" WHERE ""VoidedByMergeId"" IS NOT NULL) THEN - RAISE EXCEPTION 'AddAccountMerges cannot be reverted while merged votes or appraisals are voided'; + IF EXISTS (SELECT 1 FROM ""AccountMerges"") THEN + RAISE EXCEPTION 'AddAccountMerges cannot be reverted once an account merge exists: Bifrost is roll-forward only'; END IF; END $$;"); + migrationBuilder.DropIndex( + name: "UX_TierSnapshots_MembershipId_CurrentPatron", + table: "TierSnapshots"); + migrationBuilder.Sql(@"DROP TRIGGER ""AccountMerges_refuse_chain"" ON ""AccountMerges""; DROP FUNCTION ""AccountMerges_refuse_chain""();"); migrationBuilder.DropTable( diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 83bc319..3966dd3 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1445,6 +1445,11 @@ namespace Bifrost.Web.Data.Migrations b.HasIndex("MembershipId", "Kind", "IsCurrent"); + b.HasIndex("MembershipId") + .IsUnique() + .HasDatabaseName("UX_TierSnapshots_MembershipId_CurrentPatron") + .HasFilter("\"Kind\" = 'Patron' AND \"IsCurrent\""); + b.ToTable("TierSnapshots"); }); diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index e290c82..17e9ddd 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -65,13 +65,6 @@ public sealed class ConceptAppraisalService( return CastResult.NoVoter; } - // A voter resolved before a merge froze the account must not write an appraisal after it: standing is read again at the write. - if (voter.UserAccountId is { } voterAccountId && - await AccountMergeLedger.StandingAsync(dbContext, voterAccountId, cancellationToken) is not null) - { - return CastResult.NoVoter; - } - var subject = await FindSubjectAsync(collection, key, cancellationToken); if (subject is null) { @@ -89,6 +82,14 @@ public sealed class ConceptAppraisalService( : NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret); var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + + // A voter resolved before a merge froze the account must not write an appraisal after it: standing is read again at the write. + if (voter.UserAccountId is { } voterAccountId && + !await AccountMergeLedger.MayWriteAsync(dbContext, voterAccountId, cancellationToken)) + { + return CastResult.NoVoter; + } + if (existing is not null) { return await ApplyStanceAsync(existing, stance, tag, cancellationToken); diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index e52cc75..aa2416e 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -840,11 +840,13 @@ public sealed class HeimdallAuthService( Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now }; - userAccount.Membership ??= new Bifrost.Web.Domain.Membership + if (userAccount.Membership is null) { - UserAccountId = userAccount.Id, - CreatedAtUtc = now - }; + // Added explicitly: a row with a preset key assigned to a tracked account is taken for an existing one and the save fails. + userAccount.Membership = new Bifrost.Web.Domain.Membership { UserAccountId = userAccount.Id, CreatedAtUtc = now }; + dbContext.Memberships.Add(userAccount.Membership); + } + // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. A merged row is terminal: the claim never reactivates it. if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended && !userAccount.Membership.IsMerged) { diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs index fe88cae..57c565f 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -70,14 +70,12 @@ public sealed class HeimdallPatronSupportIntakeService( .AsNoTracking() .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); - // A folded account's events record on the survivor and are journalled on the merge, so an undo moves them back. - var folded = user is null ? null : await mergeLedger.StandingAsync(user.Id, cancellationToken) as MergeStanding.Folded; - if (existingEvent is not null) { - if (folded is not null && user is not null) + // A provider retry of an event already processed: journal it if the account has since folded (idempotent). + if (user is not null && await mergeLedger.SupportTargetAsync(user.Id, cancellationToken) is { MergeId: { } retriedMergeId }) { - await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, existingEvent.Id, cancellationToken); + await mergeLedger.JournalIntakeAsync(retriedMergeId, user.Id, existingEvent.Id, cancellationToken); } return HeimdallPatronSupportIntakeResult.Processed( @@ -91,9 +89,11 @@ public sealed class HeimdallPatronSupportIntakeService( $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); } + // A folded account's events record on the survivor and are journalled on the merge, so an undo moves them back. + var target = await mergeLedger.SupportTargetAsync(user.Id, cancellationToken); var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, - userAccountId: folded?.SurvivorId ?? user.Id, + userAccountId: target.RecordOnAccountId, request.Kind, request.Amount, request.CurrencyCode, @@ -107,9 +107,9 @@ public sealed class HeimdallPatronSupportIntakeService( request.Notes, cancellationToken); - if (folded is not null) + if (target.MergeId is { } mergeId) { - await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, supportEvent.Id, cancellationToken); + await mergeLedger.JournalIntakeAsync(mergeId, user.Id, supportEvent.Id, cancellationToken); } return HeimdallPatronSupportIntakeResult.Processed( diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs index 3e964e3..f540061 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -7,6 +7,9 @@ using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Features.Membership; /// How an account stands under a merge: the folded account of an unfinished merge is Frozen, of a landed one Folded. +/// Where a patron event for an account lands; is set when the account is folded and the event must be journalled. +public sealed record SupportTarget(Guid ForAccountId, Guid RecordOnAccountId, Guid? MergeId); + public abstract record MergeStanding(Guid MergeId) { public sealed record Frozen(Guid MergeId) : MergeStanding(MergeId); @@ -70,6 +73,25 @@ public sealed class AccountMergeLedger( : new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId); } + /// + /// The one standing-at-write rule: an account with a merge standing (frozen or folded) writes nothing as itself. Every writer that + /// acts for an account (votes, appraisals, sealed ballots) calls this after loading what it will change and before it mutates, + /// so an actor resolved before the merge froze the account cannot write after it. It reads, then the writer saves: the + /// microseconds between the two are the window the saga cannot close without a lock or tally-time voiding (docs/account-merge.md). + /// + public static async Task MayWriteAsync(BifrostDbContext db, Guid userAccountId, CancellationToken cancellationToken) => + await StandingAsync(db, userAccountId, cancellationToken) is null; + + /// + /// The one routing of patron support: an event for a folded account lands on its survivor, anything else on the account itself. + /// The Heimdall intake and the Stripe webhook both resolve through here, immediately before they record, and journal a folded + /// event with . + /// + public async Task SupportTargetAsync(Guid userAccountId, CancellationToken cancellationToken) => + await StandingAsync(dbContext, userAccountId, cancellationToken) is MergeStanding.Folded folded + ? new SupportTarget(userAccountId, folded.SurvivorId, folded.MergeId) + : new SupportTarget(userAccountId, userAccountId, null); + /// Writes nothing. lets the saga re-plan a merge it already holds without it blocking itself. public async Task PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null) { diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 48ccb77..cfe46b6 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -54,12 +54,6 @@ public sealed class MotionGovernanceService( { var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); - // An actor resolved before a merge froze this account must not write a vote after it: standing is read again at the write. - if (await AccountMergeLedger.StandingAsync(dbContext, actorUser.Id, cancellationToken) is not null) - { - return; - } - var motion = await dbContext.Motions .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); @@ -69,6 +63,12 @@ public sealed class MotionGovernanceService( return; } + // An actor resolved before a merge froze this account must not write a vote after it: standing is read again at the write. + if (!await AccountMergeLedger.MayWriteAsync(dbContext, actorUser.Id, cancellationToken)) + { + return; + } + var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); if (vote is null) { diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 818465e..f6e2922 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.RegularExpressions; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -101,6 +102,7 @@ public sealed class SealedBallotService( var ballot = await dbContext.SealedBallots .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); + await RequireStandingAsync(user.Id, cancellationToken); if (ballot is null) { ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; @@ -161,6 +163,7 @@ public sealed class SealedBallotService( // another voter's reveal landed first while the window is still open (re-read the row and write again). for (var attempt = 0; ; attempt++) { + await RequireStandingAsync(user.Id, cancellationToken); ballot.RevealedCandidate = candidate; ballot.RevealedReason = reason; ballot.Salt = document.Salt; @@ -359,6 +362,15 @@ public sealed class SealedBallotService( private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName; + // A member resolved before a merge froze the account must not write a ballot after it: standing is read again at the write. + private async Task RequireStandingAsync(Guid userAccountId, CancellationToken cancellationToken) + { + if (!await AccountMergeLedger.MayWriteAsync(dbContext, userAccountId, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required."); + } + } + private static UserAccount RequireMember(CurrentBifrostActor actor) => actor is { IsActiveMember: true, UserAccount: { } user } ? user diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 83f5539..86168d5 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -10,6 +10,8 @@ public sealed class PatronageService( AuditTrailService auditTrailService, TimeProvider timeProvider) { + private const string CurrentPatronIndex = "UX_TierSnapshots_MembershipId_CurrentPatron"; + public async Task RecordSupportEventAsync( Guid? actorUserAccountId, Guid userAccountId, @@ -134,21 +136,38 @@ public sealed class PatronageService( Guid userAccountId, CancellationToken cancellationToken) { - dbContext.ChangeTracker.Clear(); - - var user = await dbContext.UserAccounts - .Include(x => x.Membership) - .Include(x => x.PatronSupportEvents) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); - - if (user.Membership is null) + // One current Patron snapshot per membership is a database invariant (UX_TierSnapshots_MembershipId_CurrentPatron). A restage that + // lost a race to another restage of the same membership reads the winner's snapshot and derives again from the events it now sees. + UserAccount user; + PatronPointSummary summary; + PatronTierDefinition? tier; + for (var attempt = 0; ; attempt++) { - throw new InvalidOperationException("Cannot refresh patron tier for a user without membership."); + dbContext.ChangeTracker.Clear(); + + user = await dbContext.UserAccounts + .Include(x => x.Membership) + .Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == userAccountId, cancellationToken); + + if (user.Membership is null) + { + throw new InvalidOperationException("Cannot refresh patron tier for a user without membership."); + } + + (summary, tier) = await StageTierSnapshotAsync(user.Membership, user.PatronSupportEvents, timeProvider.GetUtcNow(), cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + break; + } + catch (DbUpdateException error) when (attempt < 3 && + error.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation, ConstraintName: CurrentPatronIndex }) + { + } } - var (summary, tier) = await StageTierSnapshotAsync(user.Membership, user.PatronSupportEvents, timeProvider.GetUtcNow(), cancellationToken); - - await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( actorUserAccountId, nameof(TierSnapshot), diff --git a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs index 4293ffe..34142ad 100644 --- a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs +++ b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs @@ -5,6 +5,7 @@ using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -13,6 +14,7 @@ namespace Bifrost.Web.Features.Patronage; public sealed class StripeWebhookService( BifrostDbContext dbContext, PatronageService patronageService, + AccountMergeLedger mergeLedger, IOptions options) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -66,6 +68,17 @@ public sealed class StripeWebhookService( var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken); + // A checkout opened by an account that has since folded pays the survivor, as the Heimdall intake does: the same routing owner. + var target = patronageAccount is null + ? null + : await mergeLedger.SupportTargetAsync(patronageAccount.Id, cancellationToken); + if (patronageAccount is not null && target!.RecordOnAccountId != patronageAccount.Id) + { + patronageAccount = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == target.RecordOnAccountId, cancellationToken); + } + if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended) { return StripeWebhookResult.NotConfigured( @@ -83,7 +96,7 @@ public sealed class StripeWebhookService( ? "USD" : session.Currency.ToUpperInvariant(); - await patronageService.RecordSupportEventAsync( + var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, patronageAccount.Id, PatronSupportEventKind.OneTimeDonation, @@ -100,6 +113,11 @@ public sealed class StripeWebhookService( cancellationToken, projectId.ProjectId); + if (target!.MergeId is { } mergeId) + { + await mergeLedger.JournalIntakeAsync(mergeId, target.ForAccountId, supportEvent.Id, cancellationToken); + } + return StripeWebhookResult.Processed("Stripe patronage support event recorded."); } diff --git a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs index 800cd33..7382c1e 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -191,6 +191,79 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I Assert.Equal([s.Folded], await EventOwnersAsync(s)); } + private Task> PatronJournalAsync(Guid merge) => + WithDb(db => db.AccountMergeEntries.Where(x => x.MergeId == merge).OrderBy(x => x.Seq).ToListAsync()); + + [Fact] + public async Task A_single_patron_event_during_grace_is_journalled_by_its_own_delivery_and_undo_moves_it_back() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}")); // no retry follows + + var journal = await PatronJournalAsync(merge); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); // the new entry takes its own Seq + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_provider_retry_journals_an_event_the_first_delivery_left_unjournalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + await WithDb(async db => // the first delivery recorded on the survivor, then died before journalling + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = s.Survivor, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1", + Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return 0; + }); + Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + + Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_stripe_checkout_opened_by_an_account_that_folded_pays_the_survivor_and_is_journalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var payload = JsonSerializer.Serialize(new + { + id = $"evt_{Guid.NewGuid():N}", type = "checkout.session.completed", created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + data = new { @object = new { id = $"cs_{Guid.NewGuid():N}", customer = "cus_1", currency = "usd", amount_total = 1900, payment_status = "paid", + metadata = new Dictionary { ["bifrost_user_account_id"] = s.Folded.ToString("N") } } } + }); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("whsec_test_webhook_secret")); + using var request = new HttpRequestMessage(HttpMethod.Post, "/patronage/stripe/webhook"); + request.Headers.Add("Stripe-Signature", $"t={timestamp},v1={Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{payload}"))).ToLowerInvariant()}"); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var client = factory.CreateClient(); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + } + [Fact] public async Task A_folded_bootstrap_admins_sign_in_does_not_reactivate_the_merged_membership_and_an_approval_is_refused() { diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index da58e4f..db8ecd9 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -1,5 +1,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Patronage; +using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -387,28 +389,136 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi // ---- rollback ---- [PostgresFact] - public async Task Reverting_the_migration_is_refused_while_a_merge_has_voided_a_vote_and_allowed_after() + public async Task Reverting_the_migration_is_refused_while_any_merge_exists_even_one_that_voided_nothing_and_allowed_after() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await world.CommitAsync(await world.MergeAsync(survivor, folded)); // moves and voids nothing: only the Merged membership remains + + await using (var db = world.Db()) + { + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); + Assert.Contains("roll-forward only", Pg(error).MessageText); + Assert.Equal("20261114120000_AddAccountMerges", (await db.Database.GetAppliedMigrationsAsync()).Last()); + } + + await ExecuteAsync(world, "DELETE FROM \"AccountMergeEntries\"; DELETE FROM \"AccountMerges\""); + await using var down = world.Db(); + await down.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); + Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last()); + await down.GetService().MigrateAsync(); // and up again, trigger and index and all + Assert.Equal("20261114120000_AddAccountMerges", (await down.Database.GetAppliedMigrationsAsync()).Last()); + } + + // ---- restage and intake journal ---- + + [PostgresFact] + public async Task Two_merges_into_one_survivor_restaging_at_once_leave_one_current_patron_snapshot_and_the_loser_commits_on_retry() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var first = await world.AccountAsync("first"); + var second = await world.AccountAsync("second"); + await using (var db = world.Db()) + { + foreach (var who in new[] { first, second }) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = who.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m, + SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0 + }); + } + + await db.SaveChangesAsync(); + } + + var one = await world.MergeAsync(survivor, first); + var two = await world.MergeAsync(survivor, second); + var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous()); + + async Task TryCommit(AccountMerge merge) + { + try + { + await committers.CommitAsync(merge); + return null; + } + catch (Exception error) + { + return error; + } + } + + var results = await Task.WhenAll(Task.Run(() => TryCommit(one)), Task.Run(() => TryCommit(two))); + + Assert.Equal(1, results.Count(x => x is null)); + Assert.All(results.OfType(), e => Assert.Contains(Chain(e), x => x is PostgresException { SqlState: "23505", ConstraintName: "UX_TierSnapshots_MembershipId_CurrentPatron" })); + var loser = results[0] is null ? two : one; + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(loser)).State); + await using var read = world.Db(); + var membership = await read.Memberships.Include(x => x.TierSnapshots).SingleAsync(x => x.UserAccountId == survivor.Id); + Assert.Single(membership.TierSnapshots, x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent); + Assert.All(await read.PatronSupportEvents.ToListAsync(), e => Assert.Equal(survivor.Id, e.UserAccountId)); + } + + [PostgresFact] + public async Task A_patron_refresh_that_loses_the_restage_race_reads_the_winners_snapshot_and_derives_again() + { + var world = await postgres.NewWorldAsync(); + var patron = await world.AccountAsync("patron"); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = patron.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m, + SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0 + }); + await db.SaveChangesAsync(); + } + + var clock = world.Clock; + var rival = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world)); + var loser = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + await using var rivalDb = rival.Db(); + await new PatronageService(rivalDb, new AuditTrailService(rivalDb, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default); + })); + + await using (var db = loser.Db()) + { + var summary = await new PatronageService(db, new AuditTrailService(db, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default); + Assert.True(summary.VotingWeight > 0m); + } + + await using var read = world.Db(); + var snapshots = await read.TierSnapshots.Where(x => x.Kind == TierSnapshotKind.Patron).ToListAsync(); + Assert.Single(snapshots, x => x.IsCurrent); + Assert.Equal(2, snapshots.Count); // the winner's, demoted by the retry, and the retry's own + } + + [PostgresFact] + public async Task A_journalled_patron_intake_takes_the_next_seq_after_the_commits_entries_and_is_idempotent() { var world = await postgres.NewWorldAsync(); var survivor = await world.AccountAsync("survivor"); var folded = await world.AccountAsync("folded"); var motion = await world.MotionAsync(); - await world.VoteAsync(motion, survivor, VoteChoice.For); - await world.VoteAsync(motion, folded, VoteChoice.Against); - await world.CommitAsync(await world.MergeAsync(survivor, folded)); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + var committed = (await world.JournalAsync(merge)).Count; + Assert.True(committed > 0); - await using (var db = world.Db()) + var events = new[] { Guid.NewGuid(), Guid.NewGuid() }; + foreach (var id in events.Concat(events)) // each twice: the provider retries { - var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); - Assert.Contains("cannot be reverted", Pg(error).MessageText); - Assert.Equal("20261114120000_AddAccountMerges", (await db.Database.GetAppliedMigrationsAsync()).Last()); + await using var db = world.Db(); + await world.Ledger(db).JournalIntakeAsync(merge.Id, folded.Id, id, default); } - await ExecuteAsync(world, "UPDATE \"Votes\" SET \"VoidedByMergeId\" = NULL, \"VoidedUtc\" = NULL"); - await using var down = world.Db(); - await down.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); - Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last()); - await down.GetService().MigrateAsync(); // and up again, trigger and all - Assert.Equal("20261114120000_AddAccountMerges", (await down.Database.GetAppliedMigrationsAsync()).Last()); + var journal = await world.JournalAsync(merge); + Assert.Equal(committed + 2, journal.Count); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); } } diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index 3bb7714..f25b8c7 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -916,6 +916,26 @@ public sealed class HeimdallCompletionTests Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status); } + [Fact] + public async Task A_legacy_account_without_a_membership_row_signs_in_and_gets_one() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), accountId)).SignedIn); + await host.Db(async db => + { + db.Memberships.RemoveRange(db.Memberships.Where(x => x.UserAccount.HeimdallAccountId == accountId)); + await db.SaveChangesAsync(); + return 0; + }); + Assert.Null((await host.Account(accountId))!.Membership); + + var again = await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"]); + + Assert.True(again.SignedIn); + Assert.Equal(MembershipStatus.Active, (await host.Account(accountId))!.Membership!.Status); + } + [Fact] public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member() { diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 29ebd19..04062f0 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -272,6 +272,45 @@ public sealed class SealedBallotTests Assert.Equal(1, await rig.Db.AuditEvents.CountAsync(x => x.Action == "sealed.closed")); } + private static async Task FreezeAsync(Rig rig, CurrentBifrostActor folded) + { + var survivor = rig.Member("Survivor"); + rig.Db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = survivor.UserAccount!.Id, FoldedUserAccountId = folded.UserAccount!.Id, State = AccountMergeState.Confirmed, CreatedUtc = T0 + }); + await rig.Db.SaveChangesAsync(); + } + + [Fact] + public async Task A_commit_from_an_actor_resolved_before_the_account_froze_writes_no_ballot() + { + var rig = new Rig(); + var motion = await rig.OpenAsync("Ana", "Bo"); + var staleActor = rig.Member("Folded"); // resolved before the merge was confirmed + await FreezeAsync(rig, staleActor); + var refusal = await Assert.ThrowsAsync(() => rig.Cast(staleActor, motion.Id, "Ana", "Mine.")); + + Assert.Equal(SealedBallotFailure.Forbidden, refusal.Failure); + Assert.Empty(rig.Db.SealedBallots); + } + + [Fact] + public async Task A_reveal_from_an_actor_resolved_before_the_account_froze_changes_nothing() + { + var rig = new Rig(); + var motion = await rig.OpenAsync("Ana", "Bo"); + var staleActor = rig.Member("Folded"); + await rig.Cast(staleActor, motion.Id, "Ana", "Mine."); + await FreezeAsync(rig, staleActor); + rig.Clock.Now = T0.AddMinutes(90); + + var refusal = await Assert.ThrowsAsync(() => rig.Reveal(staleActor, motion.Id, "Ana", "Mine.")); + + Assert.Equal(SealedBallotFailure.Forbidden, refusal.Failure); + Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc); + } + // Runs a callback once, just before the first save that closes a motion: the window between the close reading its ballots and writing. private sealed class BeforeCloseSave(Func action) : SaveChangesInterceptor { -- 2.47.3 From 10fc9803d7d1cd8c4324635720fefa1c119130a0 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 04:59:44 +0000 Subject: [PATCH 12/20] Account merge ledger: test the migration's duplicate current Patron snapshot dedupe Co-Authored-By: Claude Sonnet 5.5 --- .../AccountMergePostgresTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index db8ecd9..0ec037d 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -498,6 +498,29 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi Assert.Equal(2, snapshots.Count); // the winner's, demoted by the retry, and the retry's own } + [PostgresFact] + public async Task The_migration_keeps_only_the_newest_of_duplicate_current_patron_snapshots_before_it_builds_the_index() + { + var world = await postgres.NewWorldAsync(); + var patron = await world.AccountAsync("patron"); + await using var db = world.Db(); + await db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); // the index does not exist yet + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == patron.Id); + TierSnapshot Snapshot(string label, int minutes) => new() + { + MembershipId = membership.Id, Kind = TierSnapshotKind.Patron, Label = label, Weight = 1m, IsCurrent = true, + EffectiveFromUtc = AccountMergeLedgerTests.T0, CapturedAtUtc = AccountMergeLedgerTests.T0.AddMinutes(minutes) + }; + db.TierSnapshots.AddRange(Snapshot("older", 0), Snapshot("newer", 5)); + await db.SaveChangesAsync(); + + await db.GetService().MigrateAsync(); + + await using var read = world.Db(); + Assert.Equal("newer", (await read.TierSnapshots.SingleAsync(x => x.IsCurrent)).Label); + Assert.Equal(2, await read.TierSnapshots.CountAsync()); + } + [PostgresFact] public async Task A_journalled_patron_intake_takes_the_next_seq_after_the_commits_entries_and_is_idempotent() { -- 2.47.3 From 5df08bd94a13419598d37e0982f20a27748c8301 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 06:08:36 +0000 Subject: [PATCH 13/20] Move client: pin that invalid_request at 413 and 415 is Unknown, the rule is exactly 400 Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index a2cc873..a979b10 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -312,6 +312,8 @@ public sealed class HeimdallIdentityMoveClientTests [InlineData(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"x"}""")] [InlineData(HttpStatusCode.BadRequest, """{"message":"no error field"}""")] [InlineData(HttpStatusCode.Conflict, """{"error":"invalid_request"}""")] + [InlineData(HttpStatusCode.RequestEntityTooLarge, """{"error":"invalid_request"}""")] // Heimdall's own 413 for a body over its limit; the client's rule is exactly 400 + [InlineData(HttpStatusCode.UnsupportedMediaType, """{"error":"invalid_request"}""")] [InlineData(HttpStatusCode.RequestTimeout, "")] [InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")] [InlineData(HttpStatusCode.Gone, "")] -- 2.47.3 From f726101207d3176ffb8c3d85f4d3b1fd135cf882 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 06:08:36 +0000 Subject: [PATCH 14/20] Account merge ledger: one write gate for every writer, held across the standing read and the save WriteAsAsync takes a shared Postgres advisory lock on the account (the key the chain trigger locks exclusively) across the standing read and the writer's save; CommitAsync takes the exclusive lock before it reduces. Votes, appraisals, sealed commit and reveal, patron events (RecordSupportEventAsync routes and journals in the same save, so the manual Ledger door obeys it too) and the Heimdall sign-in pass through it; SupportTargetAsync, MayWriteAsync and JournalIntakeAsync are deleted. A folded or frozen account's sign-in writes no membership. A retry of an event stranded on the folded account journals nothing. Tests: Down refuses in every merge state, Stripe journal prior owner and undo, the lock racing a freeze and a commit on live Postgres. Docs say what the lock guarantees and what it does not. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- docs/account-merge.md | 6 +- .../ConceptVotes/ConceptAppraisalService.cs | 56 ++--- .../Features/Heimdall/HeimdallAuthService.cs | 123 +++++----- .../HeimdallPatronSupportIntakeService.cs | 38 +--- .../Features/Membership/AccountMergeLedger.cs | 81 ++++--- .../Motions/MotionGovernanceService.cs | 58 +++-- .../Features/Motions/SealedBallotService.cs | 91 ++++---- .../Features/Patronage/PatronageService.cs | 214 ++++++++++-------- .../Patronage/StripeWebhookService.cs | 58 ++--- .../AccountMergeHttpTests.cs | 58 ++++- .../AccountMergePostgresTests.cs | 144 +++++++++++- .../HeimdallCompletionTests.cs | 48 ++++ 13 files changed, 644 insertions(+), 333 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f0fd40..e822cf2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Then start the first implementation milestone: ## Live Postgres tests -Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the one-current-Patron-snapshot index under racing restages, the intake journal `Seq`, the migration's roll-forward-only guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: +Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the one-current-Patron-snapshot index under racing restages, the intake journal `Seq`, the write gate's shared lock racing a merge's freeze and commit, the migration's roll-forward-only guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: ```bash BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests diff --git a/docs/account-merge.md b/docs/account-merge.md index 5dc1068..cbb0460 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -18,9 +18,9 @@ An account's standing is derived from that state on every request; there is no ` | Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` | | Proposed, Failed, Undone | none | -An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Heimdall patron intake reads the standing: an event for a folded account's Heimdall id records on the survivor and is journalled on the merge (`JournalIntakeAsync`, idempotent for a provider's retry), so an undo moves it back. +An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Every patron event is recorded through `PatronageService.RecordSupportEventAsync`, which writes through the gate below: an event for a folded account (Heimdall intake, Stripe webhook, the admin Ledger's manual record) records on the survivor and is journalled in the same save, so an undo moves it back; a provider retry returns the stored event and journals it once if it sits on the survivor. -`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and the vote and appraisal writers read it again at the write, so an actor resolved before the merge froze the account writes nothing after it. +`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and every writer reads it again at the write, through the gate below, so an actor resolved before the merge froze the account writes nothing after it. ## Plan, commit, undo, finalize @@ -54,6 +54,6 @@ Bifrost is **roll-forward only** once a merge exists (`heimdall-body:ruling:merg ## Not covered -`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. Every writer that acts for an account (votes, concept appraisals, sealed-ballot commit and reveal) reads standing through `AccountMergeLedger.MayWriteAsync` after loading what it changes and before it mutates, and an account with a standing writes nothing. Patron events (the Heimdall intake and the Stripe webhook) route through `AccountMergeLedger.SupportTargetAsync` immediately before they record: a folded account's event lands on the survivor and is journalled, so undo moves it back. Each of these is a read followed by a save, so a request whose read precedes the commit by microseconds and whose save follows it lands on the folded account: that window is not closed. Closing it needs a lock held across the read and the save, or the tally-time voiding of design section 7; neither is built, and no operator void tool exists. A patron event or vote that falls in it stays on the folded account until it is corrected by hand in the database. +`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. Every writer that acts for an account (votes, concept appraisals, sealed-ballot commit and reveal, patron events, the Heimdall sign-in's membership) passes through `AccountMergeLedger.WriteAsAsync`. On Postgres it opens a transaction, takes a shared advisory lock on the account (`pg_advisory_xact_lock_shared(hashtextextended(id::text, 0))`, the key the chain trigger takes exclusively when a merge row is inserted or changes state), reads standing, runs the writer's save and commits. `CommitAsync` takes the exclusive lock on the folded account before it reduces, then re-reads the merge. So: a writer whose standing read found no merge saves before a merge can freeze or fold the account, and the commit's reduction sees what it saved; a merge that is already frozen or folded when the writer reads is refused (votes, appraisals, ballots, sign-in membership) or routed (patron events). Guaranteed only for writes made through the gate, and only on Postgres: the in-memory provider has no lock or transaction (tests that rest on the lock run on live Postgres), and a writer that catches a database error inside the gate cannot continue, because the transaction is aborted. Not closed: writers outside the gate (nothing in the code base today; a new one must use it), and a writer's own stale reads taken before the gate opened (the motion a vote was loaded with), which concern the motion, not the account's standing. No operator void tool exists. `UX_TierSnapshots_MembershipId_CurrentPatron` allows one current Patron snapshot per membership, so two restages of one survivor racing cannot both leave a current snapshot: the loser's save fails on the unique index, a merge commit rolls back whole and is retried by the saga, and a patron refresh re-reads and derives again. diff --git a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs index 17e9ddd..602ac7a 100644 --- a/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs +++ b/src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs @@ -81,21 +81,42 @@ public sealed class ConceptAppraisalService( ? string.Empty : NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret); - var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); + // A voter resolved before a merge froze the account must not write an appraisal after it: the write gate reads standing at the write. + Task Write() => voter.UserAccountId is { } voterAccountId + ? AccountMergeLedger.WriteAsAsync( + dbContext, + voterAccountId, + standing => standing is null ? WriteAppraisalAsync(subject, voter, stance, tag, cancellationToken) : Task.FromResult(CastResult.NoVoter), + cancellationToken) + : WriteAppraisalAsync(subject, voter, stance, tag, cancellationToken); - // A voter resolved before a merge froze the account must not write an appraisal after it: standing is read again at the write. - if (voter.UserAccountId is { } voterAccountId && - !await AccountMergeLedger.MayWriteAsync(dbContext, voterAccountId, cancellationToken)) + try { - return CastResult.NoVoter; + return await Write(); } + catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation }) + { + // A concurrent cast by the same voter won the insert (and the failed save aborted the gate's transaction): go through the + // gate again, which now finds their row and applies this stance to it, once. + dbContext.ChangeTracker.Clear(); + if (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken) is null) + { + throw; + } + return await Write(); + } + } + + private async Task WriteAppraisalAsync(ConceptSubject subject, VoterIdentity voter, ConceptStance stance, string tag, CancellationToken cancellationToken) + { + var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); if (existing is not null) { return await ApplyStanceAsync(existing, stance, tag, cancellationToken); } - var row = new ConceptAppraisal + dbContext.ConceptAppraisals.Add(new ConceptAppraisal { SubjectId = subject.Id, VoterKey = voter.Key, @@ -104,26 +125,9 @@ public sealed class ConceptAppraisalService( UserAccountId = voter.UserAccountId, NetworkTag = tag, CastAtUtc = timeProvider.GetUtcNow() - }; - dbContext.ConceptAppraisals.Add(row); - - try - { - await dbContext.SaveChangesAsync(cancellationToken); - return CastResult.Recorded; - } - catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation }) - { - // A concurrent cast by the same voter won the insert: apply this stance to their row, once. - dbContext.Entry(row).State = EntityState.Detached; - var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken); - if (winner is null) - { - throw; - } - - return await ApplyStanceAsync(winner, stance, tag, cancellationToken); - } + }); + await dbContext.SaveChangesAsync(cancellationToken); + return CastResult.Recorded; } public async Task RetractAsync( diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs index aa2416e..bb413a9 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallAuthService.cs @@ -803,76 +803,87 @@ public sealed class HeimdallAuthService( .ThenInclude(x => x.TierSnapshots) .SingleOrDefaultAsync(x => x.HeimdallAccountId == accountId, cancellationToken); - if (userAccount is null) + // An existing account is written as that account, through the merge ledger's write gate: a merge cannot fold it between the standing + // read and the save. A new account has no merge to race. + return userAccount is null + ? await PersistAsync(null) + : await AccountMergeLedger.WriteAsAsync(dbContext, userAccount.Id, PersistAsync, cancellationToken); + + async Task PersistAsync(MergeStanding? standing) { - userAccount = new UserAccount + if (userAccount is null) { - HeimdallAccountId = accountId, - DisplayName = displayName, - CreatedAtUtc = now, - LastSeenAtUtc = now, - MemberProfile = new MemberProfile + userAccount = new UserAccount { + HeimdallAccountId = accountId, + DisplayName = displayName, + CreatedAtUtc = now, + LastSeenAtUtc = now, + MemberProfile = new MemberProfile + { + Nickname = displayName, + Headline = $"Verified through Heimdall {provider}", + UpdatedAtUtc = now + }, + Membership = new Bifrost.Web.Domain.Membership + { + Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated, + CreatedAtUtc = now, + ApprovedAtUtc = grantMemberAccess ? now : null, + Notes = grantMemberAccess + ? "Activated by Heimdall member access claim" + : $"Identity verified through Heimdall {provider}" + } + }; + dbContext.UserAccounts.Add(userAccount); + } + else + { + userAccount.DisplayName = displayName; + userAccount.LastSeenAtUtc = now; + userAccount.MemberProfile ??= new MemberProfile + { + UserAccountId = userAccount.Id, Nickname = displayName, Headline = $"Verified through Heimdall {provider}", UpdatedAtUtc = now - }, - Membership = new Bifrost.Web.Domain.Membership + }; + if (userAccount.Membership is null && standing is null) { - Status = grantMemberAccess ? MembershipStatus.Active : MembershipStatus.Authenticated, - CreatedAtUtc = now, - ApprovedAtUtc = grantMemberAccess ? now : null, - Notes = grantMemberAccess - ? "Activated by Heimdall member access claim" - : $"Identity verified through Heimdall {provider}" + // Added explicitly: a row with a preset key assigned to a tracked account is taken for an existing one and the save fails. + userAccount.Membership = new Bifrost.Web.Domain.Membership { UserAccountId = userAccount.Id, CreatedAtUtc = now }; + dbContext.Memberships.Add(userAccount.Membership); + } + + // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. A merged row is terminal: the claim never reactivates it. + // A folded account, or one a merge has frozen, is an alias of its survivor: sign-in neither creates nor activates a membership for it. + if (grantMemberAccess && standing is null && userAccount.Membership is { IsMerged: false, Status: not MembershipStatus.Suspended }) + { + userAccount.Membership.Status = MembershipStatus.Active; + userAccount.Membership.ApprovedAtUtc ??= now; } - }; - dbContext.UserAccounts.Add(userAccount); - } - else - { - userAccount.DisplayName = displayName; - userAccount.LastSeenAtUtc = now; - userAccount.MemberProfile ??= new MemberProfile - { - UserAccountId = userAccount.Id, - Nickname = displayName, - Headline = $"Verified through Heimdall {provider}", - UpdatedAtUtc = now - }; - if (userAccount.Membership is null) - { - // Added explicitly: a row with a preset key assigned to a tracked account is taken for an existing one and the save fails. - userAccount.Membership = new Bifrost.Web.Domain.Membership { UserAccountId = userAccount.Id, CreatedAtUtc = now }; - dbContext.Memberships.Add(userAccount.Membership); } - // A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. A merged row is terminal: the claim never reactivates it. - if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended && !userAccount.Membership.IsMerged) + userAccount.MemberProfile!.Nickname = displayName; + userAccount.MemberProfile.UpdatedAtUtc = now; + await bifrostIdentityService.EnsureIdentityAsync( + userAccount, + displayName, + cancellationToken); + if (grantMemberAccess && userAccount.Membership is { Status: MembershipStatus.Active }) { - userAccount.Membership.Status = MembershipStatus.Active; - userAccount.Membership.ApprovedAtUtc ??= now; + ApplicationBootstrapper.EnsureRole( + userAccount.Membership, + MemberRole.StandardMember, + null, + now, + "Heimdall member access"); } - } - userAccount.MemberProfile!.Nickname = displayName; - userAccount.MemberProfile.UpdatedAtUtc = now; - await bifrostIdentityService.EnsureIdentityAsync( - userAccount, - displayName, - cancellationToken); - if (grantMemberAccess && userAccount.Membership!.Status == MembershipStatus.Active) - { - ApplicationBootstrapper.EnsureRole( - userAccount.Membership!, - MemberRole.StandardMember, - null, - now, - "Heimdall member access"); - } + await dbContext.SaveChangesAsync(cancellationToken); - await dbContext.SaveChangesAsync(cancellationToken); - return userAccount; + return userAccount!; + } } private static HeimdallEntitlementPolicy BuildDiscordPolicy(HeimdallOptions options) diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs index 57c565f..02b41c2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -5,7 +5,6 @@ using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; -using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Patronage; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -15,7 +14,6 @@ namespace Bifrost.Web.Features.Heimdall; public sealed class HeimdallPatronSupportIntakeService( BifrostDbContext dbContext, PatronageService patronageService, - AccountMergeLedger mergeLedger, IOptions heimdallOptions) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) @@ -70,30 +68,21 @@ public sealed class HeimdallPatronSupportIntakeService( .AsNoTracking() .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); - if (existingEvent is not null) - { - // A provider retry of an event already processed: journal it if the account has since folded (idempotent). - if (user is not null && await mergeLedger.SupportTargetAsync(user.Id, cancellationToken) is { MergeId: { } retriedMergeId }) - { - await mergeLedger.JournalIntakeAsync(retriedMergeId, user.Id, existingEvent.Id, cancellationToken); - } - - return HeimdallPatronSupportIntakeResult.Processed( - $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", - existingEvent.Id); - } - if (user is null) { - return HeimdallPatronSupportIntakeResult.BadRequest( - $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); + return existingEvent is not null + ? HeimdallPatronSupportIntakeResult.Processed( + $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", + existingEvent.Id) + : HeimdallPatronSupportIntakeResult.BadRequest( + $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); } - // A folded account's events record on the survivor and are journalled on the merge, so an undo moves them back. - var target = await mergeLedger.SupportTargetAsync(user.Id, cancellationToken); + // The write gate behind RecordSupportEventAsync routes a folded account's event to its survivor and journals it, and on a + // provider retry returns the stored event, journalling it if it sits on the survivor and was left unjournalled. var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, - userAccountId: target.RecordOnAccountId, + userAccountId: user.Id, request.Kind, request.Amount, request.CurrencyCode, @@ -107,13 +96,10 @@ public sealed class HeimdallPatronSupportIntakeService( request.Notes, cancellationToken); - if (target.MergeId is { } mergeId) - { - await mergeLedger.JournalIntakeAsync(mergeId, user.Id, supportEvent.Id, cancellationToken); - } - return HeimdallPatronSupportIntakeResult.Processed( - $"Processed {request.Provider} patron support event {request.ProviderEventId}.", + existingEvent is not null + ? $"Already processed {request.Provider} patron support event {request.ProviderEventId}." + : $"Processed {request.Provider} patron support event {request.ProviderEventId}.", supportEvent.Id); } diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs index f540061..c84d6f8 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -7,9 +7,6 @@ using Microsoft.EntityFrameworkCore; namespace Bifrost.Web.Features.Membership; /// How an account stands under a merge: the folded account of an unfinished merge is Frozen, of a landed one Folded. -/// Where a patron event for an account lands; is set when the account is folded and the event must be journalled. -public sealed record SupportTarget(Guid ForAccountId, Guid RecordOnAccountId, Guid? MergeId); - public abstract record MergeStanding(Guid MergeId) { public sealed record Frozen(Guid MergeId) : MergeStanding(MergeId); @@ -74,23 +71,45 @@ public sealed class AccountMergeLedger( } /// - /// The one standing-at-write rule: an account with a merge standing (frozen or folded) writes nothing as itself. Every writer that - /// acts for an account (votes, appraisals, sealed ballots) calls this after loading what it will change and before it mutates, - /// so an actor resolved before the merge froze the account cannot write after it. It reads, then the writer saves: the - /// microseconds between the two are the window the saga cannot close without a lock or tally-time voiding (docs/account-merge.md). + /// The one gate every writer that acts for an account passes through (votes, appraisals, sealed ballots, patron events, the Heimdall + /// sign-in's membership). On Postgres it holds a shared advisory lock on the account, the key the chain trigger locks exclusively + /// when a merge row is inserted or changes state and that takes exclusively before it reduces, across the + /// standing read and everything saves, then commits. So a merge cannot freeze or fold the account between a + /// writer's standing read and its save: it waits for the writer, and a writer that starts after it reads the new standing. The + /// lock covers writes made through this gate and nothing else; it is held until the transaction ends, so a writer must not + /// catch a database error inside and carry on (the transaction is already aborted). On the in-memory + /// provider there is no lock and no transaction: the standing is read, then the writer saves. /// - public static async Task MayWriteAsync(BifrostDbContext db, Guid userAccountId, CancellationToken cancellationToken) => - await StandingAsync(db, userAccountId, cancellationToken) is null; + public static async Task WriteAsAsync(BifrostDbContext db, Guid userAccountId, Func> write, CancellationToken cancellationToken) + { + await using var transaction = await BeginAsync(db, cancellationToken); + await LockAsync(db, userAccountId, exclusive: false, cancellationToken); + var result = await write(await StandingAsync(db, userAccountId, cancellationToken)); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } - /// - /// The one routing of patron support: an event for a folded account lands on its survivor, anything else on the account itself. - /// The Heimdall intake and the Stripe webhook both resolve through here, immediately before they record, and journal a folded - /// event with . - /// - public async Task SupportTargetAsync(Guid userAccountId, CancellationToken cancellationToken) => - await StandingAsync(dbContext, userAccountId, cancellationToken) is MergeStanding.Folded folded - ? new SupportTarget(userAccountId, folded.SurvivorId, folded.MergeId) - : new SupportTarget(userAccountId, userAccountId, null); + return result; + } + + /// A transaction for the caller to commit, or null where there is none to open: not Postgres, or one already open (its owner commits). + private static async Task BeginAsync(BifrostDbContext db, CancellationToken cancellationToken) => + db.Database.IsNpgsql() && db.Database.CurrentTransaction is null ? await db.Database.BeginTransactionAsync(cancellationToken) : null; + + /// The account's lock, on the key the chain trigger uses (hashtextextended of the id's text). A no-op off Postgres. + private static async Task LockAsync(BifrostDbContext db, Guid userAccountId, bool exclusive, CancellationToken cancellationToken) + { + if (!db.Database.IsNpgsql()) + { + return; + } + + var key = userAccountId.ToString(); + await (exclusive + ? db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({key}, 0))", cancellationToken) + : db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock_shared(hashtextextended({key}, 0))", cancellationToken)); + } /// Writes nothing. lets the saga re-plan a merge it already holds without it blocking itself. public async Task PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null) @@ -190,6 +209,12 @@ public sealed class AccountMergeLedger( public async Task CommitAsync(Guid mergeId, CancellationToken cancellationToken) { var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken); + + // The exclusive lock first, so no writer that read the folded account's standing is still to save when the reduction reads its + // rows, then the state again: a commit that waited here found the merge landed and returns its receipt. + await using var transaction = await BeginAsync(dbContext, cancellationToken); + await LockAsync(dbContext, merge.FoldedUserAccountId, exclusive: true, cancellationToken); + await dbContext.Entry(merge).ReloadAsync(cancellationToken); if (merge.State is AccountMergeState.Committed or AccountMergeState.Final) { return merge; @@ -221,6 +246,11 @@ public sealed class AccountMergeLedger( merge.CommittedUtc = now; merge.GraceEndsUtc = now + Grace; await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return merge; } catch @@ -444,25 +474,24 @@ public sealed class AccountMergeLedger( } /// - /// Journals a patron event that landed on a folded account's survivor during grace, so undo moves it back with the rest. Idempotent: - /// the intake calls it again when the provider retries an event it already processed. + /// Stages the journal entry for a patron event that is on a folded account's survivor, so undo moves it back with the rest; saved + /// with the event by , the only caller. Idempotent: a provider retry stages nothing. /// - public async Task JournalIntakeAsync(Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, CancellationToken cancellationToken) + public static async Task StageIntakeJournalAsync(BifrostDbContext db, Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, DateTimeOffset now, CancellationToken cancellationToken) { - if (await dbContext.AccountMergeEntries.AnyAsync( + if (await db.AccountMergeEntries.AnyAsync( x => x.MergeId == mergeId && x.Kind == AccountMergeEntryKind.PatronSupportEvent && x.RecordId == supportEventId, cancellationToken)) { return; } - var seq = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0; - dbContext.AccountMergeEntries.Add(Journal( + var seq = await db.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0; + db.AccountMergeEntries.Add(Journal( mergeId, seq + 1, new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEventId, foldedUserAccountId), - timeProvider.GetUtcNow())); - await dbContext.SaveChangesAsync(cancellationToken); + now)); } private sealed class Reduction( diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index cfe46b6..ba60a2f 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -63,34 +63,44 @@ public sealed class MotionGovernanceService( return; } - // An actor resolved before a merge froze this account must not write a vote after it: standing is read again at the write. - if (!await AccountMergeLedger.MayWriteAsync(dbContext, actorUser.Id, cancellationToken)) + // An actor resolved before a merge froze this account must not write a vote after it: the write gate reads standing at the write. + var wrote = await AccountMergeLedger.WriteAsAsync(dbContext, actorUser.Id, async standing => + { + if (standing is not null) + { + return false; + } + + var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); + if (vote is null) + { + dbContext.Votes.Add(new Vote + { + MotionId = motionId, + UserAccountId = actorUser.Id, + Choice = choice, + Weight = actor.EffectiveVotingWeight, + Comment = comment?.Trim() ?? string.Empty, + CastAtUtc = timeProvider.GetUtcNow() + }); + } + else + { + vote.Choice = choice; + vote.Weight = actor.EffectiveVotingWeight; + vote.Comment = comment?.Trim() ?? string.Empty; + vote.CastAtUtc = timeProvider.GetUtcNow(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + }, cancellationToken); + + if (!wrote) { return; } - var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); - if (vote is null) - { - dbContext.Votes.Add(new Vote - { - MotionId = motionId, - UserAccountId = actorUser.Id, - Choice = choice, - Weight = actor.EffectiveVotingWeight, - Comment = comment?.Trim() ?? string.Empty, - CastAtUtc = timeProvider.GetUtcNow() - }); - } - else - { - vote.Choice = choice; - vote.Weight = actor.EffectiveVotingWeight; - vote.Comment = comment?.Trim() ?? string.Empty; - vote.CastAtUtc = timeProvider.GetUtcNow(); - } - - await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( actorUser.Id, nameof(Motion), diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index f6e2922..7fc3af0 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -100,22 +100,27 @@ public sealed class SealedBallotService( throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256)."); } - var ballot = await dbContext.SealedBallots - .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); - await RequireStandingAsync(user.Id, cancellationToken); - if (ballot is null) + var ballot = await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing => { - ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; - dbContext.SealedBallots.Add(ballot); - } - else - { - ballot.Revision++; - } + RequireStanding(standing); + var written = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); + if (written is null) + { + written = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; + dbContext.SealedBallots.Add(written); + } + else + { + written.Revision++; + } + + written.Commitment = commitment; + written.CommittedAtUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + return written; + }, cancellationToken); - ballot.Commitment = commitment; - ballot.CommittedAtUtc = now; - await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( user.Id, nameof(Motion), motionId, "sealed.committed", $"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.", @@ -161,37 +166,43 @@ public sealed class SealedBallotService( // RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or // another voter's reveal landed first while the window is still open (re-read the row and write again). - for (var attempt = 0; ; attempt++) + // Standing is read once, under the gate's lock, which holds across every attempt: a merge cannot freeze the account between them. + await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing => { - await RequireStandingAsync(user.Id, cancellationToken); - ballot.RevealedCandidate = candidate; - ballot.RevealedReason = reason; - ballot.Salt = document.Salt; - ballot.RevealedAtUtc = now; - motion.RevealSeq++; - try + RequireStanding(standing); + for (var attempt = 0; ; attempt++) { - await dbContext.SaveChangesAsync(cancellationToken); - break; - } - catch (DbUpdateConcurrencyException error) - { - foreach (var entry in error.Entries) + ballot.RevealedCandidate = candidate; + ballot.RevealedReason = reason; + ballot.Salt = document.Salt; + ballot.RevealedAtUtc = now; + motion.RevealSeq++; + try { - await entry.ReloadAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + break; } - - if (PhaseOf(motion, now) != SealedPhase.Reveal) + catch (DbUpdateConcurrencyException error) { - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); - } + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } - if (attempt >= 4) - { - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); + } + + if (attempt >= 4) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + } } } - } + + return true; + }, cancellationToken); await auditTrailService.RecordAsync( user.Id, nameof(Motion), motionId, "sealed.revealed", @@ -362,10 +373,10 @@ public sealed class SealedBallotService( private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName; - // A member resolved before a merge froze the account must not write a ballot after it: standing is read again at the write. - private async Task RequireStandingAsync(Guid userAccountId, CancellationToken cancellationToken) + // A member resolved before a merge froze the account must not write a ballot after it: the write gate reads standing at the write. + private static void RequireStanding(MergeStanding? standing) { - if (!await AccountMergeLedger.MayWriteAsync(dbContext, userAccountId, cancellationToken)) + if (standing is not null) { throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required."); } diff --git a/src/Bifrost.Web/Features/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 86168d5..e8cfbfe 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; @@ -12,6 +13,11 @@ public sealed class PatronageService( { private const string CurrentPatronIndex = "UX_TierSnapshots_MembershipId_CurrentPatron"; + /// + /// Records a patron event for an account, through the merge ledger's write gate: an event for a folded account lands on its survivor + /// and is journalled in the same save (an undo moves it back); an event for a frozen account lands on it, where the commit's + /// reduction finds it. A provider retry returns the stored event and, if it sits on a folded account's survivor, journals it once. + /// public async Task RecordSupportEventAsync( Guid? actorUserAccountId, Guid userAccountId, @@ -29,105 +35,128 @@ public sealed class PatronageService( CancellationToken cancellationToken, Guid? projectId = null) { - var user = await dbContext.UserAccounts - .Include(x => x.Membership) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); - - if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended) - { - throw new InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account."); - } - - if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) - { - throw new InvalidOperationException("Patron support amount must be greater than zero."); - } - - if (amount == 0) - { - throw new InvalidOperationException("Patron support amount must not be zero."); - } - - if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0) - { - throw new InvalidOperationException("Patron support adjustments must reduce support."); - } - var normalizedProviderEventId = providerEventId.Trim(); - if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId)) + var (supportEvent, recordedOn, isNew, recordedFor) = await AccountMergeLedger.WriteAsAsync(dbContext, userAccountId, async standing => { - throw new InvalidOperationException("External patron provider events require a provider event id."); - } + var folded = standing as MergeStanding.Folded; + var recordOnId = folded?.SurvivorId ?? userAccountId; + var now = timeProvider.GetUtcNow(); - var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId) - ? null - : await dbContext.PatronSupportEvents - .SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken); + var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId) + ? null + : await dbContext.PatronSupportEvents + .SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken); - if (existingProviderEvent is not null) - { - return existingProviderEvent; - } - - var now = timeProvider.GetUtcNow(); - var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode) - ? "USD" - : currencyCode.Trim().ToUpperInvariant(); - - if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport) - { - var existingCurrentEvents = await dbContext.PatronSupportEvents - .Where(x => x.UserAccountId == userAccountId && - x.Kind == PatronSupportEventKind.RecurringSupportSnapshot && - x.IsCurrentRecurringSupport) - .ToListAsync(cancellationToken); - - foreach (var existing in existingCurrentEvents) + if (existingProviderEvent is not null) { - existing.IsCurrentRecurringSupport = false; + if (folded is not null && existingProviderEvent.UserAccountId == folded.SurvivorId) + { + await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, existingProviderEvent.Id, now, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return (existingProviderEvent, recordOnId, false, string.Empty); } + + var user = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleAsync(x => x.Id == recordOnId, cancellationToken); + + if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended) + { + throw new PatronSupportRefusedException(); + } + + if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) + { + throw new InvalidOperationException("Patron support amount must be greater than zero."); + } + + if (amount == 0) + { + throw new InvalidOperationException("Patron support amount must not be zero."); + } + + if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0) + { + throw new InvalidOperationException("Patron support adjustments must reduce support."); + } + + if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId)) + { + throw new InvalidOperationException("External patron provider events require a provider event id."); + } + + var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode) + ? "USD" + : currencyCode.Trim().ToUpperInvariant(); + + if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport) + { + var existingCurrentEvents = await dbContext.PatronSupportEvents + .Where(x => x.UserAccountId == recordOnId && + x.Kind == PatronSupportEventKind.RecurringSupportSnapshot && + x.IsCurrentRecurringSupport) + .ToListAsync(cancellationToken); + + foreach (var existing in existingCurrentEvents) + { + existing.IsCurrentRecurringSupport = false; + } + } + + var created = new PatronSupportEvent + { + UserAccountId = recordOnId, + ProjectId = projectId, + Kind = kind, + ExternalSupportId = externalSupportId.Trim(), + Provider = provider, + ProviderEventId = normalizedProviderEventId, + ProviderPayerId = providerPayerId.Trim(), + ProviderSubscriptionId = providerSubscriptionId.Trim(), + Amount = amount, + CurrencyCode = normalizedCurrency, + IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport, + SupportedAtUtc = supportedAtUtc.ToUniversalTime(), + RecordedAtUtc = now, + Notes = notes.Trim() + }; + + dbContext.PatronSupportEvents.Add(created); + dbContext.PointTransactions.Add(new PointTransaction + { + UserAccountId = recordOnId, + ProjectId = projectId, + Type = PointTransactionType.PatronSupport, + Amount = amount, + IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, + CreatedAtUtc = now, + Note = $"Recorded {provider} {kind} patron support event {created.ExternalSupportId}." + }); + + if (folded is not null) + { + await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, created.Id, now, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return (created, recordOnId, true, user.DisplayName); + }, cancellationToken); + + if (isNew) + { + await auditTrailService.RecordAsync( + actorUserAccountId, + nameof(PatronSupportEvent), + supportEvent.Id, + "patron-support.recorded", + $"Recorded {amount:0.##} {supportEvent.CurrencyCode} {provider} {kind} patron support for {recordedFor}.", + cancellationToken); + + await RefreshPatronTierSnapshotAsync(actorUserAccountId, recordedOn, cancellationToken); } - var supportEvent = new PatronSupportEvent - { - UserAccountId = userAccountId, - ProjectId = projectId, - Kind = kind, - ExternalSupportId = externalSupportId.Trim(), - Provider = provider, - ProviderEventId = normalizedProviderEventId, - ProviderPayerId = providerPayerId.Trim(), - ProviderSubscriptionId = providerSubscriptionId.Trim(), - Amount = amount, - CurrencyCode = normalizedCurrency, - IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport, - SupportedAtUtc = supportedAtUtc.ToUniversalTime(), - RecordedAtUtc = now, - Notes = notes.Trim() - }; - - dbContext.PatronSupportEvents.Add(supportEvent); - dbContext.PointTransactions.Add(new PointTransaction - { - UserAccountId = userAccountId, - ProjectId = projectId, - Type = PointTransactionType.PatronSupport, - Amount = amount, - IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, - CreatedAtUtc = now, - Note = $"Recorded {provider} {kind} patron support event {supportEvent.ExternalSupportId}." - }); - - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUserAccountId, - nameof(PatronSupportEvent), - supportEvent.Id, - "patron-support.recorded", - $"Recorded {amount:0.##} {normalizedCurrency} {provider} {kind} patron support for {user.DisplayName}.", - cancellationToken); - - await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken); return supportEvent; } @@ -249,6 +278,9 @@ public sealed class PatronageService( } } +/// The account patron support would record on has no membership or is suspended. +public sealed class PatronSupportRefusedException() : InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account."); + public sealed record PatronPointSummary( decimal RecurringPoints, decimal HistoricalPoints, diff --git a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs index 34142ad..4dbcda7 100644 --- a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs +++ b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs @@ -5,7 +5,6 @@ using System.Text.Json.Serialization; using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; -using Bifrost.Web.Features.Membership; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -14,7 +13,6 @@ namespace Bifrost.Web.Features.Patronage; public sealed class StripeWebhookService( BifrostDbContext dbContext, PatronageService patronageService, - AccountMergeLedger mergeLedger, IOptions options) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -68,18 +66,7 @@ public sealed class StripeWebhookService( var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken); - // A checkout opened by an account that has since folded pays the survivor, as the Heimdall intake does: the same routing owner. - var target = patronageAccount is null - ? null - : await mergeLedger.SupportTargetAsync(patronageAccount.Id, cancellationToken); - if (patronageAccount is not null && target!.RecordOnAccountId != patronageAccount.Id) - { - patronageAccount = await dbContext.UserAccounts - .Include(x => x.Membership) - .SingleOrDefaultAsync(x => x.Id == target.RecordOnAccountId, cancellationToken); - } - - if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended) + if (patronageAccount is null) { return StripeWebhookResult.NotConfigured( "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); @@ -96,26 +83,31 @@ public sealed class StripeWebhookService( ? "USD" : session.Currency.ToUpperInvariant(); - var supportEvent = await patronageService.RecordSupportEventAsync( - actorUserAccountId: null, - patronageAccount.Id, - PatronSupportEventKind.OneTimeDonation, - amount, - currencyCode, - session.Id, - ExternalPatronProvider.Stripe, - stripeEvent.Id, - session.Customer, - string.Empty, - DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), - isCurrentRecurringSupport: false, - BuildNotes(session), - cancellationToken, - projectId.ProjectId); - - if (target!.MergeId is { } mergeId) + // A checkout opened by an account that has since folded pays the survivor: the write gate behind RecordSupportEventAsync routes it. + PatronSupportEvent supportEvent; + try { - await mergeLedger.JournalIntakeAsync(mergeId, target.ForAccountId, supportEvent.Id, cancellationToken); + supportEvent = await patronageService.RecordSupportEventAsync( + actorUserAccountId: null, + patronageAccount.Id, + PatronSupportEventKind.OneTimeDonation, + amount, + currencyCode, + session.Id, + ExternalPatronProvider.Stripe, + stripeEvent.Id, + session.Customer, + string.Empty, + DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), + isCurrentRecurringSupport: false, + BuildNotes(session), + cancellationToken, + projectId.ProjectId); + } + catch (PatronSupportRefusedException) + { + return StripeWebhookResult.NotConfigured( + "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); } return StripeWebhookResult.Processed("Stripe patronage support event recorded."); diff --git a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs index 7382c1e..e11b769 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -261,7 +261,63 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); Assert.Equal([s.Survivor], await EventOwnersAsync(s)); - Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(s.Folded, entry.PriorUserAccountId); // the journal names the folded account as the previous owner + + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await UndoAsync(merge); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); // so an undo sends the donation back where it was opened + } + + private Task UndoAsync(Guid merge) => + WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + + [Fact] + public async Task The_admin_ledgers_manual_patron_record_for_a_folded_account_lands_on_the_survivor_and_is_journalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().RecordSupportEventAsync( + s.Survivor, s.Folded, PatronSupportEventKind.OneTimeDonation, 10m, "USD", "manual-1", ExternalPatronProvider.Manual, + string.Empty, string.Empty, string.Empty, DateTimeOffset.UtcNow, false, string.Empty, default); // as Pages/App/Ledger records it + return 0; + }); + + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(s.Folded, entry.PriorUserAccountId); + } + + [Fact] + public async Task A_provider_retry_of_an_event_stranded_on_the_folded_account_journals_nothing_because_nothing_moved() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + await WithDb(async db => // recorded on the folded account by a delivery that straddled the commit + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = s.Folded, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1", + Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return 0; + }); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + + Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); } [Fact] diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index 0ec037d..95112c1 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; @@ -206,7 +207,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi await world.VoteAsync(motion, survivor, VoteChoice.For); await world.VoteAsync(motion, folded, VoteChoice.Against); var merge = await world.MergeAsync(survivor, folded); - var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous()); + var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world)); async Task TryCommit() { @@ -223,9 +224,8 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi var results = await Task.WhenAll(Task.Run(TryCommit), Task.Run(TryCommit)); - Assert.Equal(1, results.Count(x => x is null)); - // The loser is refused by the unique index, the token, or Postgres picking it as the deadlock victim. - Assert.All(results.OfType(), e => Assert.True(Chain(e).Any(x => x is DbUpdateException), e.ToString())); + // The second commit waits on the folded account's exclusive lock, then finds the merge landed and returns its receipt. + Assert.All(results, e => Assert.Null(e)); var journal = await world.JournalAsync(merge); Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); @@ -533,15 +533,147 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi var committed = (await world.JournalAsync(merge)).Count; Assert.True(committed > 0); - var events = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var events = new[] { $"WH-{Guid.NewGuid():N}", $"WH-{Guid.NewGuid():N}" }; foreach (var id in events.Concat(events)) // each twice: the provider retries { await using var db = world.Db(); - await world.Ledger(db).JournalIntakeAsync(merge.Id, folded.Id, id, default); + await new PatronageService(db, new AuditTrailService(db, world.Clock), world.Clock).RecordSupportEventAsync( + null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, id, + string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default); } var journal = await world.JournalAsync(merge); Assert.Equal(committed + 2, journal.Count); Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); } + + [PostgresTheory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.Final)] + [InlineData(AccountMergeState.UndoRequested)] + [InlineData(AccountMergeState.HeimdallReversed)] + [InlineData(AccountMergeState.Undone)] + [InlineData(AccountMergeState.Failed)] + public async Task Reverting_the_migration_is_refused_for_a_merge_in_any_state(AccountMergeState state) + { + var world = await postgres.NewWorldAsync(); + await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), state); + + await using var db = world.Db(); + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); + Assert.Contains("roll-forward only", Pg(error).MessageText); + } + + // ---- the write gate ---- + + [PostgresFact] + public async Task A_vote_that_read_no_merge_saves_before_a_merge_can_freeze_the_account_and_the_commit_then_moves_it() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + var actor = await world.ActorAsync(folded.Id); + Task? freeze = null; + var blocked = false; + var voters = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // The vote has read its standing (none) and is about to save: the freeze lands here and must wait for the save. + freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed)); + await Task.Delay(1500); + blocked = !freeze.IsCompleted; + })); + + await using (var db = voters.Db()) + { + await voters.Motions(db).CastVoteAsync(actor, motion.Id, VoteChoice.For, null, default); + } + + var merge = await freeze!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + await world.SetStateAsync(merge, AccountMergeState.HeimdallMoved); + await world.CommitAsync(merge); + + var votes = await world.AllVotesAsync(motion); + Assert.Equal(survivor.Id, Assert.Single(votes).UserAccountId); // the commit saw the vote and moved it: no second voice stays on the folded account + Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.Vote); + } + + [PostgresFact] + public async Task A_patron_event_that_read_a_Frozen_account_saves_before_the_commit_reduces_and_the_commit_moves_it() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded); // HeimdallMoved: the folded account is Frozen + Task? commit = null; + var blocked = false; + var recorders = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + commit = Task.Run(() => world.CommitAsync(merge)); // takes the folded account's exclusive lock before it reduces + await Task.Delay(1500); + blocked = !commit.IsCompleted; + })); + + await using (var db = recorders.Db()) + { + await new PatronageService(db, new AuditTrailService(db, recorders.Clock), recorders.Clock).RecordSupportEventAsync( + null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, "WH-frozen", + string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default); + } + + await commit!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + await using var read = world.Db(); + Assert.Equal(survivor.Id, (await read.PatronSupportEvents.SingleAsync()).UserAccountId); // the reduction found the event and moved it + Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + } + + [PostgresFact] + public async Task A_sealed_reveal_that_retries_on_a_conflict_keeps_its_lock_so_no_merge_freezes_the_account_between_attempts() + { + const string salt = "00112233445566778899aabbccddeeff"; + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var other = await world.AccountAsync("other"); + var moderator = await world.AccountAsync("moderator", moderator: true); + SealedBallotService Sealed(AccountMergeLedgerTests.World w, BifrostDbContext db) => new(db, new AuditTrailService(db, w.Clock), w.Clock); + SealedBallotDocument Doc(Guid motionId, string reason) => new(SealedBallotDocument.SchemaId, motionId, 1, "Ana", reason, salt); + + await using var setup = world.Db(); + var motionId = (await Sealed(world, setup).OpenAsync(await world.ActorAsync(moderator.Id), + new OpenSealedMotionCommand("Who", "Blind.", ["Ana", "Bo"], AccountMergeLedgerTests.T0.AddHours(1), AccountMergeLedgerTests.T0.AddHours(2)), default)).Id; + foreach (var (account, reason) in new[] { (folded, "mine"), (other, "theirs") }) + { + await using var db = world.Db(); + await Sealed(world, db).CommitAsync(await world.ActorAsync(account.Id), motionId, SealedBallotDocument.Commit(Doc(motionId, reason), account.Id), default); + } + + Task? freeze = null; + var blocked = false; + var revealers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // The first attempt has read its standing (none). A freeze starts and must wait; another member's reveal then lands first, + // so this attempt's save conflicts on RevealSeq and the reveal retries while the freeze still waits. + freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed)); + await using var rival = world.Db(); + world.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90); + await Sealed(world, rival).RevealAsync(await world.ActorAsync(other.Id), motionId, Doc(motionId, "theirs"), default); + await Task.Delay(1500); + blocked = !freeze.IsCompleted; + })); + revealers.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90); + var actor = await world.ActorAsync(folded.Id); + + await using (var db = revealers.Db()) + { + Assert.True((await Sealed(revealers, db).RevealAsync(actor, motionId, Doc(motionId, "mine"), default)).Revealed); + } + + await freeze!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + } } diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index f25b8c7..c619349 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -936,6 +936,54 @@ public sealed class HeimdallCompletionTests Assert.Equal(MembershipStatus.Active, (await host.Account(accountId))!.Membership!.Status); } + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_chat_tier_account_without_a_membership_that_a_merge_froze_or_folded_signs_in_without_getting_one(AccountMergeState state) + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), foldedId)).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: false), survivorId)).SignedIn); + await MergeAsync(host, survivorId, foldedId, state, dropFoldedMembership: true); // a chat-tier account holds none + + await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.Null((await host.Account(foldedId))!.Membership); + Assert.Equal(0, await host.Db(db => db.Memberships.CountAsync(x => x.UserAccount.HeimdallAccountId == foldedId))); + } + + [Fact] + public async Task A_frozen_accounts_sign_in_with_member_access_does_not_activate_its_membership() + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), foldedId)).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: false), survivorId)).SignedIn); + await MergeAsync(host, survivorId, foldedId, AccountMergeState.HeimdallMoved, dropFoldedMembership: false); + + await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.Equal(MembershipStatus.Authenticated, (await host.Account(foldedId))!.Membership!.Status); + } + + private static Task MergeAsync(HeimdallHost host, string survivorId, string foldedId, AccountMergeState state, bool dropFoldedMembership) => + host.Db(async db => + { + if (dropFoldedMembership) + { + db.Memberships.RemoveRange(db.Memberships.Where(x => x.UserAccount.HeimdallAccountId == foldedId)); + } + + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == foldedId); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorId); + db.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = state, CreatedUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + return 0; + }); + [Fact] public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member() { -- 2.47.3 From c62166c6ceb9fe1c336bf4a779f2895ba398080a Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 06:32:44 +0000 Subject: [PATCH 15/20] Account merge ledger: pin the Stripe refusal for a folded account whose survivor is suspended Co-Authored-By: Claude Sonnet 5.5 --- .../AccountMergeHttpTests.cs | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs index e11b769..e148132 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -244,6 +244,20 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I { var s = await SeedAsync(); var merge = await CommitMergeAsync(s); + using var response = await StripeCheckoutAsync(s); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(s.Folded, entry.PriorUserAccountId); // the journal names the folded account as the previous owner + + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await UndoAsync(merge); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); // so an undo sends the donation back where it was opened + } + + private async Task StripeCheckoutAsync(Seeded s) + { var payload = JsonSerializer.Serialize(new { id = $"evt_{Guid.NewGuid():N}", type = "checkout.session.completed", created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), @@ -257,16 +271,7 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); using var client = factory.CreateClient(); - using var response = await client.SendAsync(request); - - Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); - Assert.Equal([s.Survivor], await EventOwnersAsync(s)); - var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); - Assert.Equal(s.Folded, entry.PriorUserAccountId); // the journal names the folded account as the previous owner - - await SetStateAsync(merge, AccountMergeState.HeimdallReversed); - await UndoAsync(merge); - Assert.Equal([s.Folded], await EventOwnersAsync(s)); // so an undo sends the donation back where it was opened + return await client.SendAsync(request); } private Task UndoAsync(Guid merge) => @@ -277,6 +282,24 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I return 0; }); + [Fact] + public async Task A_stripe_checkout_opened_by_an_account_that_folded_into_a_suspended_survivor_is_refused_as_not_configured_and_records_nothing() + { + var s = await SeedAsync(); + await CommitMergeAsync(s); + await WithDb(async db => + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Survivor)).Status = MembershipStatus.Suspended; + await db.SaveChangesAsync(); + return 0; + }); + + using var response = await StripeCheckoutAsync(s); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Empty(await EventOwnersAsync(s)); + } + [Fact] public async Task The_admin_ledgers_manual_patron_record_for_a_folded_account_lands_on_the_survivor_and_is_journalled() { -- 2.47.3 From cdf64b2a10c3427ee11409a0ddb63dcf022fde51 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 06:48:43 +0000 Subject: [PATCH 16/20] Account merge saga: Confirm with consent record and sealed proofs, one crash-safe step function for the Heimdall move and reversal, one merge loop AccountMergeSaga is the only caller of the move client and the only writer of Confirmed, HeimdallMoved, Failed, UndoRequested and HeimdallReversed; the ledger keeps Committed, Final and Undone. Migration AddAccountMergeSaga adds the saga columns and the two move-id keys; its Down is roll-forward only. AccountMergeSweeper becomes the one loop: saga advance, then finalize, every 30 s. Co-Authored-By: Claude Sonnet 5.5 --- src/Bifrost.Web/Data/BifrostDbContext.cs | 11 + .../20261114130000_AddAccountMergeSaga.cs | 78 +++ .../BifrostDbContextModelSnapshot.cs | 68 +++ src/Bifrost.Web/Domain/AccountMergeModels.cs | 42 ++ .../Heimdall/HeimdallIdentityMoveClient.cs | 2 +- .../Features/Membership/AccountMergeLedger.cs | 54 +- .../Features/Membership/AccountMergeSaga.cs | 475 ++++++++++++++++++ src/Bifrost.Web/Program.cs | 1 + 8 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs create mode 100644 src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index 544d770..7b0a20d 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -635,6 +635,17 @@ public sealed class BifrostDbContext(DbContextOptions options) // State is the token: a transition computed from a stale read (the sweeper's Final over a saga's UndoRequested) fails its save. entity.Property(x => x.State).HasConversion().HasMaxLength(24).IsConcurrencyToken(); entity.Property(x => x.FailureCode).HasMaxLength(64); + entity.Property(x => x.HeimdallMoveId).HasMaxLength(64); + entity.Property(x => x.HeimdallReverseMoveId).HasMaxLength(64); + entity.Property(x => x.PreviewDigest).HasMaxLength(64); + entity.Property(x => x.SurvivorProofProvider).HasMaxLength(32); + entity.Property(x => x.FoldedProofProviders).HasMaxLength(120); + entity.Property(x => x.ProofFromTier).HasMaxLength(8); + entity.Property(x => x.ProofToTier).HasMaxLength(8); + entity.Property(x => x.LastOutcome).HasMaxLength(64); + // Heimdall's idempotency keys: one merge, one forward key and one reverse key, and no key shared by two merges. + entity.HasIndex(x => x.HeimdallMoveId).IsUnique().HasFilter("\"HeimdallMoveId\" IS NOT NULL"); + entity.HasIndex(x => x.HeimdallReverseMoveId).IsUnique().HasFilter("\"HeimdallReverseMoveId\" IS NOT NULL"); entity.HasIndex(x => new { x.FoldedUserAccountId, x.State }); // An account is folded once: the DB refuses a second live merge folding it. The cross-role case (a survivor that is also // folded) cannot be one unique index; the migration's trigger refuses it. diff --git a/src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs b/src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs new file mode 100644 index 0000000..cb0b5fb --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs @@ -0,0 +1,78 @@ +using System; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddAccountMerges, the snapshot edited to match. Additive: the saga's columns on AccountMerges. Down is + // roll-forward only, like AddAccountMerges: it drops the Heimdall idempotency keys and the consent record, so it refuses while + // any merge row exists (heimdall-body:ruling:merge-roll-forward-only). + [DbContext(typeof(BifrostDbContext))] + [Migration("20261114130000_AddAccountMergeSaga")] + public partial class AddAccountMergeSaga : Migration + { + private static readonly string[] Columns = + [ + "HeimdallMoveId", "HeimdallReverseMoveId", "SealedProofs", "PreviewDigest", "ConfirmedByUserAccountId", "ConfirmedUtc", + "SurvivorProofProvider", "FoldedProofProviders", "ProofFromTier", "ProofToTier", "MovedIdentityIds", "Attempts", + "NextAttemptUtc", "LastOutcome", "UndoRequestedUtc", "ReversedUtc" + ]; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "HeimdallMoveId", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: true); + migrationBuilder.AddColumn(name: "HeimdallReverseMoveId", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: true); + migrationBuilder.AddColumn(name: "SealedProofs", table: "AccountMerges", type: "text", nullable: true); + migrationBuilder.AddColumn(name: "PreviewDigest", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "ConfirmedByUserAccountId", table: "AccountMerges", type: "uuid", nullable: true); + migrationBuilder.AddColumn(name: "ConfirmedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true); + migrationBuilder.AddColumn(name: "SurvivorProofProvider", table: "AccountMerges", type: "character varying(32)", maxLength: 32, nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "FoldedProofProviders", table: "AccountMerges", type: "character varying(120)", maxLength: 120, nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "ProofFromTier", table: "AccountMerges", type: "character varying(8)", maxLength: 8, nullable: true); + migrationBuilder.AddColumn(name: "ProofToTier", table: "AccountMerges", type: "character varying(8)", maxLength: 8, nullable: true); + migrationBuilder.AddColumn(name: "MovedIdentityIds", table: "AccountMerges", type: "text", nullable: true); + migrationBuilder.AddColumn(name: "Attempts", table: "AccountMerges", type: "integer", nullable: false, defaultValue: 0); + migrationBuilder.AddColumn(name: "NextAttemptUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true); + migrationBuilder.AddColumn(name: "LastOutcome", table: "AccountMerges", type: "character varying(64)", maxLength: 64, nullable: false, defaultValue: ""); + migrationBuilder.AddColumn(name: "UndoRequestedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true); + migrationBuilder.AddColumn(name: "ReversedUtc", table: "AccountMerges", type: "timestamp with time zone", nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_AccountMerges_HeimdallMoveId", + table: "AccountMerges", + column: "HeimdallMoveId", + unique: true, + filter: "\"HeimdallMoveId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AccountMerges_HeimdallReverseMoveId", + table: "AccountMerges", + column: "HeimdallReverseMoveId", + unique: true, + filter: "\"HeimdallReverseMoveId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM ""AccountMerges"") THEN + RAISE EXCEPTION 'AddAccountMergeSaga cannot be reverted once an account merge exists: Bifrost is roll-forward only'; + END IF; +END +$$;"); + + migrationBuilder.DropIndex(name: "IX_AccountMerges_HeimdallMoveId", table: "AccountMerges"); + migrationBuilder.DropIndex(name: "IX_AccountMerges_HeimdallReverseMoveId", table: "AccountMerges"); + foreach (var column in Columns) + { + migrationBuilder.DropColumn(name: column, table: "AccountMerges"); + } + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 3966dd3..2d1d304 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -28,9 +28,18 @@ namespace Bifrost.Web.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Attempts") + .HasColumnType("integer"); + b.Property("CommittedUtc") .HasColumnType("timestamp with time zone"); + b.Property("ConfirmedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("ConfirmedUtc") + .HasColumnType("timestamp with time zone"); + b.Property("CreatedUtc") .HasColumnType("timestamp with time zone"); @@ -45,24 +54,75 @@ namespace Bifrost.Web.Data.Migrations b.Property("FinalizedUtc") .HasColumnType("timestamp with time zone"); + b.Property("FoldedProofProviders") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.Property("FoldedUserAccountId") .HasColumnType("uuid"); b.Property("GraceEndsUtc") .HasColumnType("timestamp with time zone"); + b.Property("HeimdallMoveId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HeimdallReverseMoveId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastOutcome") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MovedIdentityIds") + .HasColumnType("text"); + + b.Property("NextAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreviewDigest") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProofFromTier") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("ProofToTier") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("ReversedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SealedProofs") + .HasColumnType("text"); + b.Property("State") .IsConcurrencyToken() .IsRequired() .HasMaxLength(24) .HasColumnType("character varying(24)"); + b.Property("SurvivorProofProvider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + b.Property("SurvivorUserAccountId") .HasColumnType("uuid"); b.Property("UndoneUtc") .HasColumnType("timestamp with time zone"); + b.Property("UndoRequestedUtc") + .HasColumnType("timestamp with time zone"); + b.HasKey("Id"); b.HasIndex("FoldedUserAccountId") @@ -72,6 +132,14 @@ namespace Bifrost.Web.Data.Migrations b.HasIndex("FoldedUserAccountId", "State"); + b.HasIndex("HeimdallMoveId") + .IsUnique() + .HasFilter("\"HeimdallMoveId\" IS NOT NULL"); + + b.HasIndex("HeimdallReverseMoveId") + .IsUnique() + .HasFilter("\"HeimdallReverseMoveId\" IS NOT NULL"); + b.HasIndex("State", "GraceEndsUtc"); b.HasIndex("SurvivorUserAccountId", "State"); diff --git a/src/Bifrost.Web/Domain/AccountMergeModels.cs b/src/Bifrost.Web/Domain/AccountMergeModels.cs index 619d45b..cadcac2 100644 --- a/src/Bifrost.Web/Domain/AccountMergeModels.cs +++ b/src/Bifrost.Web/Domain/AccountMergeModels.cs @@ -62,6 +62,48 @@ public sealed class AccountMerge public string FailureCode { get; set; } = string.Empty; + // ---- the saga's columns (AccountMergeSaga is their one writer; docs/account-merge.md, Saga) ---- + + /// Heimdall's idempotency key of the forward move: random, minted in the save that enters Confirmed, never derived from , never re-minted. + public string? HeimdallMoveId { get; set; } + + /// The same for the reversal, minted in the save that enters UndoRequested. + public string? HeimdallReverseMoveId { get; set; } + + /// The Heimdall bearers the move or reversal will replay, protected; non-null only from Confirmed/UndoRequested until Heimdall's final answer. + public string? SealedProofs { get; set; } + + /// Digest of the plan the member saw when confirming (MergePlan.Digest); with the next four columns it is the consent record. + public string PreviewDigest { get; set; } = string.Empty; + + public Guid? ConfirmedByUserAccountId { get; set; } + + public DateTimeOffset? ConfirmedUtc { get; set; } + + public string SurvivorProofProvider { get; set; } = string.Empty; + + /// The providers of the folded side's proofs, sorted and comma-joined. + public string FoldedProofProviders { get; set; } = string.Empty; + + /// The proof tiers Heimdall's receipt named (weak or strong); ProofToTier is null for a receipt without one. + public string? ProofFromTier { get; set; } + + public string? ProofToTier { get; set; } + + /// The logins the forward move carried, a JSON array of Heimdall identity ids, from the receipt. + public string? MovedIdentityIds { get; set; } + + public int Attempts { get; set; } + + public DateTimeOffset? NextAttemptUtc { get; set; } + + /// The last Heimdall answer's class or refusal code, or the saga's own stall code; never an input value. + public string LastOutcome { get; set; } = string.Empty; + + public DateTimeOffset? UndoRequestedUtc { get; set; } + + public DateTimeOffset? ReversedUtc { get; set; } + public ICollection Entries { get; set; } = []; } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs index f285ae2..af61e4e 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -87,7 +87,7 @@ public sealed partial class HeimdallIdentityMoveClient( private const int MaxMovedIdentities = 256; private const int MaxIdentityIdLength = 128; private const int MaxReceiptBytes = 64 * 1024; - private const int MaxProofLength = 8192; + public const int MaxProofLength = 8192; // \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")] diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs index c84d6f8..b242101 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -26,7 +26,46 @@ public sealed record MergePlanEntry( public sealed record MergePlan( IReadOnlyList Blocks, IReadOnlyList Entries, - IReadOnlyDictionary Left); + IReadOnlyDictionary Left) +{ + /// + /// Lowercase hex SHA-256 over a canonical text of the plan, in plan order (Left by key): what the member saw, comparable at confirm. + /// Every field is length-prefixed, so no value can imitate a separator. The plan holds no account ids of its own, so the caller + /// names the pair the plan was made for. + /// + public string Digest(Guid survivorId, Guid foldedId) + { + var text = new System.Text.StringBuilder(); + void Field(string? value) => text.Append(value is null ? "-" : $"{value.Length}:{value}").Append('|'); + Field(survivorId.ToString("N")); + Field(foldedId.ToString("N")); + foreach (var entry in Entries) + { + Field(entry.Kind.ToString()); + Field(entry.Action.ToString()); + Field(entry.RecordId.ToString("N")); + Field(entry.PriorUserAccountId?.ToString("N")); + Field(entry.PriorText); + Field(entry.PriorAtUtc?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + text.Append('\n'); + } + + text.Append("left\n"); + foreach (var (key, count) in Left.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + Field(key); + Field(count.ToString(CultureInfo.InvariantCulture)); + } + + text.Append("\nblocks\n"); + foreach (var block in Blocks) + { + Field(block); + } + + return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant(); + } +} /// /// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed @@ -696,18 +735,25 @@ public sealed class AccountMergeLedger( } } -/// Finalizes merges past their grace every five minutes. Copies the SealedBallotCloser shape: a new scope per tick, a warning on failure. +/// +/// The one merge loop, every 30 seconds: the saga advances every due in-flight merge (confirm, replay, undo), then the ledger finalizes +/// merges past their grace. A new scope per tick, a warning on failure, the SealedBallotCloser shape. No second merge hosted service exists. +/// public sealed class AccountMergeSweeper(IServiceScopeFactory scopes, TimeProvider clock, ILogger logger) : BackgroundService { + public static readonly TimeSpan Tick = TimeSpan.FromSeconds(30); + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + using var timer = new PeriodicTimer(Tick); do { try { using var scope = scopes.CreateScope(); - await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(clock.GetUtcNow(), stoppingToken); + var now = clock.GetUtcNow(); + await scope.ServiceProvider.GetRequiredService().AdvanceDueAsync(now, stoppingToken); + await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(now, stoppingToken); } catch (Exception error) when (error is not OperationCanceledException) { diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs new file mode 100644 index 0000000..8281e9e --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs @@ -0,0 +1,475 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Bifrost.Web.Features.Membership; + +/// +/// A live Heimdall bearer a member redeemed from a fresh sign-in, with the Heimdall account and provider it came through. The token is a +/// secret: never prints it (nor the account id), so no log or exception that holds a proof can leak it. +/// +public sealed record MergeProof(string AccessToken, string HeimdallAccountId, string Provider) +{ + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"Provider = {Provider}"); + return true; + } +} + +/// What the member confirmed: the pair, the plan digest they saw, and a proof for every login of each side that must speak. +public sealed record ConfirmMergeRequest( + Guid ActorUserAccountId, + Guid SurvivorUserAccountId, + Guid FoldedUserAccountId, + string PreviewDigest, + MergeProof SurvivorProof, + IReadOnlyList FoldedProofs); + +/// The closed answer to a confirm. Only Confirmed wrote a row. +public abstract record ConfirmOutcome +{ + private ConfirmOutcome() + { + } + + public sealed record Confirmed(Guid MergeId) : ConfirmOutcome; + + /// The plan's block codes (never account names), or merge_in_flight when the database refused an overlapping merge. + public sealed record Blocked(IReadOnlyList Codes) : ConfirmOutcome; + + public sealed record PreviewStale : ConfirmOutcome; + + public sealed record NotParty : ConfirmOutcome; + + public sealed record ProofAccountMismatch : ConfirmOutcome; +} + +/// The closed answer to an undo request. Only Requested wrote. +public abstract record UndoOutcome +{ + private UndoOutcome() + { + } + + public sealed record Requested : UndoOutcome; + + public sealed record NotCommitted : UndoOutcome; + + public sealed record GraceOver : UndoOutcome; + + public sealed record NotSurvivor : UndoOutcome; + + public sealed record ProofAccountMismatch : UndoOutcome; +} + +/// +/// The one owner of an account merge's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of +/// . It inserts Confirmed (the consent record, with the Heimdall bearers sealed and the move +/// id minted), runs the one idempotent step function (move, commit, reverse, undo), and writes +/// HeimdallMoved, Failed, UndoRequested and HeimdallReversed. It never writes votes, memberships or journal rows (the ledger's), never +/// judges a proof's tier or freshness (Heimdall's), and reads no standing of its own: the plan's blocks and the database's live-merge +/// index answer overlap, and the writers that act for an account go through . +/// Every transition is one save guarded by State, the merge's concurrency token; the loser of a race reloads and continues from the +/// stored state. Heimdall's closed outcome is consumed as is: Unknown (a lost answer, an outage) is replayed with the same stored +/// move id and never fails a merge. Logs name the merge id, state, outcome class and refusal code only. +/// +public sealed partial class AccountMergeSaga( + BifrostDbContext dbContext, + AccountMergeLedger ledger, + HeimdallIdentityMoveClient mover, + IDataProtectionProvider dataProtection, + TimeProvider timeProvider, + ILogger logger) +{ + public const string MergeInFlight = "merge_in_flight"; + public const string ReceiptMismatch = "receipt_mismatch"; + public const string ProofsUnreadable = "proofs_unreadable"; + + private const string LiveMergeIndex = "UX_AccountMerges_FoldedUserAccountId_Live"; + private const int MaxProofs = 8; + + private static readonly TimeSpan[] Backoff = [TimeSpan.FromSeconds(15), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15)]; + private static readonly TimeSpan UnreadableRetry = TimeSpan.FromHours(1); + + private static readonly AccountMergeState[] DueStates = + [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1"); + + private sealed record ProofSeal(List From, string? To); + + // ---- confirm ---- + + /// + /// The member's consent, as one insert. The actor must be one of the pair; the plan must have no blocks and be the one the member + /// saw (digest); every proof must be of its side's Heimdall account. Calls no Heimdall: the caller runs . + /// + public async Task ConfirmAsync(ConfirmMergeRequest request, CancellationToken cancellationToken) + { + RequireProofs(request.SurvivorProof, request.FoldedProofs); + if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId) + { + return new ConfirmOutcome.NotParty(); + } + + try + { + return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken); + } + catch (DbUpdateException error) when (IsLiveMergeViolation(error)) + { + dbContext.ChangeTracker.Clear(); + return new ConfirmOutcome.Blocked([MergeInFlight]); + } + } + + private async Task InsertConfirmedAsync(ConfirmMergeRequest request, CancellationToken cancellationToken) + { + var plan = await ledger.PlanAsync(request.SurvivorUserAccountId, request.FoldedUserAccountId, cancellationToken); + if (plan.Blocks.Count > 0) + { + return new ConfirmOutcome.Blocked(plan.Blocks); + } + + if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal)) + { + return new ConfirmOutcome.PreviewStale(); + } + + var survivorHeimdall = await HeimdallAccountAsync(request.SurvivorUserAccountId, cancellationToken); + var foldedHeimdall = await HeimdallAccountAsync(request.FoldedUserAccountId, cancellationToken); + if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall)) + { + return new ConfirmOutcome.ProofAccountMismatch(); + } + + var now = timeProvider.GetUtcNow(); + var merge = new AccountMerge + { + State = AccountMergeState.Confirmed, + SurvivorUserAccountId = request.SurvivorUserAccountId, + FoldedUserAccountId = request.FoldedUserAccountId, + CreatedUtc = now, + ConfirmedByUserAccountId = request.ActorUserAccountId, + ConfirmedUtc = now, + PreviewDigest = request.PreviewDigest, + SurvivorProofProvider = request.SurvivorProof.Provider, + FoldedProofProviders = string.Join(',', request.FoldedProofs.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)), + // Heimdall's idempotency key: random, minted once here, read back from the row on every call, never derived from the merge id. + HeimdallMoveId = Guid.NewGuid().ToString("N"), + SealedProofs = Seal(request.FoldedProofs.Select(x => x.AccessToken), request.SurvivorProof.AccessToken), + LastOutcome = "confirmed" + }; + dbContext.AccountMerges.Add(merge); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State); + return new ConfirmOutcome.Confirmed(merge.Id); + } + + // ---- undo request ---- + + /// + /// The survivor's request to give the moved logins back, inside the grace and with fresh survivor proofs. One save: UndoRequested, a + /// freshly minted reverse move id, the sealed proofs. Losing the race to the Final sweep is the State token's, answered GraceOver. + /// + public async Task RequestUndoAsync( + Guid mergeId, Guid actorUserAccountId, IReadOnlyList survivorProofs, CancellationToken cancellationToken) + { + RequireProofs(null, survivorProofs); + try + { + return await AccountMergeLedger.WriteAsAsync(dbContext, actorUserAccountId, _ => WriteUndoRequestedAsync(mergeId, actorUserAccountId, survivorProofs, cancellationToken), cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + dbContext.ChangeTracker.Clear(); + return new UndoOutcome.GraceOver(); + } + } + + private async Task WriteUndoRequestedAsync( + Guid mergeId, Guid actorUserAccountId, IReadOnlyList survivorProofs, CancellationToken cancellationToken) + { + var merge = await LoadAsync(mergeId, cancellationToken); + if (merge is null) + { + return new UndoOutcome.NotCommitted(); + } + + var now = timeProvider.GetUtcNow(); + if (actorUserAccountId != merge.SurvivorUserAccountId) + { + return new UndoOutcome.NotSurvivor(); + } + + if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null) + { + return new UndoOutcome.GraceOver(); + } + + if (merge.State != AccountMergeState.Committed) + { + return new UndoOutcome.NotCommitted(); + } + + if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds) + { + return new UndoOutcome.GraceOver(); + } + + var survivorHeimdall = await HeimdallAccountAsync(merge.SurvivorUserAccountId, cancellationToken); + if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall)) + { + return new UndoOutcome.ProofAccountMismatch(); + } + + merge.State = AccountMergeState.UndoRequested; + merge.UndoRequestedUtc = now; + merge.HeimdallReverseMoveId = Guid.NewGuid().ToString("N"); + merge.SealedProofs = Seal(survivorProofs.Select(x => x.AccessToken), null); + merge.Attempts = 0; + merge.NextAttemptUtc = null; + merge.LastOutcome = "undo_requested"; + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State); + return new UndoOutcome.Requested(); + } + + // ---- the step function ---- + + /// + /// The one step function: confirm's inline advance, the sweeper's resume after a crash and an undo request all run it. From the + /// stored state it makes the Heimdall move (or reversal) under the stored id, records the answer, and calls the ledger's commit (or + /// local undo). Idempotent: every state has one next step and every step is safe to repeat. Other states are a no-op. + /// + public async Task AdvanceAsync(Guid mergeId, CancellationToken cancellationToken) + { + for (var lost = 0; ; lost++) + { + try + { + return await AdvanceOnceAsync(mergeId, cancellationToken); + } + catch (DbUpdateConcurrencyException) when (lost < 2) + { + // Another advancer saved first: forget what this context staged and continue from the stored state. + dbContext.ChangeTracker.Clear(); + } + } + } + + private async Task AdvanceOnceAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await LoadAsync(mergeId, cancellationToken) ?? throw new InvalidOperationException($"Merge {mergeId} does not exist."); + if (merge.State is AccountMergeState.Confirmed or AccountMergeState.UndoRequested) + { + merge = await MoveAsync(merge, cancellationToken); + } + + return merge.State switch + { + AccountMergeState.HeimdallMoved => await ledger.CommitAsync(merge.Id, cancellationToken), + AccountMergeState.HeimdallReversed => await ledger.UndoLocalAsync(merge.Id, cancellationToken), + _ => merge + }; + } + + private async Task MoveAsync(AccountMerge merge, CancellationToken cancellationToken) + { + if (merge.LastOutcome == ReceiptMismatch) + { + return merge; // Heimdall answered, wrongly, and its proofs are wiped: replay can only repeat the same answer. + } + + var forward = merge.State == AccountMergeState.Confirmed; + var now = timeProvider.GetUtcNow(); + if (Open(merge.SealedProofs) is not { } proofs) + { + // The key ring is lost or rotated out. Heimdall may already have moved, so nothing changes but the retry time. + merge.LastOutcome = ProofsUnreadable; + merge.NextAttemptUtc = now + UnreadableRetry; + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogError("Account merge {MergeId} {State}: sealed proofs unreadable", merge.Id, merge.State); + return merge; + } + + var request = forward + ? new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, proofs.To) + : new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId); + var outcome = await mover.MoveAsync(request, cancellationToken); + var code = outcome is HeimdallMoveOutcome.Refused refused ? RefusalCode(refused.Code) : null; + switch (outcome) + { + case HeimdallMoveOutcome.Moved moved: + // The leg's destination is the survivor going forward and the folded account coming back. + var destination = await HeimdallAccountAsync(forward ? merge.SurvivorUserAccountId : merge.FoldedUserAccountId, cancellationToken); + merge.SealedProofs = null; + if (moved.Receipt.AccountId != destination) + { + merge.LastOutcome = ReceiptMismatch; + break; + } + + merge.Attempts = 0; + merge.NextAttemptUtc = null; + merge.LastOutcome = "moved"; + if (forward) + { + merge.State = AccountMergeState.HeimdallMoved; + merge.ProofFromTier = Tier(moved.Receipt.ProofFrom); + merge.ProofToTier = moved.Receipt.ProofTo is { } to ? Tier(to) : null; + merge.MovedIdentityIds = JsonSerializer.Serialize(moved.Receipt.MovedIdentityIds); + } + else + { + merge.State = AccountMergeState.HeimdallReversed; + merge.ReversedUtc = now; + } + + break; + case HeimdallMoveOutcome.Refused: + // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was. + merge.SealedProofs = null; + merge.NextAttemptUtc = null; + if (forward) + { + merge.State = AccountMergeState.Failed; + merge.FailedUtc = now; + merge.FailureCode = code!; + merge.LastOutcome = "refused"; + } + else + { + merge.State = AccountMergeState.Committed; + merge.LastOutcome = "undo_" + code; + } + + break; + case HeimdallMoveOutcome.Unknown unknown: + merge.Attempts++; + merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1]; + merge.LastOutcome = unknown.Reason.Length > 64 ? unknown.Reason[..64] : unknown.Reason; + break; + } + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code); + return merge; + } + + /// Advances every in-flight merge that is due, oldest first. One that throws is logged and skipped; returns how many were advanced. + public async Task AdvanceDueAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var due = await dbContext.AccountMerges.AsNoTracking() + .Where(x => DueStates.Contains(x.State) && (x.NextAttemptUtc == null || x.NextAttemptUtc <= now)) + .OrderBy(x => x.CreatedUtc) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + var advanced = 0; + foreach (var id in due) + { + try + { + await AdvanceAsync(id, cancellationToken); + advanced++; + } + catch (Exception error) when (error is not OperationCanceledException) + { + dbContext.ChangeTracker.Clear(); + logger.LogWarning("Account merge {MergeId} advance failed: {Error}", id, error.GetType().Name); + } + } + + return advanced; + } + + // ---- helpers ---- + + /// The merge as stored now: a row this context already tracks is reloaded, never trusted. + private async Task LoadAsync(Guid mergeId, CancellationToken cancellationToken) + { + var tracked = dbContext.AccountMerges.Local.FirstOrDefault(x => x.Id == mergeId); + if (tracked is not null) + { + await dbContext.Entry(tracked).ReloadAsync(cancellationToken); + return dbContext.Entry(tracked).State == EntityState.Detached ? null : tracked; + } + + return await dbContext.AccountMerges.SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken); + } + + private Task HeimdallAccountAsync(Guid userAccountId, CancellationToken cancellationToken) => + dbContext.UserAccounts.AsNoTracking().Where(x => x.Id == userAccountId).Select(x => x.HeimdallAccountId).SingleAsync(cancellationToken); + + /// Shapes Heimdall would 400 are refused before a row exists, so a confirm can never store a merge that cannot call. Messages name no value. + private static void RequireProofs(MergeProof? one, IReadOnlyList many) + { + if (many is not { Count: >= 1 and <= MaxProofs }) + { + throw new ArgumentException("1 to 8 proofs are required", nameof(many)); + } + + foreach (var proof in one is null ? many : [one, .. many]) + { + if (string.IsNullOrEmpty(proof.AccessToken) || proof.AccessToken.Length > HeimdallIdentityMoveClient.MaxProofLength + || string.IsNullOrEmpty(proof.HeimdallAccountId) || string.IsNullOrEmpty(proof.Provider) || proof.Provider.Length > 32) + { + throw new ArgumentException("a proof is malformed", nameof(many)); + } + } + + if (many.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Sum(x => x.Length + 1) > 121) + { + throw new ArgumentException("the proofs' providers do not fit the record", nameof(many)); + } + } + + private string Seal(IEnumerable from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to))); + + private ProofSeal? Open(string? sealedProofs) + { + if (sealedProofs is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(_seal.Unprotect(sealedProofs)) is { From.Count: > 0 } proofs ? proofs : null; + } + catch (Exception error) when (error is CryptographicException or JsonException) + { + return null; + } + } + + private static string Tier(HeimdallProofTier tier) => tier == HeimdallProofTier.Strong ? "strong" : "weak"; + + private static string RefusalCode(HeimdallMoveRefusal code) => + code == HeimdallMoveRefusal.RequestInvalid ? "invalid_request" : WordBoundary().Replace(code.ToString(), "_$1").ToLowerInvariant(); + + [GeneratedRegex("(?The database's refusal of an overlapping merge: the live-merge unique index or the chain trigger's exclusion violation. + private static bool IsLiveMergeViolation(DbUpdateException error) + { + for (var current = (Exception?)error; current is not null; current = current.InnerException) + { + if (current is PostgresException { SqlState: "23P01" } or PostgresException { SqlState: "23505", ConstraintName: LiveMergeIndex }) + { + return true; + } + } + + return false; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 25b91ac..2985fe2 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -140,6 +140,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); -- 2.47.3 From 41f4cc385396c36dd38f62750647a9f726f8b945 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 06:48:43 +0000 Subject: [PATCH 17/20] Account merge saga: consumer tests through the real host and the fake mover, live-Postgres races, docs Co-Authored-By: Claude Sonnet 5.5 --- docs/account-merge.md | 28 +- .../AccountMergePostgresTests.cs | 161 ++++- .../AccountMergeSagaTests.cs | 677 ++++++++++++++++++ .../MigrationDiscoveryTests.cs | 1 + tests/Bifrost.Web.Tests/Support/FakeMoves.cs | 9 + .../Bifrost.Web.Tests/Support/FaultOnSave.cs | 16 + .../Support/HeimdallTestKit.cs | 5 + .../Support/TestWebApplicationFactory.cs | 5 +- 8 files changed, 897 insertions(+), 5 deletions(-) create mode 100644 tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs create mode 100644 tests/Bifrost.Web.Tests/Support/FaultOnSave.cs diff --git a/docs/account-merge.md b/docs/account-merge.md index cbb0460..9c9547c 100644 --- a/docs/account-merge.md +++ b/docs/account-merge.md @@ -1,6 +1,6 @@ # Account merge ledger -What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. This is the ledger only: no page, no Heimdall call, and nothing calls `CommitAsync` or `UndoLocalAsync` yet (the saga cut does). +What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. The ledger owns what a merge does to Bifrost records; the saga (below) owns the merge's lifecycle around it and every Heimdall call. No page calls the saga yet (cuts D1 and D2), so no merge can be created in production by this code alone. ## Owner @@ -42,7 +42,31 @@ Left untouched (counted by the plan): consent receipts, passkeys, ledger entries `UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to. -`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every five minutes. `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final. +`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every 30 seconds, after the saga's due steps (see Saga). `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final. + +## Saga + +`Features/Membership/AccountMergeSaga.cs` is the one owner of the merge row's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of `HeimdallIdentityMoveClient.MoveAsync` (whose closed `Moved`/`Refused`/`Unknown` answer it consumes as is; the client retries nothing). It writes Confirmed, HeimdallMoved, Failed, UndoRequested and HeimdallReversed, the return UndoRequested to Committed, the move ids and the sealed proofs; it never writes a vote, membership or journal row (it calls `CommitAsync` and `UndoLocalAsync`), reads no standing of its own, and never judges a proof's tier or freshness (Heimdall's). + +| State | Written by | Key and proofs | A crash here | +| --- | --- | --- | --- | +| Confirmed | `ConfirmAsync`: one insert, the consent record | `HeimdallMoveId` minted (random, never derived from the merge id); both sides' Heimdall bearers sealed | Before the insert nothing exists. After it the sweep calls Heimdall with the stored id; a lost answer replays to the same receipt, because Heimdall answers a replay before it judges liveness. | +| HeimdallMoved | `AdvanceAsync` on `Moved`, one save | proofs wiped; receipt tiers and moved login ids stored | `CommitAsync` is total and idempotent; the sweep retries it. | +| Failed | `AdvanceAsync` on any `Refused` | proofs wiped; `FailureCode` is Heimdall's code | Terminal. Heimdall applied nothing, Bifrost changed nothing, the folded account stands as before. | +| Committed | ledger `CommitAsync`, one save with every reduction | grace 7 days | A crash before the save leaves HeimdallMoved. | +| UndoRequested | `RequestUndoAsync`: the survivor, fresh survivor proofs, inside the grace | `HeimdallReverseMoveId` minted; survivor bearers sealed | The sweep replays the reversal (past the grace too). A refused reversal returns the merge to Committed, proofs wiped, `LastOutcome` `undo_`. | +| HeimdallReversed | `AdvanceAsync` on the reversal's `Moved` | proofs wiped | `UndoLocalAsync` is retried. | +| Undone, Final | ledger | | | + +`AdvanceAsync(mergeId)` is the one step function: the confirm's inline advance, the sweeper's resume and an undo request all run it, forward move and reversal alike (same seal, same outcome handling, same backoff). Every transition is one save guarded by `State`, the concurrency token; the loser of a race clears its tracker, reloads and continues from the stored state, so two advances make one Heimdall move and one commit. `Proposed` is declared by the ledger and written by nobody in this cut. + +`ConfirmAsync` refuses (writing nothing) a third-party actor (`NotParty`), a plan with blocks (`Blocked`, codes only), a plan other than the one the member saw (`PreviewStale`: `MergePlan.Digest(survivorId, foldedId)`, a SHA-256 over a length-prefixed canonical text of the plan), and any proof of another Heimdall account (`ProofAccountMismatch`). A second live merge folding the same account, or a chain, is refused by the database (the live-merge unique index and the chain trigger) and answered `Blocked(merge_in_flight)`. Confirm and the undo request run through `AccountMergeLedger.WriteAsAsync` for the acting member, like every writer that acts for an account. + +**Heimdall down.** `Unknown` (a timeout, a 5xx, a 429, an unrecognised 4xx, `move_id_conflict`) never fails a merge: the saga counts `Attempts`, sets `NextAttemptUtc` (15 s, 1 min, 5 min, then 15 min) and replays the same stored id; the folded account stays Frozen until Heimdall answers. Heimdall's answer to a replay of a move it already stored does not depend on the proofs' age, but a move that never reached Heimdall does: an outage longer than the proofs' freshness (300 s for a login's weak proof, 600 s for a passkey's strong one) ends, when Heimdall is back, in `Refused proof_stale` and a Failed merge; the member confirms again with fresh sign-ins. A replay whose proofs no longer verify (Heimdall's key regenerated) answers `move_id_conflict`, stays Unknown and can never settle: that is follow-up `merge-stuck-operator-path`, as is a receipt that names an account other than the destination (`receipt_mismatch`: proofs wiped, no retry) and a sealed payload the key ring can no longer open (`proofs_unreadable`: retried in an hour, state unchanged, because Heimdall may already have moved). + +**Sealed proofs.** The saga holds the two Heimdall bearers (`ruling merge-sealed-proofs-until-move-final`) only between Confirmed (or UndoRequested) and Heimdall's final answer: protected with Data Protection (purpose `Bifrost.AccountMerge.Proofs.v1`), never logged, wiped in the save that records Moved or Refused. Logs name the merge id, state, outcome class and refusal code; never a token, account id, identity id or provider user id. Proof coverage is Heimdall's rule (`move-proof-strict-passkey`): a proof through one login moves only that login, and only an asserted passkey speaks for the whole account, so a folded account's every login must be proved (or a passkey asserted) or Heimdall answers `identity_not_proved`. A `moveId` replayed with other identities returns the first receipt (`move-replay-first-receipt`). + +`AccountMergeSweeper` is the one merge loop (30 s): per tick, in one scope, `AccountMergeSaga.AdvanceDueAsync` (every in-flight merge whose `NextAttemptUtc` is null or due, oldest first, a failure logged and skipped), then `FinalizeDueAsync`. ## Merged is terminal diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index 95112c1..d29af70 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -1,12 +1,19 @@ +using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Npgsql; namespace Bifrost.Web.Tests; @@ -400,7 +407,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi { var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); Assert.Contains("roll-forward only", Pg(error).MessageText); - Assert.Equal("20261114120000_AddAccountMerges", (await db.Database.GetAppliedMigrationsAsync()).Last()); + Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last()); } await ExecuteAsync(world, "DELETE FROM \"AccountMergeEntries\"; DELETE FROM \"AccountMerges\""); @@ -408,7 +415,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi await down.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last()); await down.GetService().MigrateAsync(); // and up again, trigger and index and all - Assert.Equal("20261114120000_AddAccountMerges", (await down.Database.GetAppliedMigrationsAsync()).Last()); + Assert.Equal("20261114130000_AddAccountMergeSaga", (await down.Database.GetAppliedMigrationsAsync()).Last()); } // ---- restage and intake journal ---- @@ -676,4 +683,154 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi await freeze!.WaitAsync(TimeSpan.FromSeconds(30)); Assert.True(blocked); } + + // ---- the saga: races the State token and the live-merge index carry ---- + + /// Holds the first save of each of two contexts until both have arrived: both have read, neither has written. + private sealed class FirstSaveRendezvous : SaveChangesInterceptor + { + private readonly Barrier _barrier = new(2); + private readonly HashSet _seen = []; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + bool first; + lock (_seen) + { + first = _seen.Add(eventData.Context!.ContextId.InstanceId); + } + + if (first) + { + await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken); + } + + return result; + } + } + + /// The fake Heimdall's account model behind the real move client, one key ring for every saga built over it. + private sealed class Heimdall + { + private readonly EphemeralDataProtectionProvider _keys = new(); + private readonly HeimdallIdentityMoveClient _mover; + + public Heimdall(AccountMergeLedgerTests.World clockOwner) + { + Fake = new FakeHeimdall { RequiredAppSecret = HeimdallHost.AppSecret, Now = () => clockOwner.Clock.Now }; + var options = new HeimdallOptions { BaseUrl = HeimdallShapes.Issuer, AppSecret = HeimdallHost.AppSecret }; + var client = new HttpClient(Fake); + HeimdallAuthService.ConfigureClient(client, options); + _mover = new HeimdallIdentityMoveClient(client, Options.Create(options), NullLogger.Instance); + } + + public FakeHeimdall Fake { get; } + + public AccountMergeSaga Saga(AccountMergeLedgerTests.World world, BifrostDbContext db) => + new(db, world.Ledger(db), _mover, _keys, world.Clock, NullLogger.Instance); + + /// A world account with one login at the fake Heimdall and a weak proof through it. + public MergeProof Proof(UserAccount account, string provider) => + new(Fake.Moves.Issue(account.HeimdallAccountId, Fake.Moves.AddLogin(account.HeimdallAccountId, provider)), account.HeimdallAccountId, provider); + } + + [PostgresFact] + public async Task Two_concurrent_advances_of_one_confirmed_merge_make_one_move_commit_once_and_journal_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var heimdall = new Heimdall(world); + var survivorProof = heimdall.Proof(survivor, "twitch"); + var foldedProof = heimdall.Proof(folded, "discord"); + Guid mergeId; + await using (var db = world.Db()) + { + var saga = heimdall.Saga(world, db); + var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + mergeId = Assert.IsType(await saga.ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, [foldedProof]), default)).MergeId; + } + + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Advance() + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).AdvanceAsync(mergeId, default); + } + + var both = await Task.WhenAll(Task.Run(Advance), Task.Run(Advance)); + + Assert.All(both, x => Assert.Equal(AccountMergeState.Committed, x.State)); + Assert.Single(heimdall.Fake.Moves.Stored); + var journal = await world.JournalAsync(new AccountMerge { Id = mergeId }); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote); + Assert.Equal(survivor.Id, Assert.Single(await world.AllVotesAsync(motion)).UserAccountId); + } + + [PostgresFact] + public async Task Two_confirms_folding_the_same_account_leave_one_row_and_the_other_is_blocked_by_the_database() + { + var world = await postgres.NewWorldAsync(); + var first = await world.AccountAsync("first"); + var second = await world.AccountAsync("second"); + var folded = await world.AccountAsync("folded"); + var heimdall = new Heimdall(world); + var proofs = new Dictionary(); + foreach (var survivor in new[] { first, second }) + { + await using var db = world.Db(); + proofs[survivor.Id] = (heimdall.Proof(survivor, "twitch"), (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id)); + } + + var foldedProof = heimdall.Proof(folded, "discord"); + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Confirm(UserAccount survivor) + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, proofs[survivor.Id].Digest, proofs[survivor.Id].Survivor, [foldedProof]), default); + } + + var results = await Task.WhenAll(Task.Run(() => Confirm(first)), Task.Run(() => Confirm(second))); + + Assert.Single(results.OfType()); + var blocked = Assert.Single(results.OfType()); + Assert.Contains(AccountMergeSaga.MergeInFlight, blocked.Codes); + await using var check = world.Db(); + Assert.Equal(1, await check.AccountMerges.CountAsync()); + } + + [PostgresFact] + public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists() + { + var world = await postgres.NewWorldAsync(); + await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), AccountMergeState.Confirmed); + await using (var db = world.Db()) + { + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261114120000_AddAccountMerges")); + Assert.Contains("AddAccountMergeSaga cannot be reverted", Pg(error).MessageText); + Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last()); + } + + await ExecuteAsync(world, "DELETE FROM \"AccountMerges\""); + await using var down = world.Db(); + await down.GetService().MigrateAsync("20261114120000_AddAccountMerges"); + Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')")); + Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'")); + await down.GetService().MigrateAsync(); + Assert.Equal(3, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')")); + Assert.Equal(2, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'")); + } + + private static async Task ScalarAsync(AccountMergeLedgerTests.World world, string sql) + { + await using var connection = new NpgsqlConnection(ConnectionOf(world)); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + return (long)(await command.ExecuteScalarAsync())!; + } } diff --git a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs new file mode 100644 index 0000000..821e22c --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs @@ -0,0 +1,677 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Bifrost.Web.Tests; + +/// +/// The merge saga from its consumers' side: a request scope of the real host (DI, the move client's HttpClient, Data Protection, the shared +/// TestClock) driving Confirm, Advance and RequestUndo against the fake Heimdall's account model (Heimdall 4fe367a's contract). Every +/// operation takes a fresh scope, as a request or a sweeper tick would, so what survives is only what was saved. +/// +public sealed class AccountMergeSagaTests +{ + private const string Passkey = "bifrost-passkey"; + + private sealed record Party(Guid Id, string HeimdallId, IReadOnlyDictionary Logins); + + /// A host plus the helpers every test needs: accounts with logins at the fake Heimdall, proofs, a scope per operation. + private sealed class Rig : IDisposable + { + public HeimdallHost Host { get; } = new(); + + public FakeMoves Moves => Host.Heimdall.Moves; + + public TestClock Clock => Host.Clock; + + public void Dispose() => Host.Dispose(); + + public async Task AccountAsync(string name, params string[] providers) + { + var heimdallId = HeimdallShapes.NewAccountId(); + var logins = providers.ToDictionary(x => x, x => Moves.AddLogin(heimdallId, x)); + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name), + HeimdallAccountId = heimdallId, CreatedAtUtc = Clock.Now, LastSeenAtUtc = Clock.Now + }; + account.Membership = new Membership { UserAccountId = account.Id, Status = MembershipStatus.Active }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return new Party(account.Id, heimdallId, logins); + } + + /// A fresh sign-in through the login of : a weak proof of that login only. + public MergeProof Weak(Party who, string provider) => new(Moves.Issue(who.HeimdallId, who.Logins[provider]), who.HeimdallId, provider); + + /// A fresh sign-in of through a login that came from (a login the merge moved to it). + public MergeProof Moved(Party holder, Party origin, string provider) => new(Moves.Issue(holder.HeimdallId, origin.Logins[provider]), holder.HeimdallId, provider); + + /// A passkey assertion: a strong proof that speaks for the whole account. + public MergeProof Strong(Party who) => new(Moves.Issue(who.HeimdallId, stepUpMethod: Passkey), who.HeimdallId, "passkey"); + + public async Task InScopeAsync(Func> work) + { + using var scope = Host.Services.CreateScope(); + return await work(scope.ServiceProvider); + } + + public async Task DigestAsync(Party survivor, Party folded) => + await InScopeAsync(async sp => (await sp.GetRequiredService().PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id)); + + public async Task ConfirmAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs) + { + var digest = await DigestAsync(survivor, folded); + return await InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, foldedProofs), default)); + } + + public async Task ConfirmedAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs) => + Assert.IsType(await ConfirmAsync(survivor, folded, survivorProof, foldedProofs)).MergeId; + + public Task AdvanceAsync(Guid id) => InScopeAsync(sp => sp.GetRequiredService().AdvanceAsync(id, default)); + + public Task AdvanceDueAsync() => InScopeAsync(sp => sp.GetRequiredService().AdvanceDueAsync(Clock.Now, default)); + + public Task MergeAsync(Guid id) => + InScopeAsync(sp => sp.GetRequiredService().AccountMerges.AsNoTracking().SingleAsync(x => x.Id == id)); + + public Task> JournalAsync(Guid id) => + InScopeAsync(sp => sp.GetRequiredService().AccountMergeEntries.Where(x => x.MergeId == id).ToListAsync()); + + public Task StandingAsync(Party who) => + InScopeAsync(sp => sp.GetRequiredService().StandingAsync(who.Id, default)); + + public Task MergeCountAsync() => InScopeAsync(sp => sp.GetRequiredService().AccountMerges.CountAsync()); + + public async Task MotionAsync(Party author) + { + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var motion = new Motion + { + CreatedByUserAccountId = author.Id, Title = "m", Status = MotionStatus.Open, Kind = MotionKind.Standard, OpensAtUtc = Clock.Now, + ClosesAtUtc = Clock.Now.AddDays(3), CreatedAtUtc = Clock.Now, ApprovalThreshold = 0.5m + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return motion; + } + + public async Task VoteAsync(Motion motion, Party who, VoteChoice choice) + { + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Votes.Add(new Vote { MotionId = motion.Id, UserAccountId = who.Id, Choice = choice, Weight = 1m, CastAtUtc = Clock.Now.AddHours(1) }); + await db.SaveChangesAsync(); + } + + public Task SeenMoveIdsAsync() => + Task.FromResult(Moves.Requests.Select(x => JsonDocument.Parse(x.Body).RootElement.GetProperty("moveId").GetString()!).ToArray()); + } + + // ---- the happy path and the stored move id ---- + + [Fact] + public async Task Two_weak_folded_proofs_and_a_survivor_proof_confirm_and_advance_to_committed_with_a_seven_day_grace_and_one_stored_move() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + + var digest = await rig.DigestAsync(survivor, folded); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")); + var confirmed = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.Confirmed, confirmed.State); + Assert.Equal(survivor.Id, confirmed.ConfirmedByUserAccountId); + Assert.Equal(rig.Clock.Now, confirmed.ConfirmedUtc); + Assert.Equal("twitch", confirmed.SurvivorProofProvider); + Assert.Equal("discord,patreon", confirmed.FoldedProofProviders); + Assert.Equal(digest, confirmed.PreviewDigest); + Assert.NotNull(confirmed.SealedProofs); + Assert.Matches("^[0-9a-f]{32}$", confirmed.HeimdallMoveId!); + Assert.Empty(rig.Moves.Requests); // confirm calls no Heimdall + Assert.IsType(await rig.StandingAsync(folded)); + + var done = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Committed, done.State); + Assert.Equal(done.CommittedUtc + AccountMergeLedger.Grace, done.GraceEndsUtc); + var stored = Assert.Single(rig.Moves.Stored); + Assert.Equal(confirmed.HeimdallMoveId, stored.MoveId); + var final = await rig.MergeAsync(id); + Assert.Null(final.SealedProofs); + Assert.Equal("weak", final.ProofFromTier); + Assert.Equal("weak", final.ProofToTier); + Assert.Equal(folded.Logins.Values.Order(), JsonSerializer.Deserialize(final.MovedIdentityIds!)!.Order()); + Assert.IsType(await rig.StandingAsync(folded)); + Assert.Equal(["twitch", "discord", "patreon"], rig.Moves.Logins[survivor.HeimdallId].Select(x => x.Provider)); + } + + [Fact] + public async Task A_vote_on_the_same_open_motion_from_both_accounts_counts_only_the_survivors_after_the_saga_lands() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, survivor, VoteChoice.For); + await rig.VoteAsync(motion, folded, VoteChoice.Against); + + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + Assert.Equal(survivor.Id, (await db.Votes.SingleAsync(x => x.MotionId == motion.Id)).UserAccountId); + var all = await db.Votes.IgnoreQueryFilters().Where(x => x.MotionId == motion.Id).ToListAsync(); + Assert.Equal(id, Assert.Single(all, x => x.UserAccountId == folded.Id).VoidedByMergeId); + return 0; + }); + } + + [Fact] + public async Task Heimdall_sees_the_move_id_stored_on_the_row_and_it_is_not_derived_from_the_merge_id() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + Assert.NotEqual(id.ToString("N"), (await rig.MergeAsync(id)).HeimdallMoveId); + const string overwritten = "overwritten0123456789abcdef0123456789"; + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.AccountMerges.SingleAsync(x => x.Id == id)).HeimdallMoveId = overwritten; + await db.SaveChangesAsync(); + return 0; + }); + + await rig.AdvanceAsync(id); + + Assert.Equal(overwritten, Assert.Single(rig.Moves.Stored).MoveId); + } + + // ---- crashes and lost answers ---- + + [Fact] + public async Task A_lost_answer_leaves_the_merge_confirmed_and_a_fresh_scope_after_the_proofs_expired_settles_it_by_replay() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Moves.MoveApplyThenFail = HttpStatusCode.InternalServerError; + + var lost = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, lost.State); + Assert.Equal(1, lost.Attempts); + Assert.Equal(rig.Clock.Now + TimeSpan.FromSeconds(15), lost.NextAttemptUtc); + Assert.Equal("HTTP 500", lost.LastOutcome); + Assert.Single(rig.Moves.Stored); + rig.Moves.MoveApplyThenFail = null; + rig.Clock.Advance(TimeSpan.FromSeconds(601)); // every proof is stale and the bearers expired by now; Heimdall answers a replay first + + Assert.Equal(1, await rig.AdvanceDueAsync()); + + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + Assert.Single(rig.Moves.Stored); + Assert.Equal(2, (await rig.SeenMoveIdsAsync()).Length); + } + + [Fact] + public async Task A_crash_on_the_save_that_writes_HeimdallMoved_is_resumed_with_the_same_move_id_and_one_stored_move() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Host.Fault.When = db => db.ChangeTracker.Entries().Any(x => x.Entity.State == AccountMergeState.HeimdallMoved); + + await Assert.ThrowsAsync(() => rig.AdvanceAsync(id)); + rig.Host.Fault.When = null; + Assert.Equal(AccountMergeState.Confirmed, (await rig.MergeAsync(id)).State); + Assert.NotNull((await rig.MergeAsync(id)).SealedProofs); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + + var seen = await rig.SeenMoveIdsAsync(); + Assert.Equal(2, seen.Length); + Assert.Single(seen.Distinct()); + Assert.Single(rig.Moves.Stored); + Assert.Equal(seen[0], (await rig.MergeAsync(id)).HeimdallMoveId); + } + + [Fact] + public async Task A_crash_on_the_Committed_save_leaves_HeimdallMoved_and_the_next_advance_commits_once_without_calling_Heimdall_again() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Host.Fault.When = db => db.ChangeTracker.Entries().Any(x => x.Entity.State == AccountMergeState.Committed); + + await Assert.ThrowsAsync(() => rig.AdvanceAsync(id)); + rig.Host.Fault.When = null; + var moved = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.HeimdallMoved, moved.State); + Assert.Null(moved.SealedProofs); + Assert.Empty(await rig.JournalAsync(id)); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + + Assert.Single(rig.Moves.Requests); + var journal = await rig.JournalAsync(id); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote); + } + + [Fact] + public async Task The_sweepers_own_loop_resumes_a_confirmed_merge_after_a_restart_and_finalizes_it_after_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + + await RunOneSweepAsync(rig); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await RunOneSweepAsync(rig); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + /// Starts the production sweeper (its first pass runs at once, before its 30 s timer) and stops it once the pass is over. + private static async Task RunOneSweepAsync(Rig rig) + { + var sweeper = new AccountMergeSweeper(rig.Host.Services.GetRequiredService(), rig.Clock, NullLogger.Instance); + await sweeper.StartAsync(default); + var before = rig.Clock.Now; + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline && sweeper.ExecuteTask is { IsCompleted: false } && await rig.InScopeAsync(async sp => + await sp.GetRequiredService().AccountMerges.AnyAsync(x => + x.State == AccountMergeState.Confirmed || x.State == AccountMergeState.HeimdallMoved || (x.State == AccountMergeState.Committed && x.GraceEndsUtc <= before)))) + { + await Task.Delay(50); + } + + await sweeper.StopAsync(default); + } + + // ---- refusals ---- + + [Theory] + [InlineData("proof_stale")] + [InlineData("identity_not_proved")] + [InlineData("provider_conflict")] + [InlineData("already_moved")] + public async Task A_coded_refusal_fails_the_merge_wipes_the_proofs_and_changes_nothing_in_Bifrost(string code) + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", code == "provider_conflict" ? ["discord"] : ["twitch"]); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); + MergeProof[] foldedProofs = code == "identity_not_proved" + ? [rig.Weak(folded, "discord")] // the patreon login is not proved: no partial move + : [rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")]; + var survivorProof = rig.Weak(survivor, code == "provider_conflict" ? "discord" : "twitch"); + if (code == "already_moved") + { + // Heimdall already moved the folded account elsewhere; Bifrost's merge does not know. + var elsewhere = await rig.AccountAsync("elsewhere", "twitch2"); + var move = new HeimdallMoveRequest("preexisting-move-0000", [rig.Strong(folded).AccessToken], rig.Weak(elsewhere, "twitch2").AccessToken); + Assert.IsType(await rig.InScopeAsync(sp => sp.GetRequiredService().MoveAsync(move, default))); + foldedProofs = [rig.Strong(folded)]; + } + + var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProofs); + if (code == "proof_stale") + { + rig.Clock.Advance(TimeSpan.FromSeconds(400)); + } + + var failed = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Failed, failed.State); + Assert.Equal(code, failed.FailureCode); + Assert.Equal(rig.Clock.Now, failed.FailedUtc); + var stored = await rig.MergeAsync(id); + Assert.Null(stored.SealedProofs); + Assert.Empty(await rig.JournalAsync(id)); + Assert.Null(await rig.StandingAsync(folded)); + await rig.InScopeAsync(async sp => + { + var vote = await sp.GetRequiredService().Votes.SingleAsync(); + Assert.Equal(folded.Id, vote.UserAccountId); + return 0; + }); + Assert.DoesNotContain(rig.Moves.Stored, x => x.MoveId == stored.HeimdallMoveId); + } + + // ---- confirm's consent checks ---- + + [Fact] + public async Task Confirm_refuses_a_third_party_a_changed_plan_a_proof_of_another_account_and_a_blocked_pair_and_writes_no_row() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var stranger = await rig.AccountAsync("stranger", "github"); + var digest = await rig.DigestAsync(survivor, folded); + var good = rig.Weak(survivor, "twitch"); + var foldedProof = rig.Weak(folded, "discord"); + + Task Confirm(Guid actor, string digestSeen, MergeProof survivorProof, params MergeProof[] proofs) => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(actor, survivor.Id, folded.Id, digestSeen, survivorProof, proofs), default)); + + Assert.IsType(await Confirm(stranger.Id, digest, good, foldedProof)); + Assert.IsType(await Confirm(survivor.Id, digest, rig.Weak(stranger, "github"), foldedProof)); + Assert.IsType(await Confirm(survivor.Id, digest, good, rig.Weak(survivor, "twitch"))); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); // the plan the member saw is no longer the plan + Assert.IsType(await Confirm(survivor.Id, digest, good, foldedProof)); + Assert.Equal(0, await rig.MergeCountAsync()); + + var blocked = Assert.IsType(await rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, survivor.Id, "x", good, [good]), default))); + Assert.Contains("same_account", blocked.Codes); + Assert.Equal(0, await rig.MergeCountAsync()); + + // a second confirm of the same fold, after the first + var fresh = await rig.DigestAsync(survivor, folded); + Assert.IsType(await Confirm(survivor.Id, fresh, good, foldedProof)); + var second = Assert.IsType(await Confirm(survivor.Id, fresh, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"))); + Assert.Contains(AccountMergeSaga.MergeInFlight, second.Codes); + Assert.Equal(1, await rig.MergeCountAsync()); + } + + [Fact] + public async Task A_malformed_confirm_throws_before_any_row_and_neither_the_message_nor_the_proof_text_carry_a_token() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + const string canary = "CANARY-bearer-9f3a"; + var proof = new MergeProof(canary, folded.HeimdallId, "discord"); + Assert.DoesNotContain(canary, proof.ToString()); + Assert.DoesNotContain(folded.HeimdallId, proof.ToString()); + var request = new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, "d", new MergeProof(canary, survivor.HeimdallId, "twitch"), [proof]); + Assert.DoesNotContain(canary, request.ToString()); + + var nine = Enumerable.Repeat(proof, 9).ToList(); + var tooMany = await Assert.ThrowsAsync(() => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync(request with { FoldedProofs = nine }, default))); + var tooLong = await Assert.ThrowsAsync(() => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + request with { FoldedProofs = [proof with { AccessToken = canary + new string('x', HeimdallIdentityMoveClient.MaxProofLength) }] }, default))); + + Assert.DoesNotContain(canary, tooMany.ToString()); + Assert.DoesNotContain(canary, tooLong.ToString()); + Assert.Equal(0, await rig.MergeCountAsync()); + } + + // ---- sealing ---- + + [Fact] + public async Task Sealed_proofs_never_hold_a_bearer_in_the_clear_are_wiped_at_each_final_answer_and_no_log_carries_a_bearer_or_an_account() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var survivorProof = rig.Weak(survivor, "twitch"); + var foldedProof = rig.Weak(folded, "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProof); + var sealedText = (await rig.MergeAsync(id)).SealedProofs!; + Assert.DoesNotContain(survivorProof.AccessToken, sealedText); + Assert.DoesNotContain(foldedProof.AccessToken, sealedText); + Assert.DoesNotContain("bearer", sealedText); + + await rig.AdvanceAsync(id); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + + var undoProof = rig.Moved(survivor, folded, "discord"); // the moved login now lives on the survivor + Assert.IsType(await RequestUndoAsync(rig, id, survivor, undoProof)); + var requested = (await rig.MergeAsync(id)).SealedProofs!; + Assert.DoesNotContain(undoProof.AccessToken, requested); + await rig.AdvanceAsync(id); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + + var log = string.Join('\n', rig.Host.Logs.Lines); + foreach (var secret in new[] { survivorProof.AccessToken, foldedProof.AccessToken, undoProof.AccessToken, survivor.HeimdallId, folded.HeimdallId } + .Concat(folded.Logins.Values).Concat(survivor.Logins.Values)) + { + Assert.DoesNotContain(secret, log); + } + + Assert.Contains(id.ToString(), log); + } + + private static Task RequestUndoAsync(Rig rig, Guid id, Party actor, params MergeProof[] proofs) => + rig.InScopeAsync(sp => sp.GetRequiredService().RequestUndoAsync(id, actor.Id, proofs, default)); + + [Fact] + public async Task Unreadable_sealed_proofs_change_no_state_and_retry_in_an_hour_and_the_error_log_names_only_the_merge() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.AccountMerges.SingleAsync(x => x.Id == id)).SealedProofs = "CfDJ8-not-a-protected-payload"; + await db.SaveChangesAsync(); + return 0; + }); + + var stuck = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, stuck.State); + Assert.Equal(AccountMergeSaga.ProofsUnreadable, stuck.LastOutcome); + Assert.Equal(rig.Clock.Now + TimeSpan.FromHours(1), stuck.NextAttemptUtc); + Assert.Empty(rig.Moves.Requests); + Assert.Contains(rig.Host.Logs.Lines, x => x.Contains(id.ToString()) && x.Contains("unreadable")); + Assert.Equal(0, await rig.AdvanceDueAsync()); // not due for an hour + } + + [Fact] + public async Task A_receipt_for_another_account_is_recorded_wipes_the_proofs_and_is_never_retried() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.UserAccounts.SingleAsync(x => x.Id == survivor.Id)).HeimdallAccountId = HeimdallShapes.NewAccountId(); // Bifrost's own link drifted + await db.SaveChangesAsync(); + return 0; + }); + + var stuck = await rig.AdvanceAsync(id); + await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, stuck.State); + Assert.Equal(AccountMergeSaga.ReceiptMismatch, stuck.LastOutcome); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + Assert.Single(rig.Moves.Requests); + Assert.IsType(await rig.StandingAsync(folded)); + } + + // ---- Heimdall down ---- + + [Fact] + public async Task While_Heimdall_is_down_the_merge_stays_confirmed_and_the_folded_account_frozen_then_commits_when_it_answers() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Strong(survivor), rig.Strong(folded)); // strong proofs outlive the outage below + rig.Moves.MoveDown = new HttpRequestException("down"); + + Assert.Equal(1, await rig.AdvanceDueAsync()); + Assert.Equal(0, await rig.AdvanceDueAsync()); // backed off 15 s + rig.Clock.Advance(TimeSpan.FromSeconds(16)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + rig.Clock.Advance(TimeSpan.FromSeconds(61)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + + var down = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.Confirmed, down.State); + Assert.Equal(3, down.Attempts); + Assert.Equal(rig.Clock.Now + TimeSpan.FromMinutes(5), down.NextAttemptUtc); + Assert.IsType(await rig.StandingAsync(folded)); + Assert.Empty(rig.Moves.Stored); + + rig.Moves.MoveDown = null; + rig.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + } + + // ---- undo ---- + + [Fact] + public async Task Undo_with_survivor_proofs_of_the_moved_logins_reverses_the_move_and_ends_Undone() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")); + await rig.AdvanceAsync(id); + var forward = (await rig.MergeAsync(id)).HeimdallMoveId; + + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord"), rig.Moved(survivor, folded, "patreon"))); + var requested = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.UndoRequested, requested.State); + Assert.Matches("^[0-9a-f]{32}$", requested.HeimdallReverseMoveId!); + Assert.NotEqual(forward, requested.HeimdallReverseMoveId); + Assert.IsType(await rig.StandingAsync(folded)); + + var done = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Undone, done.State); + Assert.NotNull((await rig.MergeAsync(id)).ReversedUtc); + Assert.Equal(2, rig.Moves.Stored.Count); + var reversal = Assert.Single(rig.Moves.Stored, x => x.ReversesMoveId is not null); + Assert.Equal(forward, reversal.ReversesMoveId); + Assert.Equal(requested.HeimdallReverseMoveId, reversal.MoveId); + Assert.Equal(["discord", "patreon"], rig.Moves.Logins[folded.HeimdallId].Select(x => x.Provider).Order()); + Assert.Null(await rig.StandingAsync(folded)); + } + + [Fact] + public async Task A_refused_undo_returns_the_merge_to_Committed_with_the_proofs_wiped_and_the_sweep_finalizes_it_after_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + var committed = await rig.AdvanceAsync(id); + rig.Moves.Logins[survivor.HeimdallId].RemoveAll(x => x.Provider == "discord"); // the survivor unlinked a moved login + + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + var back = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Committed, back.State); + var stored = await rig.MergeAsync(id); + Assert.Equal("undo_not_reversible", stored.LastOutcome); + Assert.Null(stored.SealedProofs); + Assert.Equal(committed.GraceEndsUtc, stored.GraceEndsUtc); + Assert.IsType(await rig.StandingAsync(folded)); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await rig.InScopeAsync(async sp => + { + await sp.GetRequiredService().AdvanceDueAsync(rig.Clock.Now, default); + return await sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default); + }); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + [Fact] + public async Task Undo_is_requested_only_inside_the_grace_only_by_the_survivor_and_only_from_Committed() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + Assert.IsType(await RequestUndoAsync(rig, Guid.NewGuid(), survivor, rig.Weak(survivor, "twitch"))); + await rig.AdvanceAsync(id); + + Assert.IsType(await RequestUndoAsync(rig, id, folded, rig.Weak(folded, "discord"))); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(folded, "discord"))); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + + var proof = rig.Weak(survivor, "twitch"); + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromSeconds(1)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, proof)); // Committed, but the grace ended + await rig.InScopeAsync(sp => sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, proof)); // Final + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + [Fact] + public async Task An_unknown_answer_to_the_reversal_is_replayed_under_the_same_reverse_id_even_past_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord"))); + rig.Moves.MoveApplyThenFail = HttpStatusCode.BadGateway; + + Assert.Equal(AccountMergeState.UndoRequested, (await rig.AdvanceAsync(id)).State); + rig.Moves.MoveApplyThenFail = null; + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromDays(1)); + await rig.AdvanceDueAsync(); + + Assert.Equal(AccountMergeState.Undone, (await rig.MergeAsync(id)).State); + var seen = (await rig.SeenMoveIdsAsync()).Skip(1).ToArray(); + Assert.Equal(2, seen.Length); + Assert.Single(seen.Distinct()); + Assert.Equal(2, rig.Moves.Stored.Count); + } + + // ---- the wiring ---- + + [Fact] + public void The_host_registers_exactly_one_merge_loop_in_production_and_the_saga_beside_the_ledger() + { + var program = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Bifrost.Web", "Program.cs")); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(program, @"AddHostedService<.*Merge")); + Assert.Contains("AddScoped", program); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } +} diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index d2f625f..28612ee 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -30,6 +30,7 @@ public sealed class MigrationDiscoveryTests "20261111120000_ClearLegacyConsentReceipts", "20261113120000_AddPasskeyCredentials", "20261114120000_AddAccountMerges", + "20261114130000_AddAccountMergeSaga", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. diff --git a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs index 1a87171..ba0ae98 100644 --- a/tests/Bifrost.Web.Tests/Support/FakeMoves.cs +++ b/tests/Bifrost.Web.Tests/Support/FakeMoves.cs @@ -91,7 +91,16 @@ public sealed class FakeMoves(Func now) public bool IsRevoked(string token) => _revoked.Contains(_tokens[token].Sid); + /// Heimdall serializes a move by its id; so does the fake, because concurrent callers (the saga tests that race two advances) share its dictionaries. public HttpResponseMessage Route(HttpRequestMessage request, string body, string? requiredSecret) + { + lock (_moves) + { + return RouteLocked(request, body, requiredSecret); + } + } + + private HttpResponseMessage RouteLocked(HttpRequestMessage request, string body, string? requiredSecret) { var secret = request.Headers.TryGetValues("x-heimdall-app-secret", out var values) ? values.Single() : null; Requests.Add((secret, request.Headers.Authorization?.ToString(), body)); diff --git a/tests/Bifrost.Web.Tests/Support/FaultOnSave.cs b/tests/Bifrost.Web.Tests/Support/FaultOnSave.cs new file mode 100644 index 0000000..3d27f15 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Support/FaultOnSave.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Bifrost.Web.Tests.Support; + +/// +/// Armed by a test, it throws on the save its predicate picks and lets every other save through: a crash between two writes (the +/// SaveChangesInterceptor seam of AccountMergeLedgerTests). The host's contexts all carry it, so a crash in the saga's own scope is real. +/// +public sealed class FaultOnSave : SaveChangesInterceptor +{ + public Func? When { get; set; } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) => + When is { } when && when(eventData.Context!) ? throw new InvalidOperationException("injected save failure") : ValueTask.FromResult(result); +} diff --git a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs index 688676c..97b0062 100644 --- a/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs +++ b/tests/Bifrost.Web.Tests/Support/HeimdallTestKit.cs @@ -814,6 +814,11 @@ public sealed class HeimdallHost : TestWebApplicationFactory services.RemoveAll(); services.AddSingleton(Clock); + // The merge sweeper ticks on the wall clock; a host on a manual clock drives the saga itself (AccountMergeSagaTests runs the sweeper's own loop). + foreach (var sweeper in services.Where(x => x.ImplementationType == typeof(Bifrost.Web.Features.Membership.AccountMergeSweeper)).ToList()) + { + services.Remove(sweeper); + } services.AddHttpClient(HeimdallTokenVerifier.ClientName).ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(() => Heimdall); diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index ff491db..abe0750 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -20,6 +20,9 @@ public class TestWebApplicationFactory : WebApplicationFactory private static readonly InMemoryDatabaseRoot _databaseRoot = new(); private readonly string _databaseName = $"bifrost-tests-{Guid.NewGuid():N}"; + /// Armed by a test to crash a chosen save; every context of this host carries it. + public FaultOnSave Fault { get; } = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); @@ -60,7 +63,7 @@ public class TestWebApplicationFactory : WebApplicationFactory services.AddDbContext(options => { - options.UseInMemoryDatabase(_databaseName, _databaseRoot); + options.UseInMemoryDatabase(_databaseName, _databaseRoot).AddInterceptors(Fault); }); services.AddAuthentication(options => -- 2.47.3 From b47a6557cb5dd371344096da928c8541555421e2 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 07:49:52 +0000 Subject: [PATCH 18/20] Move client: pin duplicate identityIds and over-length proofs as refused before any request Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs index a979b10..1e4cc01 100644 --- a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -529,6 +529,9 @@ 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, proofs, "t", ["i", "j", "i"]), + new(good, [new string('p', HeimdallIdentityMoveClient.MaxProofLength + 1)], "t"), + new(good, proofs, new string('p', HeimdallIdentityMoveClient.MaxProofLength + 1)), new(good, [""], "t"), new(good + "\n", proofs, "t"), new(good, proofs, ReversesMoveId: good + "\n"), -- 2.47.3 From a2172349baa3285480503b52891aea371e1c0f65 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 08:09:39 +0000 Subject: [PATCH 19/20] Account merge saga: pin that a receipt mismatch stays the recorded answer across later sweeps Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs index 821e22c..bfeae99 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs @@ -507,10 +507,13 @@ public sealed class AccountMergeSagaTests }); var stuck = await rig.AdvanceAsync(id); - await rig.AdvanceAsync(id); + var again = await rig.AdvanceAsync(id); Assert.Equal(AccountMergeState.Confirmed, stuck.State); Assert.Equal(AccountMergeSaga.ReceiptMismatch, stuck.LastOutcome); + // The answer stays what Heimdall gave: a later sweep does not turn it into an hourly "proofs unreadable" retry. + Assert.Equal(AccountMergeSaga.ReceiptMismatch, again.LastOutcome); + Assert.Null(again.NextAttemptUtc); Assert.Null((await rig.MergeAsync(id)).SealedProofs); Assert.Single(rig.Moves.Requests); Assert.IsType(await rig.StandingAsync(folded)); -- 2.47.3 From 757ca2eeabfee0867eb082954e582b879f5612b1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 09:26:27 +0000 Subject: [PATCH 20/20] Account merge saga: plan digest ignores the id of a membership not yet created, pin grace boundary, unique move ids and the confirm write gate, ProofSeal prints no bearer, commit the mutant probe Co-Authored-By: Claude Sonnet 5.5 --- .../Features/Membership/AccountMergeLedger.cs | 3 +- .../Features/Membership/AccountMergeSaga.cs | 10 ++- .../AccountMergePostgresTests.cs | 60 ++++++++++++++ .../AccountMergeSagaTests.cs | 51 ++++++++++++ tools/merge-saga-mutants.py | 83 +++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tools/merge-saga-mutants.py diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs index b242101..c8ab2b9 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -43,7 +43,8 @@ public sealed record MergePlan( { Field(entry.Kind.ToString()); Field(entry.Action.ToString()); - Field(entry.RecordId.ToString("N")); + // A Created row does not exist yet: its id is minted anew by every plan, so it cannot be part of what the member saw. + Field(entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N")); Field(entry.PriorUserAccountId?.ToString("N")); Field(entry.PriorText); Field(entry.PriorAtUtc?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs index 8281e9e..dbf91cc 100644 --- a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs +++ b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs @@ -104,7 +104,15 @@ public sealed partial class AccountMergeSaga( private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1"); - private sealed record ProofSeal(List From, string? To); + private sealed record ProofSeal(List From, string? To) + { + // The seal holds live bearers: the generated ToString would print them into any log or exception that holds it. + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}"); + return true; + } + } // ---- confirm ---- diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs index d29af70..39b0fc0 100644 --- a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -804,6 +804,66 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi Assert.Equal(1, await check.AccountMerges.CountAsync()); } + [PostgresFact] + public async Task Two_merges_cannot_share_a_Heimdall_move_id_or_a_reverse_move_id() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var one = await world.MergeAsync(survivor, await world.AccountAsync("one"), AccountMergeState.Confirmed); + var two = await world.MergeAsync(survivor, await world.AccountAsync("two"), AccountMergeState.Confirmed); + + foreach (var column in new[] { "HeimdallMoveId", "HeimdallReverseMoveId" }) + { + await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{one.Id}'"); + var error = await Assert.ThrowsAnyAsync(() => + ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{two.Id}'")); + Assert.Equal("23505", Pg(error).SqlState); + } + } + + [PostgresFact] + public async Task Confirm_holds_the_actors_write_lock_until_its_row_is_saved() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var heimdall = new Heimdall(world); + var proof = heimdall.Proof(survivor, "twitch"); + var foldedProof = heimdall.Proof(folded, "discord"); + string digest; + await using (var db = world.Db()) + { + digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + } + + Task? exclusive = null; + var blocked = false; + var confirmers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // A writer that needs the actor's exclusive lock (a commit freezing it) arrives while the confirm is about to save. + exclusive = Task.Run(async () => + { + await using var connection = new NpgsqlConnection(ConnectionOf(world)); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using var command = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(@key, 0))", connection, transaction); + command.Parameters.AddWithValue("key", survivor.Id.ToString()); + await command.ExecuteNonQueryAsync(); + }); + await Task.Delay(1500); + blocked = !exclusive.IsCompleted; + })); + + await using (var db = confirmers.Db()) + { + Assert.IsType(await heimdall.Saga(confirmers, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, proof, [foldedProof]), default)); + } + + await exclusive!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + } + [PostgresFact] public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists() { diff --git a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs index bfeae99..d3cfc6b 100644 --- a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs +++ b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs @@ -634,6 +634,57 @@ public sealed class AccountMergeSagaTests Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); } + [Fact] + public async Task A_suspended_folded_account_into_a_memberless_survivor_confirms_on_the_digest_the_member_saw_and_commits() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); // the chat tier holds no membership + (await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status = MembershipStatus.Suspended; + await db.SaveChangesAsync(); + return 0; + }); + + // The member is shown a plan and confirms it: the same unchanged world must show the same digest. + Assert.Equal(await rig.DigestAsync(survivor, folded), await rig.DigestAsync(survivor, folded)); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + var carried = await rig.InScopeAsync(sp => sp.GetRequiredService().Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); + Assert.Equal(MembershipStatus.Suspended, carried.Status); + } + + [Fact] + public async Task Undo_is_refused_at_the_exact_instant_the_grace_ends_and_allowed_a_second_before() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + var ends = (await rig.MergeAsync(id)).GraceEndsUtc!.Value; + + rig.Clock.Advance(ends - rig.Clock.Now); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + rig.Clock.Advance(TimeSpan.FromSeconds(-1)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + } + + [Fact] + public void The_sealed_proofs_record_prints_no_bearer() + { + var seal = typeof(AccountMergeSaga).GetNestedType("ProofSeal", System.Reflection.BindingFlags.NonPublic)!; + var instance = Activator.CreateInstance(seal, new List { "bearer-folded-secret" }, "bearer-survivor-secret")!; + + var text = instance.ToString()!; + + Assert.DoesNotContain("secret", text); + } + [Fact] public async Task An_unknown_answer_to_the_reversal_is_replayed_under_the_same_reverse_id_even_past_the_grace() { diff --git a/tools/merge-saga-mutants.py b/tools/merge-saga-mutants.py new file mode 100644 index 0000000..1b8da0b --- /dev/null +++ b/tools/merge-saga-mutants.py @@ -0,0 +1,83 @@ +"""One-off mutation probe for the account merge saga (not a gate; survivors are triaged by name). +Usage, from any directory with BIFROST_TEST_PG and the dotnet environment set: + python3 tools/merge-saga-mutants.py [mutant-name ...] # no names: all +Each mutant is one exact text edit, applied, run against the merge tests (and again alone if it fails), then restored. +Logs go to $MUTANT_LOGS (default ./mutant-logs). Expected: every mutant KILLED. +""" +import subprocess, re, sys, os, json +WT=subprocess.run(['git','rev-parse','--show-toplevel'],capture_output=True,text=True,check=True,cwd=os.path.dirname(os.path.abspath(__file__))).stdout.strip() +LOGS=os.environ.get('MUTANT_LOGS','./mutant-logs'); os.makedirs(LOGS,exist_ok=True) +SAGA='src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs' +LEDGER='src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs' +FILTER='FullyQualifiedName~AccountMergeSaga|FullyQualifiedName~AccountMergePostgres' +M=[ +('derive-move-id', SAGA, [(" var merge = new AccountMerge\n {\n State = AccountMergeState.Confirmed,"," var fixedId = Guid.NewGuid();\n var merge = new AccountMerge\n {\n Id = fixedId,\n State = AccountMergeState.Confirmed,"),('HeimdallMoveId = Guid.NewGuid().ToString("N"),','HeimdallMoveId = fixedId.ToString("N"),')], None), +('no-wipe-on-moved', SAGA, " merge.SealedProofs = null;\n if (moved.Receipt.AccountId != destination)", " if (moved.Receipt.AccountId != destination)"), +('no-wipe-on-refused', SAGA, " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was.\n merge.SealedProofs = null;", " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was."), +('refused-stays-confirmed', SAGA, "merge.State = AccountMergeState.Failed;", "merge.State = AccountMergeState.Confirmed;"), +('unknown-no-attempt', SAGA, " merge.Attempts++;\n", ""), +('unknown-no-backoff', SAGA, "merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1];", "merge.NextAttemptUtc = null;"), +('no-digest-check', SAGA, "if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal))", "if (false)"), +('no-proof-account-check', SAGA, "if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall))", "if (false)"), +('no-notparty', SAGA, "if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId)", "if (false)"), +('no-block-check', SAGA, "if (plan.Blocks.Count > 0)", "if (false)"), +('no-grace-check', SAGA, "if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds)", "if (merge.GraceEndsUtc is not { } graceEnds)"), +('no-notsurvivor', SAGA, "if (actorUserAccountId != merge.SurvivorUserAccountId)", "if (false)"), +('no-undo-proof-check', SAGA, "if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall))", "if (false)"), +('no-final-graceover', SAGA, "if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null)", "if (false)"), +('no-receipt-check', SAGA, "if (moved.Receipt.AccountId != destination)", "if (false)"), +('no-concurrency-retry', SAGA, "catch (DbUpdateConcurrencyException) when (lost < 2)", "catch (DbUpdateConcurrencyException) when (false)"), +('no-live-violation-catch', SAGA, "catch (DbUpdateException error) when (IsLiveMergeViolation(error))", "catch (DbUpdateException error) when (false)"), +('undo-refused-fails', SAGA, " merge.State = AccountMergeState.Committed;\n merge.LastOutcome = \"undo_\" + code;", " merge.State = AccountMergeState.Failed;\n merge.LastOutcome = \"undo_\" + code;"), +('unreadable-no-delay', SAGA, "merge.NextAttemptUtc = now + UnreadableRetry;", "merge.NextAttemptUtc = null;"), +('due-ignores-next-attempt', SAGA, "&& (x.NextAttemptUtc == null || x.NextAttemptUtc <= now)", ""), +('mismatch-retries', SAGA, " if (merge.LastOutcome == ReceiptMismatch)\n {\n return merge;", " if (false)\n {\n return merge;"), +('reverse-id-forward', SAGA, "new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)", "new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)"), +('log-token', SAGA, 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code);', 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code} {Req}", merge.Id, merge.State, outcome.GetType().Name, code, proofs.From[0]);'), +('seal-plain', SAGA, "private string Seal(IEnumerable from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to)));", "private string Seal(IEnumerable from, string? to) => JsonSerializer.Serialize(new ProofSeal([.. from], to));"), +('sweeper-no-advance', LEDGER, " await scope.ServiceProvider.GetRequiredService().AdvanceDueAsync(now, stoppingToken);\n", ""), +('sweeper-no-finalize', LEDGER, " await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(now, stoppingToken);\n", ""), +('no-writegate-confirm', SAGA, "return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken);", "return await InsertConfirmedAsync(request, cancellationToken);"), +('digest-no-entries', LEDGER, " foreach (var entry in Entries)\n {\n Field(entry.Kind.ToString());", " foreach (var entry in Entries.Take(0))\n {\n Field(entry.Kind.ToString());"), +('tier-swapped', SAGA, "merge.ProofFromTier = Tier(moved.Receipt.ProofFrom);", "merge.ProofFromTier = \"strong\";"), +('wipe-missing-on-reversed', SAGA, " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;", " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;\n merge.SealedProofs = \"kept\";"), +('grace-boundary', SAGA, "now >= graceEnds", "now > graceEnds"), +('move-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallMoveId",\n unique: true,', 'column: "HeimdallMoveId",\n unique: false,'), +('reverse-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallReverseMoveId",\n unique: true,', 'column: "HeimdallReverseMoveId",\n unique: false,'), +('digest-includes-created-id', LEDGER, 'entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N")', 'entry.RecordId.ToString("N")'), +('proofseal-prints-bearer', SAGA, 'builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}");', 'builder.Append($"From = {string.Join(",", From)}, To = {To}");'), +] +only=sys.argv[1:] +def run(filt,log): + env=dict(os.environ,DOTNET_USE_POLLING_FILE_WATCHER='1') + r=subprocess.run([*os.environ.get('DOTNET','dotnet').split(),'test','tests/Bifrost.Web.Tests','--filter',filt],cwd=WT,env=env,capture_output=True,text=True) + open(log,'w').write(r.stdout+r.stderr) + out=r.stdout+r.stderr + fails=sorted(set(re.findall(r'^\s*Failed (Bifrost\.\S+)',out,re.M))) + built='Build FAILED' not in out and 'error CS' not in out + return built,fails,out +res={} +for name,path,old,new in M: + if only and name not in only: continue + full=os.path.join(WT,path) + src=open(full).read() + pairs=old if isinstance(old,list) else [(old,new)] + if any(src.count(o)!=1 for o,_ in pairs): + print(name,'ANCHOR',flush=True); res[name]='anchor-miss'; continue + mutated=src + for o,n in pairs: mutated=mutated.replace(o,n) + open(full,'w').write(mutated) + try: + built,fails,out=run(FILTER,f'{LOGS}/{name}.log') + if not built: res[name]='BUILD-FAIL' + elif not fails: res[name]='SURVIVED' + else: + # rerun the first failing test in isolation + t=fails[0]; method=t.split('.')[-1].split('(')[0] + built2,fails2,_=run('FullyQualifiedName~'+method,f'{LOGS}/{name}.iso.log') + res[name]=('KILLED x%d; isolated %s: %s'%(len(fails),method,'still fails' if fails2 else 'PASSES ALONE (order-dependent)')) + finally: + open(full,'w').write(src) + print(name,res[name],flush=True) +json.dump(res,open(LOGS+'/result.json','w'),indent=1) +print('MUT-DONE',flush=True) -- 2.47.3