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:
parent
f402eb13cc
commit
aa348d3b43
4 changed files with 37 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue