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 <noreply@anthropic.com>
This commit is contained in:
parent
a2172349ba
commit
757ca2eeab
5 changed files with 205 additions and 2 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -104,7 +104,15 @@ public sealed partial class AccountMergeSaga(
|
|||
|
||||
private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1");
|
||||
|
||||
private sealed record ProofSeal(List<string> From, string? To);
|
||||
private sealed record ProofSeal(List<string> 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 ----
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Exception>(() =>
|
||||
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<ConfirmOutcome.Confirmed>(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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<BifrostDbContext>();
|
||||
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<BifrostDbContext>().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<UndoOutcome.GraceOver>(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch")));
|
||||
rig.Clock.Advance(TimeSpan.FromSeconds(-1));
|
||||
Assert.IsType<UndoOutcome.Requested>(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<string> { "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()
|
||||
{
|
||||
|
|
|
|||
83
tools/merge-saga-mutants.py
Normal file
83
tools/merge-saga-mutants.py
Normal file
|
|
@ -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<string> from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to)));", "private string Seal(IEnumerable<string> from, string? to) => JsonSerializer.Serialize(new ProofSeal([.. from], to));"),
|
||||
('sweeper-no-advance', LEDGER, " await scope.ServiceProvider.GetRequiredService<AccountMergeSaga>().AdvanceDueAsync(now, stoppingToken);\n", ""),
|
||||
('sweeper-no-finalize', LEDGER, " await scope.ServiceProvider.GetRequiredService<AccountMergeLedger>().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)
|
||||
Loading…
Reference in a new issue