Account merge ledger: fix the first Soul pass (suspension carried into a memberless survivor, merged status terminal, standing at the desk door, linked accounts and write time, journalled grace intake, survivor weight on moved votes, one-hop alias, State token and database guards for overlapping merges, live-Postgres tests)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
84ff2c4578
commit
f402eb13cc
23 changed files with 1273 additions and 97 deletions
|
|
@ -80,3 +80,11 @@ Then start the first implementation milestone:
|
|||
- work items
|
||||
- motions
|
||||
- ledgers
|
||||
|
||||
## Live Postgres tests
|
||||
|
||||
Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the migration's rollback guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server:
|
||||
|
||||
```bash
|
||||
BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests
|
||||
```
|
||||
|
|
|
|||
|
|
@ -18,23 +18,23 @@ An account's standing is derived from that state on every request; there is no `
|
|||
| Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` |
|
||||
| Proposed, Failed, Undone | none |
|
||||
|
||||
`ResolveAsync(id)` follows `Folded` links to the end (at most eight hops; each account is folded once). Heimdall patron intake resolves through it, so an event for a folded account's Heimdall id records on the survivor.
|
||||
An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Heimdall patron intake reads the standing: an event for a folded account's Heimdall id records on the survivor and is journalled on the merge (`JournalIntakeAsync`, idempotent for a provider's retry), so an undo moves it back.
|
||||
|
||||
`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw.
|
||||
`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and the vote and appraisal writers read it again at the write, so an actor resolved before the merge froze the account writes nothing after it.
|
||||
|
||||
## Plan, commit, undo, finalize
|
||||
|
||||
`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_survivor_in_grace`, `sealed_ballot_pending`.
|
||||
`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_is_survivor`, `sealed_ballot_pending`. The database backs the two overlap blocks the plan checks first (check-then-act): a partial unique index lets an account be folded by one live merge only (Confirmed through HeimdallReversed), and a trigger refuses a live merge whose survivor is another live merge's folded account, taking advisory locks on both accounts so racing transactions serialise.
|
||||
|
||||
`CommitAsync(mergeId)` needs `HeimdallMoved` (Committed and Final return the receipt; any other state throws). It is total: blocks were checked before the move, so it never refuses on them. It applies everything in one `SaveChanges` together with `State = Committed` and `GraceEndsUtc = CommittedUtc + 7 days`; a failed save leaves the merge `HeimdallMoved` and applies nothing, and a retry applies once.
|
||||
|
||||
What it reduces:
|
||||
|
||||
- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge <id>)`. A survivor without a membership row is not given one.
|
||||
- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge <id>)`. A survivor without a membership row (the chat tier holds none) is given a Suspended one, journalled as `Standing`/`Created`, because an absent membership chats and the next member-access claim would create an Active one; undo removes it unless an admin has taken it over since.
|
||||
- **Membership.** The folded membership becomes `Merged` (weight 0 by the not-Active rule). Roles and admin flags of the folded membership are never copied.
|
||||
- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's weight and cast time; a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched.
|
||||
- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:<survivor>` if the survivor is Active, else it is voided.
|
||||
- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history.
|
||||
- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's cast time and takes the survivor's weight, derived from the survivor's tiers after the patron events moved (the folded weight is journalled for undo); a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched.
|
||||
- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:<survivor>` as a Member voter if the survivor is Active, else it is voided (the prior class and key are journalled).
|
||||
- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history (only `PatronageService` writes Patron snapshots, so a merge with no folded events leaves both tiers as they were).
|
||||
|
||||
Voiding stamps `VoidedByMergeId` and `VoidedUtc`; the model's query filter on `Vote` and `ConceptAppraisal` then removes the row from every tally, including future ones. Nothing is deleted.
|
||||
|
||||
|
|
@ -42,8 +42,16 @@ Left untouched (counted by the plan): consent receipts, passkeys, ledger entries
|
|||
|
||||
`UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to.
|
||||
|
||||
`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`; `AccountMergeSweeper` runs it every five minutes. Undo is never allowed past Final (the saga reads `State` and `GraceEndsUtc`).
|
||||
`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every five minutes. `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final.
|
||||
|
||||
## 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.
|
||||
|
||||
## Rollback
|
||||
|
||||
Voiding lives in the new build's query filter and the `VoidedByMergeId` columns. `Down` refuses to run while any vote or appraisal is voided (dropping the columns would count them again), and an older build run over the migrated schema also counts voided rows, because it has no filter. Rolling the code back while merges have landed is therefore unsafe; whether voided votes should instead leave the `Votes` table (so an older build cannot see them) is an open question for the operator, raised on the cut.
|
||||
|
||||
## Not covered
|
||||
|
||||
`MembershipSynchronizationService` still writes the profile of any GitHub-signed-in row it finds, and re-promotes a bootstrap admin; it is not a merge path and was not changed here. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are.
|
||||
`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. A vote written by a request that passed the standing read at the write and saved after the commit read the votes (microseconds, inside one request) is the one window left; such a vote stays live as a second voice until an operator voids it. Closing it fully needs the tally-time voiding of design section 7, which is not built.
|
||||
|
|
|
|||
|
|
@ -628,9 +628,16 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
modelBuilder.Entity<AccountMerge>(entity =>
|
||||
{
|
||||
entity.Property(x => x.State).HasConversion<string>().HasMaxLength(24);
|
||||
// State is the token: a transition computed from a stale read (the sweeper's Final over a saga's UndoRequested) fails its save.
|
||||
entity.Property(x => x.State).HasConversion<string>().HasMaxLength(24).IsConcurrencyToken();
|
||||
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||
entity.HasIndex(x => new { x.FoldedUserAccountId, x.State });
|
||||
// An account is folded once: the DB refuses a second live merge folding it. The cross-role case (a survivor that is also
|
||||
// folded) cannot be one unique index; the migration's trigger refuses it.
|
||||
entity.HasIndex(x => x.FoldedUserAccountId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live")
|
||||
.HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
entity.HasIndex(x => new { x.SurvivorUserAccountId, x.State });
|
||||
entity.HasIndex(x => new { x.State, x.GraceEndsUtc });
|
||||
entity.HasOne<UserAccount>()
|
||||
|
|
|
|||
|
|
@ -105,6 +105,13 @@ namespace Bifrost.Web.Data.Migrations
|
|||
columns: new[] { "MergeId", "Seq" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_AccountMerges_FoldedUserAccountId_Live",
|
||||
table: "AccountMerges",
|
||||
column: "FoldedUserAccountId",
|
||||
unique: true,
|
||||
filter: "\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMerges_FoldedUserAccountId_State",
|
||||
table: "AccountMerges",
|
||||
|
|
@ -119,11 +126,50 @@ namespace Bifrost.Web.Data.Migrations
|
|||
name: "IX_AccountMerges_SurvivorUserAccountId_State",
|
||||
table: "AccountMerges",
|
||||
columns: new[] { "SurvivorUserAccountId", "State" });
|
||||
|
||||
// The cross-role guard no single unique index can express: a live merge's survivor is never another live merge's folded
|
||||
// account (no chains), whichever order they arrive in. Advisory locks on both accounts, taken in a fixed order, serialise two
|
||||
// transactions racing over the same account, so the second one's check sees the first's committed row.
|
||||
migrationBuilder.Sql(@"
|
||||
CREATE FUNCTION ""AccountMerges_refuse_chain""() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed') THEN
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(LEAST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0));
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(GREATEST(NEW.""SurvivorUserAccountId""::text, NEW.""FoldedUserAccountId""::text), 0));
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM ""AccountMerges"" m
|
||||
WHERE m.""Id"" <> NEW.""Id""
|
||||
AND m.""State"" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')
|
||||
AND (m.""SurvivorUserAccountId"" = NEW.""FoldedUserAccountId"" OR m.""FoldedUserAccountId"" = NEW.""SurvivorUserAccountId""))
|
||||
THEN
|
||||
RAISE EXCEPTION 'account merge chain refused' USING ERRCODE = '23P01';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END
|
||||
$$;
|
||||
CREATE TRIGGER ""AccountMerges_refuse_chain"" BEFORE INSERT OR UPDATE OF ""State"", ""SurvivorUserAccountId"", ""FoldedUserAccountId"" ON ""AccountMerges""
|
||||
FOR EACH ROW EXECUTE FUNCTION ""AccountMerges_refuse_chain""();");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Voiding lives only in the new build's query filter and these columns: dropping them (or running an older build over them)
|
||||
// counts every voided vote again, and a close in that window records a wrong result for good. Refuse while any is voided;
|
||||
// see docs/account-merge.md (Rollback).
|
||||
migrationBuilder.Sql(@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM ""Votes"" WHERE ""VoidedByMergeId"" IS NOT NULL)
|
||||
OR EXISTS (SELECT 1 FROM ""ConceptAppraisals"" WHERE ""VoidedByMergeId"" IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'AddAccountMerges cannot be reverted while merged votes or appraisals are voided';
|
||||
END IF;
|
||||
END
|
||||
$$;");
|
||||
|
||||
migrationBuilder.Sql(@"DROP TRIGGER ""AccountMerges_refuse_chain"" ON ""AccountMerges""; DROP FUNCTION ""AccountMerges_refuse_chain""();");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMergeEntries");
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("character varying(24)");
|
||||
|
|
@ -64,6 +65,11 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FoldedUserAccountId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_AccountMerges_FoldedUserAccountId_Live")
|
||||
.HasFilter("\"State\" IN ('Confirmed', 'HeimdallMoved', 'Committed', 'Final', 'UndoRequested', 'HeimdallReversed')");
|
||||
|
||||
b.HasIndex("FoldedUserAccountId", "State");
|
||||
|
||||
b.HasIndex("State", "GraceEndsUtc");
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public enum AccountMergeEntryAction
|
|||
Moved,
|
||||
Closed,
|
||||
Suspended,
|
||||
Created,
|
||||
Restored,
|
||||
KeptAfterUndo
|
||||
}
|
||||
|
|
@ -81,7 +82,7 @@ public sealed class AccountMergeEntry
|
|||
|
||||
public Guid? PriorUserAccountId { get; set; }
|
||||
|
||||
/// <summary>The prior status or prior voter key.</summary>
|
||||
/// <summary>What the record held before: the prior status; for a moved vote its weight; for a moved appraisal its voter class and key (class|key).</summary>
|
||||
public string? PriorText { get; set; }
|
||||
|
||||
/// <summary>The cast time of a moved vote or appraisal, so undo can tell whether the survivor re-cast it.</summary>
|
||||
|
|
|
|||
|
|
@ -359,6 +359,10 @@ public sealed class Membership
|
|||
|
||||
public ICollection<TierSnapshot> TierSnapshots { get; set; } = [];
|
||||
|
||||
/// <summary>The folded membership of a landed merge. Terminal: only AccountMergeLedger writes it or leaves it, so every other status writer (sign-in sync, bootstrap, claim, approval, suspension) refuses a merged row.</summary>
|
||||
[NotMapped]
|
||||
public bool IsMerged => Status == MembershipStatus.Merged;
|
||||
|
||||
[NotMapped]
|
||||
public decimal EffectiveVotingWeight =>
|
||||
Status != MembershipStatus.Active
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
|
@ -64,6 +65,13 @@ public sealed class ConceptAppraisalService(
|
|||
return CastResult.NoVoter;
|
||||
}
|
||||
|
||||
// A voter resolved before a merge froze the account must not write an appraisal after it: standing is read again at the write.
|
||||
if (voter.UserAccountId is { } voterAccountId &&
|
||||
await AccountMergeLedger.StandingAsync(dbContext, voterAccountId, cancellationToken) is not null)
|
||||
{
|
||||
return CastResult.NoVoter;
|
||||
}
|
||||
|
||||
var subject = await FindSubjectAsync(collection, key, cancellationToken);
|
||||
if (subject is null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -845,8 +845,8 @@ public sealed class HeimdallAuthService(
|
|||
UserAccountId = userAccount.Id,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
// A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it.
|
||||
if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended)
|
||||
// A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it. A merged row is terminal: the claim never reactivates it.
|
||||
if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended && !userAccount.Membership.IsMerged)
|
||||
{
|
||||
userAccount.Membership.Status = MembershipStatus.Active;
|
||||
userAccount.Membership.ApprovedAtUtc ??= now;
|
||||
|
|
|
|||
|
|
@ -66,17 +66,25 @@ public sealed class HeimdallPatronSupportIntakeService(
|
|||
x.ProviderEventId == request.ProviderEventId.Trim(),
|
||||
cancellationToken);
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken);
|
||||
|
||||
// A folded account's events record on the survivor and are journalled on the merge, so an undo moves them back.
|
||||
var folded = user is null ? null : await mergeLedger.StandingAsync(user.Id, cancellationToken) as MergeStanding.Folded;
|
||||
|
||||
if (existingEvent is not null)
|
||||
{
|
||||
if (folded is not null && user is not null)
|
||||
{
|
||||
await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, existingEvent.Id, cancellationToken);
|
||||
}
|
||||
|
||||
return HeimdallPatronSupportIntakeResult.Processed(
|
||||
$"Already processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
existingEvent.Id);
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return HeimdallPatronSupportIntakeResult.BadRequest(
|
||||
|
|
@ -85,7 +93,7 @@ public sealed class HeimdallPatronSupportIntakeService(
|
|||
|
||||
var supportEvent = await patronageService.RecordSupportEventAsync(
|
||||
actorUserAccountId: null,
|
||||
userAccountId: await mergeLedger.ResolveAsync(user.Id, cancellationToken), // a folded account's events record on the survivor
|
||||
userAccountId: folded?.SurvivorId ?? user.Id,
|
||||
request.Kind,
|
||||
request.Amount,
|
||||
request.CurrencyCode,
|
||||
|
|
@ -99,6 +107,11 @@ public sealed class HeimdallPatronSupportIntakeService(
|
|||
request.Notes,
|
||||
cancellationToken);
|
||||
|
||||
if (folded is not null)
|
||||
{
|
||||
await mergeLedger.JournalIntakeAsync(folded.MergeId, user.Id, supportEvent.Id, cancellationToken);
|
||||
}
|
||||
|
||||
return HeimdallPatronSupportIntakeResult.Processed(
|
||||
$"Processed {request.Provider} patron support event {request.ProviderEventId}.",
|
||||
supportEvent.Id);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
|
|
@ -29,7 +30,7 @@ public sealed record MergePlan(
|
|||
|
||||
/// <summary>
|
||||
/// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed
|
||||
/// save, the undo save, the Final transition, StandingAsync and ResolveAsync. A merge only reduces: nothing is deleted, votes and
|
||||
/// save, the undo save, the Final transition and StandingAsync. A merge only reduces: nothing is deleted, votes and
|
||||
/// appraisals are stamped void (the model's query filter then removes them from every tally) or re-pointed, and every change is
|
||||
/// journalled in AccountMergeEntry. Design: /opt/commons/handoff/heimdall-account-merge-design-20261113.md sections 3, 5, 7.
|
||||
/// </summary>
|
||||
|
|
@ -40,8 +41,6 @@ public sealed class AccountMergeLedger(
|
|||
{
|
||||
public static readonly TimeSpan Grace = TimeSpan.FromDays(7);
|
||||
|
||||
private const int MaxHops = 8;
|
||||
|
||||
private static readonly AccountMergeState[] FrozenStates = [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved];
|
||||
|
||||
private static readonly AccountMergeState[] FoldedStates =
|
||||
|
|
@ -71,23 +70,6 @@ public sealed class AccountMergeLedger(
|
|||
: new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId);
|
||||
}
|
||||
|
||||
/// <summary>Follows Folded links to the account that now carries the person. Each account is folded once, so the hop cap is only a guard.</summary>
|
||||
public async Task<Guid> ResolveAsync(Guid userAccountId, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = userAccountId;
|
||||
for (var hop = 0; hop < MaxHops; hop++)
|
||||
{
|
||||
if (await StandingAsync(current, cancellationToken) is not MergeStanding.Folded folded)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = folded.SurvivorId;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>Writes nothing. <paramref name="exceptMergeId"/> lets the saga re-plan a merge it already holds without it blocking itself.</summary>
|
||||
public async Task<MergePlan> PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null)
|
||||
{
|
||||
|
|
@ -155,12 +137,12 @@ public sealed class AccountMergeLedger(
|
|||
blocks.Add("already_folded");
|
||||
}
|
||||
|
||||
// Heimdall could no longer reverse a merge this account survived once it is folded into another.
|
||||
// One hop only: a landed merge's survivor never folds into a third account, so an alias is never a chain.
|
||||
if (await dbContext.AccountMerges.AnyAsync(
|
||||
x => x.Id != exceptMergeId && x.State == AccountMergeState.Committed && x.SurvivorUserAccountId == foldedId,
|
||||
x => x.Id != exceptMergeId && FoldedStates.Contains(x.State) && x.SurvivorUserAccountId == foldedId,
|
||||
cancellationToken))
|
||||
{
|
||||
blocks.Add("folded_survivor_in_grace");
|
||||
blocks.Add("folded_is_survivor");
|
||||
}
|
||||
|
||||
if (await dbContext.SealedBallots.AnyAsync(
|
||||
|
|
@ -212,6 +194,7 @@ public sealed class AccountMergeLedger(
|
|||
await RestageTiersAsync(reduction.Survivor, reduction.Folded, now, cancellationToken);
|
||||
}
|
||||
|
||||
reduction.ReweighMovedVotes();
|
||||
merge.State = AccountMergeState.Committed;
|
||||
merge.CommittedUtc = now;
|
||||
merge.GraceEndsUtc = now + Grace;
|
||||
|
|
@ -240,6 +223,11 @@ public sealed class AccountMergeLedger(
|
|||
throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallReversed merge can be undone locally.");
|
||||
}
|
||||
|
||||
if (merge.FinalizedUtc is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Merge {mergeId} is Final; a merge is never undone past Final.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
|
@ -264,7 +252,7 @@ public sealed class AccountMergeLedger(
|
|||
AtUtc = now
|
||||
});
|
||||
|
||||
foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended))
|
||||
foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended or AccountMergeEntryAction.Created))
|
||||
{
|
||||
switch (entry.Kind, entry.Action)
|
||||
{
|
||||
|
|
@ -288,6 +276,10 @@ public sealed class AccountMergeLedger(
|
|||
if (restore)
|
||||
{
|
||||
vote!.UserAccountId = entry.PriorUserAccountId!.Value;
|
||||
if (decimal.TryParse(entry.PriorText, NumberStyles.Number, CultureInfo.InvariantCulture, out var priorWeight))
|
||||
{
|
||||
vote.Weight = priorWeight;
|
||||
}
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
|
|
@ -313,7 +305,9 @@ public sealed class AccountMergeLedger(
|
|||
if (restore)
|
||||
{
|
||||
row!.UserAccountId = entry.PriorUserAccountId!.Value;
|
||||
row.VoterKey = entry.PriorText ?? row.VoterKey;
|
||||
var (priorClass, priorKey) = ParseAppraisalPrior(entry.PriorText);
|
||||
row.VoterKey = priorKey ?? row.VoterKey;
|
||||
row.VoterClass = priorClass ?? row.VoterClass;
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
|
|
@ -345,6 +339,19 @@ public sealed class AccountMergeLedger(
|
|||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created):
|
||||
{
|
||||
// The membership this merge made to carry a suspension goes with the merge, unless an admin has taken it over since.
|
||||
var created = await dbContext.Memberships.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
var restore = created is { Status: MembershipStatus.Suspended } && created.SuspensionReason == SuspensionReason(mergeId);
|
||||
if (restore)
|
||||
{
|
||||
dbContext.Memberships.Remove(created!);
|
||||
}
|
||||
|
||||
Record(entry, restore);
|
||||
break;
|
||||
}
|
||||
case (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved):
|
||||
{
|
||||
var row = await dbContext.PatronSupportEvents.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken);
|
||||
|
|
@ -378,20 +385,62 @@ public sealed class AccountMergeLedger(
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Committed merges whose grace has ended become Final; returns how many. Undo is never allowed past Final.</summary>
|
||||
/// <summary>
|
||||
/// Committed merges whose grace has ended become Final; returns how many this call finalized. Undo is never allowed past Final.
|
||||
/// State is the merge's concurrency token, so a merge another writer moved first (UndoRequested) fails its save and is left to that
|
||||
/// transition: a stale Committed read can never overwrite it.
|
||||
/// </summary>
|
||||
public async Task<int> FinalizeDueAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
var due = await dbContext.AccountMerges
|
||||
var dueIds = await dbContext.AccountMerges.AsNoTracking()
|
||||
.Where(x => x.State == AccountMergeState.Committed && x.GraceEndsUtc <= now)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var merge in due)
|
||||
var finalized = 0;
|
||||
foreach (var id in dueIds)
|
||||
{
|
||||
var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == id, cancellationToken);
|
||||
if (merge.State != AccountMergeState.Committed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
merge.State = AccountMergeState.Final;
|
||||
merge.FinalizedUtc = now;
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
finalized++;
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
return finalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Journals a patron event that landed on a folded account's survivor during grace, so undo moves it back with the rest. Idempotent:
|
||||
/// the intake calls it again when the provider retries an event it already processed.
|
||||
/// </summary>
|
||||
public async Task JournalIntakeAsync(Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await dbContext.AccountMergeEntries.AnyAsync(
|
||||
x => x.MergeId == mergeId && x.Kind == AccountMergeEntryKind.PatronSupportEvent && x.RecordId == supportEventId,
|
||||
cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seq = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0;
|
||||
dbContext.AccountMergeEntries.Add(Journal(
|
||||
mergeId,
|
||||
seq + 1,
|
||||
new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEventId, foldedUserAccountId),
|
||||
timeProvider.GetUtcNow()));
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return due.Count;
|
||||
}
|
||||
|
||||
private sealed class Reduction(
|
||||
|
|
@ -399,6 +448,7 @@ public sealed class AccountMergeLedger(
|
|||
UserAccount folded,
|
||||
List<MergePlanEntry> entries,
|
||||
List<Action<Guid, DateTimeOffset>> effects,
|
||||
List<Vote> movedVotes,
|
||||
bool movedPatronEvents)
|
||||
{
|
||||
public UserAccount Survivor { get; } = survivor;
|
||||
|
|
@ -411,6 +461,15 @@ public sealed class AccountMergeLedger(
|
|||
|
||||
public Guid MergeId { get; set; }
|
||||
|
||||
/// <summary>A moved vote carries the survivor's weight, derived once from the survivor's tiers as they stand after the restage, never the folded account's.</summary>
|
||||
public void ReweighMovedVotes()
|
||||
{
|
||||
foreach (var vote in movedVotes)
|
||||
{
|
||||
vote.Weight = Survivor.Membership!.EffectiveVotingWeight;
|
||||
}
|
||||
}
|
||||
|
||||
public void Apply(DateTimeOffset now)
|
||||
{
|
||||
foreach (var effect in effects)
|
||||
|
|
@ -428,25 +487,43 @@ public sealed class AccountMergeLedger(
|
|||
/// </summary>
|
||||
private async Task<Reduction> ReduceAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? mergeId = null)
|
||||
{
|
||||
var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents)
|
||||
var survivor = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == survivorId, cancellationToken);
|
||||
var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents)
|
||||
var folded = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents)
|
||||
.SingleAsync(x => x.Id == foldedId, cancellationToken);
|
||||
|
||||
var entries = new List<MergePlanEntry>();
|
||||
var effects = new List<Action<Guid, DateTimeOffset>>();
|
||||
var movedVotes = new List<Vote>();
|
||||
|
||||
var foldedSuspended = folded.Membership?.Status == MembershipStatus.Suspended;
|
||||
var survivorSuspendedNow = survivor.Membership?.Status == MembershipStatus.Suspended;
|
||||
var survivorStanding = survivor.Membership?.Status;
|
||||
if (foldedSuspended && !survivorSuspendedNow && survivor.Membership is { } survivorMembership)
|
||||
if (foldedSuspended && !survivorSuspendedNow)
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString()));
|
||||
effects.Add((id, _) =>
|
||||
if (survivor.Membership is { } survivorMembership)
|
||||
{
|
||||
survivorMembership.Status = MembershipStatus.Suspended;
|
||||
survivorMembership.SuspensionReason = SuspensionReason(id);
|
||||
});
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString()));
|
||||
effects.Add((id, _) =>
|
||||
{
|
||||
survivorMembership.Status = MembershipStatus.Suspended;
|
||||
survivorMembership.SuspensionReason = SuspensionReason(id);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// The chat tier holds no membership, and an absent one chats: without a row the suspension would be gone. The row is journalled so undo removes it.
|
||||
var created = new Bifrost.Web.Domain.Membership { UserAccountId = survivor.Id, Status = MembershipStatus.Suspended };
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created, created.Id));
|
||||
effects.Add((id, now) =>
|
||||
{
|
||||
created.SuspensionReason = SuspensionReason(id);
|
||||
created.CreatedAtUtc = now;
|
||||
created.Notes = "Created by an account merge to carry the folded account's suspension.";
|
||||
dbContext.Memberships.Add(created);
|
||||
});
|
||||
}
|
||||
|
||||
survivorStanding = MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
|
|
@ -478,7 +555,8 @@ public sealed class AccountMergeLedger(
|
|||
}
|
||||
else
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, null, vote.CastAtUtc));
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, vote.Weight.ToString(CultureInfo.InvariantCulture), vote.CastAtUtc));
|
||||
movedVotes.Add(vote);
|
||||
effects.Add((_, _) => vote.UserAccountId = survivorId);
|
||||
}
|
||||
}
|
||||
|
|
@ -502,11 +580,12 @@ public sealed class AccountMergeLedger(
|
|||
}
|
||||
else
|
||||
{
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, appraisal.VoterKey, appraisal.CastAtUtc));
|
||||
entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, $"{appraisal.VoterClass}|{appraisal.VoterKey}", appraisal.CastAtUtc));
|
||||
effects.Add((_, _) =>
|
||||
{
|
||||
appraisal.UserAccountId = survivorId;
|
||||
appraisal.VoterKey = survivorKey;
|
||||
appraisal.VoterClass = VoterClass.Member; // the survivor is Active here, which is what makes an account a Member voter
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -518,7 +597,7 @@ public sealed class AccountMergeLedger(
|
|||
effects.Add((_, _) => supportEvent.UserAccountId = survivorId);
|
||||
}
|
||||
|
||||
var reduction = new Reduction(survivor, folded, entries, effects, foldedEvents.Count > 0);
|
||||
var reduction = new Reduction(survivor, folded, entries, effects, movedVotes, foldedEvents.Count > 0);
|
||||
if (mergeId is { } m)
|
||||
{
|
||||
reduction.MergeId = m;
|
||||
|
|
@ -538,7 +617,7 @@ public sealed class AccountMergeLedger(
|
|||
|
||||
private async Task StageIfMembershipAsync(Bifrost.Web.Domain.Membership? membership, IEnumerable<PatronSupportEvent> events, DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (membership is not null)
|
||||
if (membership is not null && dbContext.Entry(membership).State != EntityState.Deleted)
|
||||
{
|
||||
await patronageService.StageTierSnapshotAsync(membership, events, now, cancellationToken);
|
||||
}
|
||||
|
|
@ -558,6 +637,12 @@ public sealed class AccountMergeLedger(
|
|||
};
|
||||
|
||||
private static string AppraisalKey(Guid accountId) => "account:" + accountId.ToString("N");
|
||||
|
||||
private static (VoterClass? Class, string? Key) ParseAppraisalPrior(string? prior)
|
||||
{
|
||||
var split = prior?.IndexOf('|') ?? -1;
|
||||
return split > 0 && Enum.TryParse<VoterClass>(prior![..split], out var voterClass) ? (voterClass, prior[(split + 1)..]) : (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Finalizes merges past their grace every five minutes. Copies the SealedBallotCloser shape: a new scope per tick, a warning on failure.</summary>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,12 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi
|
|||
return SuspensionOutcome.NoSuchAccount;
|
||||
}
|
||||
|
||||
// A merged account is terminal: only the merge ledger leaves that status, so neither suspending nor lifting applies.
|
||||
if (membership.IsMerged)
|
||||
{
|
||||
return SuspensionOutcome.NotApplicable;
|
||||
}
|
||||
|
||||
// An admin cannot suspend themselves or another platform admin here (the same derivation that makes an actor an admin):
|
||||
// bootstrap admins are re-promoted to Active at sign-in by configuration, so the suspension would be undone without a receipt.
|
||||
if (suspend && (user.Id == actor.UserAccount.Id || new CurrentBifrostActor(true, user).IsPlatformAdmin))
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -52,6 +53,13 @@ public sealed class MotionGovernanceService(
|
|||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required.");
|
||||
|
||||
// An actor resolved before a merge froze this account must not write a vote after it: standing is read again at the write.
|
||||
if (await AccountMergeLedger.StandingAsync(dbContext, actorUser.Id, cancellationToken) is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var motion = await dbContext.Motions
|
||||
.Include(x => x.Votes)
|
||||
.SingleAsync(x => x.Id == motionId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -194,6 +194,12 @@ public static class OidcEndpoints
|
|||
return null;
|
||||
}
|
||||
|
||||
// A folded or frozen subject's live token acts as nobody on the desk too: the same standing the cookie gate and ChatRefusalAsync read.
|
||||
if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
|
|
|
|||
|
|
@ -55,12 +55,18 @@ public sealed class ApplicationBootstrapper(
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's
|
||||
/// The one bootstrap-admin promotion, used at startup and at sign-in. A Merged or Suspended account is left exactly as it is (an admin's
|
||||
/// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted.
|
||||
/// </summary>
|
||||
public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger)
|
||||
{
|
||||
var membership = account.Membership!;
|
||||
if (membership.IsMerged)
|
||||
{
|
||||
logger.LogWarning("Bootstrap admin promotion skipped for merged account {UserAccountId}.", account.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (membership.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ public sealed class IndexModel(
|
|||
internal const string ApproveSuspendedNotice = "Nothing changed: a suspended account can only be lifted with Unsuspend.";
|
||||
/// <summary>Admin-only text for a Suspended row whose reason predates the SuspensionReason column; the old text is never read back from the audit trail.</summary>
|
||||
internal const string LegacySuspensionText = "Suspended before reasons were recorded";
|
||||
internal const string ApproveMergedNotice = "Nothing changed: that account was merged into another and cannot be approved.";
|
||||
|
||||
internal const string NoMembershipNotice = "Nothing changed: that account has no membership yet.";
|
||||
internal const string OwnAdminRoleNotice = "Nothing changed: you cannot remove or change your own platform admin role.";
|
||||
internal const string RoleOnSuspendedNotice = "Nothing changed: a suspended account's roles cannot be changed until it is unsuspended.";
|
||||
|
|
@ -136,6 +138,12 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
if (user.Membership.IsMerged)
|
||||
{
|
||||
Notice = ApproveMergedNotice; // only the merge ledger leaves Merged
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
user.Membership.Status = MembershipStatus.Active;
|
||||
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using Bifrost.Web.Features.Passkeys;
|
|||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ public sealed class LinkedAccountsModel(
|
|||
HeimdallAuthService heimdallAuthService,
|
||||
LinkedAccountsSnapshots snapshots,
|
||||
PasskeyService passkeys,
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
ILogger<LinkedAccountsModel> logger) : PageModel
|
||||
{
|
||||
|
|
@ -33,6 +35,21 @@ public sealed class LinkedAccountsModel(
|
|||
["google"] = "Google"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// A folded or frozen account manages no logins and enrols no passkey: the page's handlers all begin a Heimdall flow on the
|
||||
/// account's raw claim, so the one gate is here, on the actor accessor's standing, before any handler runs.
|
||||
/// </summary>
|
||||
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
|
||||
{
|
||||
if ((await actors.GetAsync(context.HttpContext.RequestAborted)).Merge is not null)
|
||||
{
|
||||
context.Result = Forbid();
|
||||
return;
|
||||
}
|
||||
|
||||
await base.OnPageHandlerExecutionAsync(context, next);
|
||||
}
|
||||
|
||||
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
|
||||
|
||||
/// <summary>The wording of the outcome the page was sent back with: a closed set, null for anything else.</summary>
|
||||
|
|
|
|||
|
|
@ -856,7 +856,10 @@ app.MapPost("/auth/heimdall/complete", async (
|
|||
held.Value.Value,
|
||||
form?["code"].ToString(),
|
||||
form?["pendingLinkId"].ToString(),
|
||||
httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId),
|
||||
// A folded or frozen account links nothing: the actor accessor's standing decides, not the raw claim.
|
||||
(await httpContext.RequestServices.GetRequiredService<ICurrentBifrostActorAccessor>().GetAsync(cancellationToken)).Merge is null
|
||||
? httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId)
|
||||
: null,
|
||||
cancellationToken);
|
||||
if (step.Next is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -125,14 +125,11 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I
|
|||
Assert.Equal(1, await VoteRowsAsync(s.Motion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor()
|
||||
private async Task<HttpStatusCode> IntakeAsync(Seeded s, string eventId)
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await MergeAsync(s, AccountMergeState.Final);
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = $"WH-{Guid.NewGuid():N}", kind = "OneTimeDonation",
|
||||
heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = eventId, kind = "OneTimeDonation",
|
||||
amount = 25m, currencyCode = "USD", externalSupportId = "CAPTURE-1", supportedAtUtc = DateTimeOffset.UtcNow, isCurrentRecurringSupport = false
|
||||
});
|
||||
using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("test-heimdall-intake-secret"));
|
||||
|
|
@ -140,11 +137,151 @@ public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : I
|
|||
request.Headers.Add("X-Heimdall-Signature-256", "sha256=" + Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant());
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
var owners = await WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync());
|
||||
Assert.Equal([s.Survivor], owners);
|
||||
private Task<List<Guid>> EventOwnersAsync(Seeded s) =>
|
||||
WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync());
|
||||
|
||||
/// <summary>Lands the merge through the ledger's own commit, as the saga will: the move done, then the Bifrost records reduced.</summary>
|
||||
private async Task<Guid> CommitMergeAsync(Seeded s)
|
||||
{
|
||||
var merge = await MergeAsync(s, AccountMergeState.HeimdallMoved);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().CommitAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
return merge;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await MergeAsync(s, AccountMergeState.Final);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}"));
|
||||
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_patron_event_that_lands_during_grace_is_journalled_once_and_undo_moves_it_back_to_the_folded_account()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
var merge = await CommitMergeAsync(s);
|
||||
var eventId = $"WH-{Guid.NewGuid():N}";
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId));
|
||||
Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); // the provider retries: nothing doubles
|
||||
Assert.Equal([s.Survivor], await EventOwnersAsync(s));
|
||||
Assert.Equal(1, await WithDb(db => db.AccountMergeEntries.CountAsync(x => x.MergeId == merge && x.Kind == AccountMergeEntryKind.PatronSupportEvent)));
|
||||
|
||||
await SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await WithDb(async db =>
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<Bifrost.Web.Features.Membership.AccountMergeLedger>().UndoLocalAsync(merge, default);
|
||||
return 0;
|
||||
});
|
||||
|
||||
Assert.Equal([s.Folded], await EventOwnersAsync(s));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_folded_bootstrap_admins_sign_in_does_not_reactivate_the_merged_membership_and_an_approval_is_refused()
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
await CommitMergeAsync(s);
|
||||
using var folded = ClientFor(s.FoldedGitHubId, "test-admin"); // the bootstrap login: every request would re-promote it
|
||||
|
||||
using (await folded.GetAsync("/"))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.Equal((MembershipStatus.Merged, false), await WithDb(async db =>
|
||||
{
|
||||
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded);
|
||||
return (membership.Status, membership.IsPlatformAdmin);
|
||||
}));
|
||||
|
||||
using var admin = ClientFor(Random.Shared.NextInt64(1_000_000_000, 2_000_000_000), "test-admin");
|
||||
var token = await TokenAsync(admin);
|
||||
using var approve = await admin.PostAsync("/App/Members?handler=Approve", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["userAccountId"] = s.Folded.ToString(), ["__RequestVerificationToken"] = token
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Redirect, approve.StatusCode);
|
||||
Assert.Equal(MembershipStatus.Merged, await WithDb(async db => (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded)).Status));
|
||||
}
|
||||
|
||||
/// <summary>Forbid answers 403 or sends the browser to the access-denied page; either way the page and its Heimdall flows are not served.</summary>
|
||||
private static void AssertRefused(HttpResponseMessage response)
|
||||
{
|
||||
Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString());
|
||||
Assert.DoesNotContain("heimdall", response.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
public async Task A_frozen_or_folded_cookie_cannot_reach_the_linked_accounts_page_or_start_one_of_its_flows(AccountMergeState state)
|
||||
{
|
||||
var s = await SeedAsync();
|
||||
using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}");
|
||||
var token = await TokenAsync(folded);
|
||||
using (var control = await folded.GetAsync("/Membership/LinkedAccounts"))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, control.StatusCode); // before the merge the page is theirs
|
||||
}
|
||||
|
||||
await MergeAsync(s, state);
|
||||
|
||||
using var page = await folded.GetAsync("/Membership/LinkedAccounts");
|
||||
AssertRefused(page);
|
||||
using var refresh = await folded.PostAsync("/Membership/LinkedAccounts?handler=Refresh", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["via"] = "google", ["__RequestVerificationToken"] = token
|
||||
}));
|
||||
AssertRefused(refresh);
|
||||
|
||||
using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}");
|
||||
using var other = await survivor.GetAsync("/Membership/LinkedAccounts");
|
||||
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The desk's bearer door judges standing the way the cookie gate and the chat refusal do.</summary>
|
||||
public sealed class AccountMergeDeskTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
public async Task A_frozen_or_folded_subjects_live_desk_token_is_forbidden(AccountMergeState state)
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
|
||||
Task<DeskReply> ListAsync() => factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token);
|
||||
|
||||
Assert.Equal(200, (await ListAsync()).Status); // the live member, before the merge
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var survivor = new UserAccount { BifrostIdentity = "desk-survivor", NormalizedBifrostIdentity = "desk-survivor", GitHubLogin = "desk-survivor", NormalizedGitHubLogin = "desk-survivor" };
|
||||
db.UserAccounts.Add(survivor);
|
||||
db.AccountMerges.Add(new AccountMerge
|
||||
{
|
||||
SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = Guid.Parse(DeskWebApplicationFactory.Alice), State = state, CreatedUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(403, (await ListAsync()).Status);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,29 +18,44 @@ namespace Bifrost.Web.Tests;
|
|||
/// </summary>
|
||||
public sealed class AccountMergeLedgerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
public static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private sealed class Clock(DateTimeOffset start) : TimeProvider
|
||||
public sealed class Clock(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
private sealed class World
|
||||
/// <summary>The merge fixtures. In memory by default; <see cref="OnPostgres"/> points the same fixtures at a real database, where unique indexes, transactions, tokens and triggers exist.</summary>
|
||||
public sealed class World
|
||||
{
|
||||
private readonly string _name = $"merge-{Guid.NewGuid():N}";
|
||||
private readonly SaveChangesInterceptor[] _interceptors;
|
||||
private readonly string? _postgres;
|
||||
|
||||
public World(params SaveChangesInterceptor[] interceptors) => _interceptors = interceptors;
|
||||
|
||||
private World(string postgres, SaveChangesInterceptor[] interceptors) : this(interceptors) => _postgres = postgres;
|
||||
|
||||
public static World OnPostgres(string connectionString, params SaveChangesInterceptor[] interceptors) => new(connectionString, interceptors);
|
||||
|
||||
public Clock Clock { get; } = new(T0);
|
||||
|
||||
public BifrostDbContext Db() => new(new DbContextOptionsBuilder<BifrostDbContext>()
|
||||
.UseInMemoryDatabase(_name)
|
||||
.AddInterceptors(_interceptors)
|
||||
.ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options);
|
||||
public BifrostDbContext Db()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<BifrostDbContext>().AddInterceptors(_interceptors);
|
||||
if (_postgres is null)
|
||||
{
|
||||
options.UseInMemoryDatabase(_name).ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning));
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UseNpgsql(_postgres);
|
||||
}
|
||||
|
||||
return new BifrostDbContext(options.Options);
|
||||
}
|
||||
|
||||
public AccountMergeLedger Ledger(BifrostDbContext db) => new(db, new PatronageService(db, new AuditTrailService(db, Clock), Clock), Clock);
|
||||
|
||||
|
|
@ -449,22 +464,6 @@ public sealed class AccountMergeLedgerTests
|
|||
|
||||
await using var db = world.Db();
|
||||
Assert.Null(await world.Ledger(db).StandingAsync(folded.Id, default));
|
||||
Assert.Equal(folded.Id, await world.Ledger(db).ResolveAsync(folded.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_follows_folded_links_to_the_end()
|
||||
{
|
||||
var world = new World();
|
||||
var a = await world.AccountAsync("acct-a");
|
||||
var b = await world.AccountAsync("acct-b");
|
||||
var c = await world.AccountAsync("acct-c");
|
||||
await world.MergeAsync(b, a, AccountMergeState.Final);
|
||||
await world.MergeAsync(c, b, AccountMergeState.Committed);
|
||||
|
||||
await using var db = world.Db();
|
||||
Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(a.Id, default));
|
||||
Assert.Equal(c.Id, await world.Ledger(db).ResolveAsync(c.Id, default));
|
||||
}
|
||||
|
||||
// ---- plan ----
|
||||
|
|
@ -494,7 +493,7 @@ public sealed class AccountMergeLedgerTests
|
|||
}
|
||||
|
||||
public static IEnumerable<object[]> BlockCases() =>
|
||||
new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_survivor_in_grace", "sealed_ballot_pending" }
|
||||
new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_is_survivor", "sealed_ballot_pending" }
|
||||
.Select(x => new object[] { x });
|
||||
|
||||
[Theory]
|
||||
|
|
@ -528,8 +527,8 @@ public sealed class AccountMergeLedgerTests
|
|||
case "already_folded":
|
||||
await world.MergeAsync(other, folded, AccountMergeState.Committed);
|
||||
break;
|
||||
case "folded_survivor_in_grace":
|
||||
await world.MergeAsync(folded, other, AccountMergeState.Committed);
|
||||
case "folded_is_survivor":
|
||||
await world.MergeAsync(folded, other, AccountMergeState.Final);
|
||||
break;
|
||||
case "sealed_ballot_pending":
|
||||
var motion = await world.MotionAsync(kind: MotionKind.SealedBallot);
|
||||
|
|
@ -722,4 +721,366 @@ public sealed class AccountMergeLedgerTests
|
|||
Assert.Equal((AccountMergeState.Final, T0.AddDays(7)), (final.State, final.FinalizedUtc));
|
||||
Assert.Equal(AccountMergeState.Committed, (await read.AccountMerges.SingleAsync(x => x.Id == youngMerge.Id)).State);
|
||||
}
|
||||
|
||||
// ---- verdict cut-merge-ledger.s1: the rules each fix decides ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.HeimdallMoved)]
|
||||
[InlineData(AccountMergeState.UndoRequested)]
|
||||
[InlineData(AccountMergeState.HeimdallReversed)]
|
||||
public async Task Plan_blocks_while_either_account_is_in_a_merge_in_flight(AccountMergeState state)
|
||||
{
|
||||
foreach (var survivorSide in new[] { true, false })
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var other = await world.AccountAsync("other");
|
||||
await (survivorSide ? world.MergeAsync(survivor, other, state) : world.MergeAsync(other, folded, state));
|
||||
|
||||
await using var read = world.Db();
|
||||
Assert.Contains("merge_in_flight", (await world.Ledger(read).PlanAsync(survivor.Id, folded.Id, default)).Blocks);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
[InlineData(AccountMergeState.Final)]
|
||||
public async Task A_landed_merges_survivor_cannot_itself_fold_so_an_alias_is_never_a_chain(AccountMergeState state)
|
||||
{
|
||||
var world = new World();
|
||||
var a = await world.AccountAsync("acct-a");
|
||||
var b = await world.AccountAsync("acct-b");
|
||||
var c = await world.AccountAsync("acct-c");
|
||||
await world.MergeAsync(b, a, state);
|
||||
|
||||
await using var read = world.Db();
|
||||
Assert.Contains("folded_is_survivor", (await world.Ledger(read).PlanAsync(c.Id, b.Id, default)).Blocks);
|
||||
Assert.Empty((await world.Ledger(read).PlanAsync(b.Id, c.Id, default)).Blocks); // the other direction is an ordinary merge
|
||||
Assert.Equal(b.Id, ((MergeStanding.Folded)(await world.Ledger(read).StandingAsync(a.Id, default))!).SurvivorId);
|
||||
}
|
||||
|
||||
private static async Task DropMembershipAsync(World world, UserAccount account)
|
||||
{
|
||||
await using var db = world.Db();
|
||||
db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == account.Id));
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspension_is_not_laundered_into_a_survivor_without_a_membership_and_undo_removes_the_row_it_made()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
await DropMembershipAsync(world, survivor);
|
||||
var folded = await world.AccountAsync("folded", MembershipStatus.Suspended);
|
||||
var merge = await world.MergeAsync(survivor, folded);
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var plan = await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default);
|
||||
Assert.Contains(plan.Entries, e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Created });
|
||||
Assert.Empty(db.Memberships.Where(x => x.UserAccountId == survivor.Id)); // a plan writes nothing
|
||||
}
|
||||
|
||||
await world.CommitAsync(merge);
|
||||
|
||||
var carried = await world.MembershipOfAsync(survivor);
|
||||
Assert.Equal(MembershipStatus.Suspended, carried.Status);
|
||||
Assert.Equal(AccountMergeLedger.SuspensionReason(merge.Id), carried.SuspensionReason);
|
||||
Assert.False((await world.ActorAsync(survivor.Id)).IsActiveMember);
|
||||
|
||||
await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Ledger(db).UndoLocalAsync(merge.Id, default);
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
Assert.Empty(read.Memberships.Where(x => x.UserAccountId == survivor.Id));
|
||||
Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Restored });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Undo_keeps_the_membership_a_merge_made_for_a_suspension_once_an_admin_has_taken_it_over()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
await DropMembershipAsync(world, survivor);
|
||||
var folded = await world.AccountAsync("folded", MembershipStatus.Suspended);
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
(await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)).SuspensionReason = "steward decision";
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Ledger(db).UndoLocalAsync(merge.Id, default);
|
||||
}
|
||||
|
||||
Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(survivor)).Status);
|
||||
Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.KeptAfterUndo });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_moved_vote_carries_the_survivors_weight_not_the_folded_accounts_and_undo_restores_it()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var moderator = await world.AccountAsync("mod", moderator: true);
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For, 5m);
|
||||
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
var moved = Assert.Single(await world.AllVotesAsync(motion));
|
||||
Assert.Equal((survivor.Id, 1m), (moved.UserAccountId, moved.Weight));
|
||||
Assert.Equal(1m, (await world.StateOfAsync(motion, moderator)).VotesFor);
|
||||
|
||||
await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Ledger(db).UndoLocalAsync(merge.Id, default);
|
||||
}
|
||||
|
||||
var back = Assert.Single(await world.AllVotesAsync(motion));
|
||||
Assert.Equal((folded.Id, 5m), (back.UserAccountId, back.Weight));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_moved_vote_takes_the_survivors_weight_after_the_patron_tiers_were_derived_again()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For, 1m);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
db.PatronSupportEvents.Add(new PatronSupportEvent
|
||||
{
|
||||
UserAccountId = folded.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 100m,
|
||||
ProviderEventId = "e1", SupportedAtUtc = T0, RecordedAtUtc = T0
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
var weight = (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight;
|
||||
Assert.Equal(2m, weight);
|
||||
Assert.Equal(weight, Assert.Single(await world.AllVotesAsync(motion)).Weight);
|
||||
}
|
||||
|
||||
private static async Task<(Guid Both, Guid OnlyFolded)> SeedAppraisalsAsync(World world, UserAccount survivor, UserAccount folded, VoterClass foldedClass)
|
||||
{
|
||||
await using var db = world.Db();
|
||||
var both = new ConceptSubject { Collection = "c", Key = "both", Title = "Both", RegisteredAtUtc = T0 };
|
||||
var only = new ConceptSubject { Collection = "c", Key = "only-folded", Title = "Only folded", RegisteredAtUtc = T0 };
|
||||
db.ConceptSubjects.AddRange(both, only);
|
||||
ConceptAppraisal Row(ConceptSubject subject, UserAccount who, ConceptStance stance, VoterClass voterClass) => new()
|
||||
{
|
||||
SubjectId = subject.Id, UserAccountId = who.Id, VoterKey = "account:" + who.Id.ToString("N"), Stance = stance, VoterClass = voterClass, CastAtUtc = T0
|
||||
};
|
||||
db.ConceptAppraisals.AddRange(Row(both, survivor, ConceptStance.Up, VoterClass.Member), Row(both, folded, ConceptStance.Down, foldedClass), Row(only, folded, ConceptStance.Down, foldedClass));
|
||||
await db.SaveChangesAsync();
|
||||
return (both.Id, only.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lone_folded_appraisal_is_voided_when_the_survivor_is_not_active()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor", MembershipStatus.PendingApproval);
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Member);
|
||||
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
await using var read = world.Db();
|
||||
var row = await read.ConceptAppraisals.IgnoreQueryFilters().SingleAsync(x => x.SubjectId == onlyFolded);
|
||||
Assert.Equal((merge.Id, folded.Id), (row.VoidedByMergeId, row.UserAccountId));
|
||||
Assert.Equal(0, (await world.Concepts(read).GetTalliesAsync("c", default))["only-folded"].Down);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_moved_appraisal_takes_the_member_class_and_undo_gives_back_the_class_and_key_unless_the_survivor_recast_it()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Account);
|
||||
var survivorKey = "account:" + survivor.Id.ToString("N");
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
await using (var read = world.Db())
|
||||
{
|
||||
var moved = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded);
|
||||
Assert.Equal((survivor.Id, survivorKey, VoterClass.Member), (moved.UserAccountId, moved.VoterKey, moved.VoterClass));
|
||||
}
|
||||
|
||||
await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Ledger(db).UndoLocalAsync(merge.Id, default);
|
||||
}
|
||||
|
||||
await using (var read = world.Db())
|
||||
{
|
||||
var back = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded);
|
||||
Assert.Equal((folded.Id, "account:" + folded.Id.ToString("N"), VoterClass.Account), (back.UserAccountId, back.VoterKey, back.VoterClass));
|
||||
}
|
||||
|
||||
// Again, but the survivor re-casts the moved appraisal before the undo: it is theirs now and stays.
|
||||
var folded2 = await world.AccountAsync("folded2");
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
db.ConceptAppraisals.Add(new ConceptAppraisal
|
||||
{
|
||||
SubjectId = onlyFolded, UserAccountId = folded2.Id, VoterKey = "account:" + folded2.Id.ToString("N"), Stance = ConceptStance.Down,
|
||||
VoterClass = VoterClass.Account, CastAtUtc = T0
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var second = await world.CommitAsync(await world.MergeAsync(survivor, folded2));
|
||||
world.Clock.Now = T0.AddDays(1);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var voter = new VoterIdentity(survivorKey, VoterClass.Member, survivor.Id);
|
||||
Assert.Equal(CastResult.Changed, await world.Concepts(db).CastAsync("c", "only-folded", voter, ConceptStance.Up, null, default));
|
||||
}
|
||||
|
||||
await world.SetStateAsync(second, AccountMergeState.HeimdallReversed);
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Ledger(db).UndoLocalAsync(second.Id, default);
|
||||
}
|
||||
|
||||
await using var after = world.Db();
|
||||
var kept = await after.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded && x.UserAccountId == survivor.Id);
|
||||
Assert.Equal((survivorKey, ConceptStance.Up), (kept.VoterKey, kept.Stance));
|
||||
Assert.Contains(await world.JournalAsync(second), e => e is { Kind: AccountMergeEntryKind.ConceptAppraisal, Action: AccountMergeEntryAction.KeptAfterUndo });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Closed_motions_keep_their_votes_with_their_owners_and_journal_nothing_for_them()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var closed = await world.MotionAsync(status: MotionStatus.Passed);
|
||||
await world.VoteAsync(closed, survivor, VoteChoice.For, 1m);
|
||||
await world.VoteAsync(closed, folded, VoteChoice.For, 2m);
|
||||
var lone = await world.MotionAsync(status: MotionStatus.Failed);
|
||||
await world.VoteAsync(lone, folded, VoteChoice.Against, 2m);
|
||||
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
Assert.Equal(new[] { folded.Id, survivor.Id }.Order(), (await world.AllVotesAsync(closed)).Select(x => x.UserAccountId).Order());
|
||||
var loneVote = Assert.Single(await world.AllVotesAsync(lone));
|
||||
Assert.Equal((folded.Id, 2m), (loneVote.UserAccountId, loneVote.Weight)); // not re-pointed, not re-weighed
|
||||
Assert.DoesNotContain(await world.JournalAsync(merge), e => e.Kind == AccountMergeEntryKind.Vote);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AccountMergeState.Committed)]
|
||||
[InlineData(AccountMergeState.Final)]
|
||||
public async Task Commit_on_a_landed_merge_returns_the_receipt_and_writes_nothing(AccountMergeState state)
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var merge = await world.MergeAsync(survivor, folded, state);
|
||||
|
||||
var receipt = await world.CommitAsync(merge);
|
||||
|
||||
Assert.Equal((merge.Id, state), (receipt.Id, receipt.State));
|
||||
Assert.Empty(await world.JournalAsync(merge));
|
||||
Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Undo_is_refused_for_a_merge_that_was_ever_finalized()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var row = await db.AccountMerges.SingleAsync(x => x.Id == merge.Id);
|
||||
row.State = AccountMergeState.HeimdallReversed;
|
||||
row.FinalizedUtc = T0.AddDays(8);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using var undo = world.Db();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => world.Ledger(undo).UndoLocalAsync(merge.Id, default));
|
||||
Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_vote_and_an_appraisal_from_an_actor_resolved_before_the_account_froze_write_nothing()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
var staleActor = await world.ActorAsync(folded.Id); // a request resolved this before the merge was confirmed
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
db.ConceptSubjects.Add(new ConceptSubject { Collection = "c", Key = "k", Title = "K", RegisteredAtUtc = T0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await world.MergeAsync(survivor, folded, AccountMergeState.Confirmed);
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await world.Motions(db).CastVoteAsync(staleActor, motion.Id, VoteChoice.For, null, default);
|
||||
Assert.Equal(CastResult.NoVoter, await world.Concepts(db).CastAsync("c", "k", VoterIdentity.For(staleActor, null), ConceptStance.Up, null, default));
|
||||
}
|
||||
|
||||
Assert.Empty(await world.AllVotesAsync(motion));
|
||||
await using var read = world.Db();
|
||||
Assert.Empty(read.ConceptAppraisals.IgnoreQueryFilters());
|
||||
|
||||
// The survivor, not frozen, still writes.
|
||||
await using var control = world.Db();
|
||||
await world.Motions(control).CastVoteAsync(await world.ActorAsync(survivor.Id), motion.Id, VoteChoice.For, null, default);
|
||||
Assert.Single(await world.AllVotesAsync(motion));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_merged_membership_is_never_rewritten_by_the_bootstrap_promotion_or_by_suspension()
|
||||
{
|
||||
var world = new World();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var admin = await world.AccountAsync("admin", roles: MemberRole.PlatformAdmin);
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var account = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == folded.Id);
|
||||
Assert.False(ApplicationBootstrapper.TryPromoteBootstrapAdmin(account, T0, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance));
|
||||
Assert.Equal((MembershipStatus.Merged, false), (account.Membership!.Status, account.Membership.IsPlatformAdmin));
|
||||
}
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var suspension = new MembershipSuspensionService(db, new AuditTrailService(db, world.Clock));
|
||||
var actor = await world.ActorAsync(admin.Id);
|
||||
Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.SuspendAsync(actor, folded.Id, "reason", default));
|
||||
Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.UnsuspendAsync(actor, folded.Id, "reason", default));
|
||||
}
|
||||
|
||||
Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
405
tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs
Normal file
405
tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The merge tests that need a real server run only when BIFROST_TEST_PG holds a Postgres connection string (no Database=; a role that
|
||||
/// may create databases). Every other merge test runs on the in-memory provider, which enforces no unique index, transaction,
|
||||
/// concurrency token or trigger, so the rules that rest on those are proven here, on a database each test creates, builds through the
|
||||
/// real migrations and the class drops. See AGENTS.md (Live Postgres tests).
|
||||
/// </summary>
|
||||
public static class PostgresGate
|
||||
{
|
||||
public const string Variable = "BIFROST_TEST_PG";
|
||||
|
||||
public static string? Skip =>
|
||||
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(Variable))
|
||||
? $"live Postgres tests: set {Variable} to a connection string without Database= (see AGENTS.md, Live Postgres tests)"
|
||||
: null;
|
||||
}
|
||||
|
||||
public sealed class PostgresFactAttribute : FactAttribute
|
||||
{
|
||||
public PostgresFactAttribute() => Skip = PostgresGate.Skip;
|
||||
}
|
||||
|
||||
public sealed class PostgresTheoryAttribute : TheoryAttribute
|
||||
{
|
||||
public PostgresTheoryAttribute() => Skip = PostgresGate.Skip;
|
||||
}
|
||||
|
||||
public sealed class MergePostgres : IAsyncLifetime
|
||||
{
|
||||
private readonly List<string> _databases = [];
|
||||
|
||||
private static string? Admin => Environment.GetEnvironmentVariable(PostgresGate.Variable);
|
||||
|
||||
public Task InitializeAsync() => Task.CompletedTask;
|
||||
|
||||
/// <summary>A fresh database, built by the real migrations, and a world over it.</summary>
|
||||
public async Task<AccountMergeLedgerTests.World> NewWorldAsync()
|
||||
{
|
||||
var name = $"bifrost_merge_{Guid.NewGuid():N}";
|
||||
await using (var admin = new NpgsqlConnection(Admin))
|
||||
{
|
||||
await admin.OpenAsync();
|
||||
await using var create = new NpgsqlCommand($"CREATE DATABASE \"{name}\"", admin);
|
||||
await create.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
_databases.Add(name);
|
||||
var world = AccountMergeLedgerTests.World.OnPostgres(new NpgsqlConnectionStringBuilder(Admin) { Database = name }.ConnectionString);
|
||||
await using var db = world.Db();
|
||||
await db.Database.MigrateAsync();
|
||||
return world;
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_databases.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
await using var admin = new NpgsqlConnection(Admin);
|
||||
await admin.OpenAsync();
|
||||
foreach (var name in _databases)
|
||||
{
|
||||
await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", admin);
|
||||
await drop.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFixture<MergePostgres>
|
||||
{
|
||||
private static PostgresException Pg(Exception error)
|
||||
{
|
||||
for (var current = error; current is not null; current = current.InnerException!)
|
||||
{
|
||||
if (current is PostgresException pg)
|
||||
{
|
||||
return pg;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Xunit.Sdk.XunitException($"no PostgresException under {error.GetType().Name}");
|
||||
}
|
||||
|
||||
/// <summary>Runs an action once, just before the first save of a context: the rival writer that lands inside a read-then-save window.</summary>
|
||||
private sealed class BeforeFirstSave(Func<Task> rival) : SaveChangesInterceptor
|
||||
{
|
||||
private int _fired;
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _fired, 1) == 0)
|
||||
{
|
||||
await rival();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Holds each of two contexts at its save until both have arrived, so both have read before either writes.</summary>
|
||||
private sealed class Rendezvous : SaveChangesInterceptor
|
||||
{
|
||||
private readonly Barrier _barrier = new(2);
|
||||
|
||||
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConnectionOf(AccountMergeLedgerTests.World world)
|
||||
{
|
||||
using var db = world.Db();
|
||||
return db.Database.GetConnectionString()!;
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(AccountMergeLedgerTests.World world, string sql)
|
||||
{
|
||||
await using var db = world.Db();
|
||||
await db.Database.ExecuteSqlRawAsync(sql);
|
||||
}
|
||||
|
||||
// ---- commit atomicity ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_commit_that_fails_midway_leaves_nothing_applied_and_the_retry_commits_once()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
var merge = await world.MergeAsync(survivor, folded);
|
||||
await ExecuteAsync(world, """
|
||||
CREATE FUNCTION fail_merged() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN IF NEW."Status" = 'Merged' THEN RAISE EXCEPTION 'injected fault'; END IF; RETURN NEW; END $$;
|
||||
CREATE TRIGGER fail_merged BEFORE UPDATE ON "Memberships" FOR EACH ROW EXECUTE FUNCTION fail_merged();
|
||||
""");
|
||||
|
||||
var fault = await Assert.ThrowsAsync<DbUpdateException>(() => world.CommitAsync(merge));
|
||||
Assert.Contains("injected fault", Pg(fault).MessageText);
|
||||
|
||||
await using (var check = world.Db())
|
||||
{
|
||||
Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State);
|
||||
Assert.Empty(check.AccountMergeEntries);
|
||||
Assert.All(await check.Votes.IgnoreQueryFilters().ToListAsync(), v => Assert.Null(v.VoidedByMergeId));
|
||||
Assert.Equal(MembershipStatus.Active, (await check.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status);
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, "DROP TRIGGER fail_merged ON \"Memberships\"");
|
||||
Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State);
|
||||
var journal = await world.JournalAsync(merge);
|
||||
Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote));
|
||||
Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); // a second commit is a no-op
|
||||
Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_journal_refuses_a_second_entry_with_the_same_merge_and_seq()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var merge = await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"));
|
||||
await using var db = world.Db();
|
||||
AccountMergeEntry Entry() => new() { MergeId = merge.Id, Seq = 1, Kind = AccountMergeEntryKind.Vote, Action = AccountMergeEntryAction.Voided, RecordId = Guid.NewGuid(), AtUtc = AccountMergeLedgerTests.T0 };
|
||||
db.AccountMergeEntries.Add(Entry());
|
||||
await db.SaveChangesAsync();
|
||||
db.AccountMergeEntries.Add(Entry());
|
||||
|
||||
var error = await Assert.ThrowsAsync<DbUpdateException>(() => db.SaveChangesAsync());
|
||||
|
||||
Assert.Equal(("23505", "IX_AccountMergeEntries_MergeId_Seq"), (Pg(error).SqlState, Pg(error).ConstraintName));
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_commits_of_the_same_merge_racing_apply_it_exactly_once()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
var merge = await world.MergeAsync(survivor, folded);
|
||||
var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous());
|
||||
|
||||
async Task<Exception?> TryCommit()
|
||||
{
|
||||
try
|
||||
{
|
||||
await committers.CommitAsync(merge);
|
||||
return null;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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));
|
||||
Assert.Single((await world.AllVotesAsync(motion)).Where(v => v.VoidedByMergeId == merge.Id));
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_voided_vote_leaves_every_tally_on_a_real_database()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var moderator = await world.AccountAsync("mod", moderator: true);
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.For, 3m);
|
||||
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
var item = await world.StateOfAsync(motion, moderator);
|
||||
Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst));
|
||||
Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator));
|
||||
}
|
||||
|
||||
// ---- the sweeper against the saga ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_sweep_does_not_overwrite_an_UndoRequested_written_after_it_read_the_merge()
|
||||
{
|
||||
var plain = await postgres.NewWorldAsync();
|
||||
var merge = await plain.CommitAsync(await plain.MergeAsync(await plain.AccountAsync("survivor"), await plain.AccountAsync("folded")));
|
||||
var sweeper = AccountMergeLedgerTests.World.OnPostgres(
|
||||
ConnectionOf(plain), new BeforeFirstSave(() => plain.SetStateAsync(merge, AccountMergeState.UndoRequested)));
|
||||
|
||||
await using (var db = sweeper.Db())
|
||||
{
|
||||
Assert.Equal(0, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
await using var read = plain.Db();
|
||||
var row = await read.AccountMerges.SingleAsync();
|
||||
Assert.Equal(AccountMergeState.UndoRequested, row.State);
|
||||
Assert.Null(row.FinalizedUtc);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task An_UndoRequested_written_from_a_stale_Committed_read_cannot_overwrite_Final()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var merge = await world.CommitAsync(await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded")));
|
||||
await using var saga = world.Db();
|
||||
var stale = await saga.AccountMerges.SingleAsync(x => x.Id == merge.Id); // the saga read Committed
|
||||
|
||||
await using (var sweep = world.Db())
|
||||
{
|
||||
Assert.Equal(1, await world.Ledger(sweep).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
stale.State = AccountMergeState.UndoRequested;
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => saga.SaveChangesAsync());
|
||||
await using var read = world.Db();
|
||||
Assert.Equal(AccountMergeState.Final, (await read.AccountMerges.SingleAsync()).State);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_sweep_that_loses_one_merge_to_the_saga_still_finalizes_the_others()
|
||||
{
|
||||
var plain = await postgres.NewWorldAsync();
|
||||
var survivor = await plain.AccountAsync("survivor");
|
||||
await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-a")));
|
||||
await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-b")));
|
||||
var sweeper = AccountMergeLedgerTests.World.OnPostgres(
|
||||
ConnectionOf(plain), new BeforeFirstSave(async () =>
|
||||
{
|
||||
// Whichever merge the sweep saves first, the saga takes one of the two before it does.
|
||||
await using var rival = plain.Db();
|
||||
var taken = await rival.AccountMerges.Where(x => x.State == AccountMergeState.Committed).OrderBy(x => x.Id).FirstAsync();
|
||||
taken.State = AccountMergeState.UndoRequested;
|
||||
await rival.SaveChangesAsync();
|
||||
}));
|
||||
|
||||
await using (var db = sweeper.Db())
|
||||
{
|
||||
Assert.Equal(1, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default));
|
||||
}
|
||||
|
||||
await using var read = plain.Db();
|
||||
Assert.Equal([AccountMergeState.Final, AccountMergeState.UndoRequested], (await read.AccountMerges.ToListAsync()).Select(x => x.State).Order());
|
||||
}
|
||||
|
||||
// ---- overlapping merges, refused by the database ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_second_live_merge_folding_the_same_account_is_refused_and_a_finished_one_frees_it()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var s1 = await world.AccountAsync("acct-s1");
|
||||
var s2 = await world.AccountAsync("acct-s2");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
|
||||
var second = await world.MergeAsync(s2, folded, AccountMergeState.Proposed); // proposals may coexist
|
||||
var first = await world.MergeAsync(s1, folded, AccountMergeState.Confirmed);
|
||||
|
||||
var refused = await Assert.ThrowsAsync<DbUpdateException>(() => world.SetStateAsync(second, AccountMergeState.Confirmed));
|
||||
Assert.Equal(("23505", "UX_AccountMerges_FoldedUserAccountId_Live"), (Pg(refused).SqlState, Pg(refused).ConstraintName));
|
||||
var inserted = await Assert.ThrowsAsync<DbUpdateException>(() => world.MergeAsync(s2, folded, AccountMergeState.Final));
|
||||
Assert.Equal("23505", Pg(inserted).SqlState);
|
||||
|
||||
await world.SetStateAsync(first, AccountMergeState.Undone);
|
||||
await world.SetStateAsync(second, AccountMergeState.Confirmed); // the slot is free again
|
||||
}
|
||||
|
||||
[PostgresTheory]
|
||||
[InlineData(AccountMergeState.Confirmed, AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Final, AccountMergeState.Confirmed)]
|
||||
[InlineData(AccountMergeState.Committed, AccountMergeState.HeimdallMoved)]
|
||||
public async Task A_chain_is_refused_in_either_arrival_order_and_the_same_survivor_twice_is_fine(AccountMergeState earlier, AccountMergeState later)
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
foreach (var survivorFirst in new[] { true, false })
|
||||
{
|
||||
var tag = survivorFirst ? "a" : "b";
|
||||
var s = await world.AccountAsync("acct-s" + tag);
|
||||
var f = await world.AccountAsync("acct-f" + tag);
|
||||
var g = await world.AccountAsync("acct-g" + tag);
|
||||
var other = await world.AccountAsync("other" + tag);
|
||||
|
||||
await world.MergeAsync(s, other, AccountMergeState.Confirmed); // one survivor may carry two folded accounts
|
||||
await world.MergeAsync(s, f, earlier);
|
||||
// f survives a live merge and folds into g, or s folds into g while it survives one: both are chains.
|
||||
var chain = await Assert.ThrowsAsync<DbUpdateException>(() => survivorFirst ? world.MergeAsync(f, g, later) : world.MergeAsync(g, s, later));
|
||||
Assert.Equal("23P01", Pg(chain).SqlState);
|
||||
}
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_transactions_racing_to_chain_the_same_account_cannot_both_commit()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var s = await world.AccountAsync("acct-s");
|
||||
var f = await world.AccountAsync("acct-f");
|
||||
var g = await world.AccountAsync("acct-g");
|
||||
await using var first = world.Db();
|
||||
await using var firstTransaction = await first.Database.BeginTransactionAsync();
|
||||
first.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = s.Id, FoldedUserAccountId = f.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 });
|
||||
await first.SaveChangesAsync();
|
||||
|
||||
await using var second = world.Db();
|
||||
await using var secondTransaction = await second.Database.BeginTransactionAsync();
|
||||
second.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = f.Id, FoldedUserAccountId = g.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 });
|
||||
var racing = second.SaveChangesAsync();
|
||||
|
||||
await Task.Delay(1500);
|
||||
Assert.False(racing.IsCompleted); // it waits on the first, which holds the lock on f
|
||||
await firstTransaction.CommitAsync();
|
||||
|
||||
var refused = await Assert.ThrowsAsync<DbUpdateException>(() => racing.WaitAsync(TimeSpan.FromSeconds(30)));
|
||||
Assert.Equal("23P01", Pg(refused).SqlState);
|
||||
}
|
||||
|
||||
// ---- rollback ----
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Reverting_the_migration_is_refused_while_a_merge_has_voided_a_vote_and_allowed_after()
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var motion = await world.MotionAsync();
|
||||
await world.VoteAsync(motion, survivor, VoteChoice.For);
|
||||
await world.VoteAsync(motion, folded, VoteChoice.Against);
|
||||
await world.CommitAsync(await world.MergeAsync(survivor, folded));
|
||||
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials"));
|
||||
Assert.Contains("cannot be reverted", Pg(error).MessageText);
|
||||
Assert.Equal("20261114120000_AddAccountMerges", (await db.Database.GetAppliedMigrationsAsync()).Last());
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, "UPDATE \"Votes\" SET \"VoidedByMergeId\" = NULL, \"VoidedUtc\" = NULL");
|
||||
await using var down = world.Db();
|
||||
await down.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials");
|
||||
Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last());
|
||||
await down.GetService<IMigrator>().MigrateAsync(); // and up again, trigger and all
|
||||
Assert.Equal("20261114120000_AddAccountMerges", (await down.Database.GetAppliedMigrationsAsync()).Last());
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ using System.Text.Json;
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Patronage;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
|
|
@ -888,6 +889,33 @@ public sealed class HeimdallCompletionTests
|
|||
Assert.Equal(rolesBefore, after.RoleAssignments.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_merged_account_stays_merged_through_a_sign_in_with_the_member_claim()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
var foldedId = HeimdallShapes.NewAccountId();
|
||||
var survivorId = HeimdallShapes.NewAccountId();
|
||||
Assert.True((await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"])).SignedIn);
|
||||
Assert.True((await Genuine(host, await host.StartAsync(member: true), survivorId, ["member_access"])).SignedIn);
|
||||
await host.Db(async db =>
|
||||
{
|
||||
var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == foldedId);
|
||||
var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorId);
|
||||
var merge = new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = AccountMergeState.HeimdallMoved, CreatedUtc = DateTimeOffset.UtcNow };
|
||||
db.AccountMerges.Add(merge);
|
||||
await db.SaveChangesAsync();
|
||||
var clock = TimeProvider.System;
|
||||
await new AccountMergeLedger(db, new PatronageService(db, new AuditTrailService(db, clock), clock), clock).CommitAsync(merge.Id, default);
|
||||
return 0;
|
||||
});
|
||||
Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status);
|
||||
|
||||
var again = await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]);
|
||||
|
||||
Assert.True(again.SignedIn); // the login still completes; it is the actor gate that makes it act as nobody
|
||||
Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue