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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-14 04:13:54 +00:00
commit aa348d3b43
4 changed files with 37 additions and 7 deletions

View file

@ -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

View file

@ -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;

View file

@ -121,6 +121,14 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
}
}
private static IEnumerable<Exception> 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<Exception>(), e => Assert.IsAssignableFrom<DbUpdateException>(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<Exception>(), 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));

View file

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