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() {