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)