Merge pages and notices: prove both, choose survivor with preview, undo, notice feed (dark by default) #87

Merged
heimdall merged 22 commits from heimdall/merge-notices into main 2026-11-14 13:18:19 +00:00
45 changed files with 4137 additions and 34 deletions

View file

@ -1,6 +1,6 @@
# Account merge ledger
What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. The ledger owns what a merge does to Bifrost records; the saga (below) owns the merge's lifecycle around it and every Heimdall call. No page calls the saga yet (cuts D1 and D2), so no merge can be created in production by this code alone.
What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. The ledger owns what a merge does to Bifrost records; the saga (below) owns the merge's lifecycle around it and every Heimdall call. The member's pages are in Pages and flags below; with `AccountMerge:Enabled` false (the default) no page can start a merge.
## Owner
@ -68,6 +68,44 @@ Left untouched (counted by the plan): consent receipts, passkeys, ledger entries
`AccountMergeSweeper` is the one merge loop (30 s): per tick, in one scope, `AccountMergeSaga.AdvanceDueAsync` (every in-flight merge whose `NextAttemptUtc` is null or due, oldest first, a failure logged and skipped), then `FinalizeDueAsync`.
## Pages and flags
`Pages/Membership/MergeAccounts` is the one page that creates a merge. It is `[Authorize]`, no-store, a 404 unless `AccountMergeOptions.Enabled` (section `AccountMerge`, default false; environment `AccountMerge__Enabled`) and forbidden to a frozen or folded account, as Linked accounts is. Linked accounts shows one "Merge two accounts" link, only when the flag is on and the session is Heimdall-backed.
Its stage is derived on every request from `MergeProofs.Snapshot` and the `keep` query; no cookie or form field carries progress except the chosen direction and the preview digest, which the saga re-checks.
1. Prove: the member names a provider for this account (`MergeProveSelf`) and for the other (`MergeProveOther`). Nothing about the other account is rendered until both proofs are held.
2. Choose: both accounts are named by display name only, and `keep=self` or `keep=other` shows the preview. No survivor is preselected (the chat-history default needs `ChatFirstSignInUtc`, follow-up `merge-chat-default-survivor`; the chat paragraph carries the warning meanwhile).
3. Prove the rest: one fresh sign-in for each login of the account that would be folded and is not yet proved (`ProveRemaining`). Proofs older than `MergeProofs.ProofLifetime` fall out and are asked again.
4. Confirm: two checkboxes, one per account, the direction and the digest. `AccountMergeSaga.ConfirmAsync` inserts the Confirmed row, which is the consent record (actor, time, digest, proof providers); the page then runs `AdvanceAsync` once (a throw is logged and the sweeper resumes) and sends the member to Merge status. `MergeProofs.Clear` runs after a Confirmed outcome only: Blocked, PreviewStale, NotParty and ProofAccountMismatch come back as closed codes (`?merge=`), keep the proofs and write nothing.
`MergePreview` is the one wording of the plan (counts by entry kind and action, the nine block codes, the static statements about chat, connections, StreamPixels, roles, one vote per motion, the 7-day undo and ordinary sign-ins); the digest is `MergePlan.Digest` of the plan just rendered. `MergeWording.Confirm` holds the refusal words. The page writes no merge, vote, membership or journal row.
Deploy: set `AccountMerge__Enabled` only after Heimdall's identity-moves route is deployed and probed. A Confirmed merge against a Heimdall without it stays Unknown and keeps the folded account frozen.
## Notices
The chat host learns of merges by pulling, never by being pushed to: Bifrost has no event receiver and no outbox. `MergeNoticeFeed` derives the notices from the `AccountMerges` row on every read (merged: `CommittedUtc` set; undone: `UndoneUtc` set; final: `FinalizedUtc` set), so a notice cannot disagree with its merge, and a Failed, Frozen or Confirmed merge (no `CommittedUtc`) yields none. The only stored fact is the reader's ack, one `AccountMergeNoticeAcks` row per (merge, kind, target), written by nothing else (migration `AddAccountMergeNoticeAcks`; the table holds only acks, so its `Down` drops it). The feed never writes a merge column.
```
GET /api/merge-notices/chat?after=<iso>&limit=<n> -> 200 JSON array, oldest first by (occurred_at, merge_id, kind)
POST /api/merge-notices/chat/ack {"merge_id": "...", "type": "merged"} -> 204
```
Payload, exactly these keys: `type` (`merged`, `undone`, `final`), `merge_id`, `occurred_at` (the matching timestamp, ISO 8601 UTC), `grace_ends_at`, `old_subject` (the folded `UserAccount.Id`, format D, the OIDC `sub` the chat session knew), `new_subject` (the survivor's), `old_preferred_username`, `new_preferred_username` (`NormalizedBifrostIdentity`). `limit` defaults to 50 and is capped at 200. Acks are the cursor: a read returns every unacked notice; `after` only narrows the scan to notices strictly later than that time, so a reader must not skip unacked ones by it. An ack is idempotent (a repeat is 204); an ack for a (merge, kind) the feed would not emit now, such as `undone` for a merge not undone, is 404; a malformed body or unknown `type` is 400.
Auth: `Authorization: Bearer <token>`, compared in constant time after SHA-256 of both sides against `ChatNotices:Token` (environment `ChatNotices__Token`). An empty token turns both routes off (404). A missing or wrong bearer is 401 with an empty body. No cookie, no CORS, `Cache-Control: no-store`. Startup does not require the token.
```
curl -sS -H "Authorization: Bearer $TOKEN" "$BIFROST/api/merge-notices/chat?limit=50"
curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"merge_id":"<merge_id>","type":"merged"}' "$BIFROST/api/merge-notices/chat/ack"
```
The feed carries no email, display name, Heimdall id, Matrix id or history flag. It is information for the chat operator's side; back-channel logout (`OidcLogoutReconciler`) stays what ends chat sessions. Chat-side rules that bind Bifrost already hold: undo is refused past Final, and `preferred_username` never changes. A later reader (StreamPixels) adds a target constant and its own token over the same derivation.
Deploy: set `ChatNotices__Token` on Bifrost and hand the same value to the chat host's timer script (the secret's path is the chat steward's).
## Merged is terminal
Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): the bootstrap-admin promotion (startup and every GitHub sign-in, which is the one status write sign-in sync makes on an existing account) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status.

View file

@ -47,7 +47,7 @@ A link refused because the login already belongs to another account (`409 identi
## Not here
- Merging two accounts. A refused link ends there; nothing is moved or merged.
- Merging two accounts. A refused link ends there; nothing is moved or merged. Merging is the separate Merge accounts page, behind the `AccountMerge:Enabled` flag (`docs/account-merge.md`, Pages and flags).
- Step-up through Discord, Patreon or Twitch. Heimdall does not offer it to the `bifrost` profile.
- Linking Twitch or GitHub: not admitted by the `bifrost` profile, and Heimdall's GitHub runtime is not implemented.
- A list of other apps' logins, grants or consent receipts (Heimdall's grants page).

View file

@ -0,0 +1,13 @@
namespace Bifrost.Web.Configuration;
/// <summary>
/// Gates on the account-merge page (section <c>AccountMerge</c>). Dark by default: a merge Bifrost confirms against a Heimdall without
/// the identity-move route stays Unknown forever and freezes the folded account, so set <c>AccountMerge__Enabled</c> only after
/// Heimdall's identity-moves is deployed and probed.
/// </summary>
public sealed class AccountMergeOptions
{
public const string SectionName = "AccountMerge";
public bool Enabled { get; init; }
}

View file

@ -0,0 +1,10 @@
namespace Bifrost.Web.Configuration;
/// <summary>The chat host's pull of merge notices (MergeNoticeFeed). An empty token turns both routes off (404); startup does not require it.</summary>
public sealed class ChatNoticesOptions
{
public const string SectionName = "ChatNotices";
/// <summary>The bearer the chat host presents (environment <c>ChatNotices__Token</c>). Its own secret: it grants nothing else.</summary>
public string Token { get; init; } = string.Empty;
}

View file

@ -85,6 +85,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<AccountMergeEntry> AccountMergeEntries => Set<AccountMergeEntry>();
public DbSet<AccountMergeNoticeAck> AccountMergeNoticeAcks => Set<AccountMergeNoticeAck>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
@ -676,5 +678,16 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.HasForeignKey(x => x.MergeId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<AccountMergeNoticeAck>(entity =>
{
entity.HasKey(x => new { x.MergeId, x.Kind, x.Target });
entity.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
entity.Property(x => x.Target).HasConversion<string>().HasMaxLength(16);
entity.HasOne<AccountMerge>()
.WithMany()
.HasForeignKey(x => x.MergeId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}

View file

@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
// Hand-written like AddAccountMergeSaga (no dotnet-ef on the authoring host), the snapshot edited to match. Additive: one table of
// acks, the only fact a notice reader adds; the notices themselves are derived from AccountMerges. Down may drop it: it holds only acks.
[DbContext(typeof(BifrostDbContext))]
[Migration("20261114140000_AddAccountMergeNoticeAcks")]
public partial class AddAccountMergeNoticeAcks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AccountMergeNoticeAcks",
columns: table => new
{
MergeId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Target = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
AckedUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AccountMergeNoticeAcks", x => new { x.MergeId, x.Kind, x.Target });
table.ForeignKey(
name: "FK_AccountMergeNoticeAcks_AccountMerges_MergeId",
column: x => x.MergeId,
principalTable: "AccountMerges",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AccountMergeNoticeAcks");
}
}
}

View file

@ -193,6 +193,27 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("AccountMergeEntries");
});
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeNoticeAck", b =>
{
b.Property<Guid>("MergeId")
.HasColumnType("uuid");
b.Property<string>("Kind")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Target")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("AckedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("MergeId", "Kind", "Target");
b.ToTable("AccountMergeNoticeAcks");
});
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
{
b.Property<Guid>("Id")
@ -2277,6 +2298,15 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("Merge");
});
modelBuilder.Entity("Bifrost.Web.Domain.AccountMergeNoticeAck", b =>
{
b.HasOne("Bifrost.Web.Domain.AccountMerge", null)
.WithMany()
.HasForeignKey("MergeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount")

View file

@ -134,3 +134,32 @@ public sealed class AccountMergeEntry
public AccountMerge Merge { get; set; } = null!;
}
/// <summary>What a merge notice tells its reader: the merge committed, was undone, or can no longer be undone. Stored as a string.</summary>
public enum AccountMergeNoticeKind
{
Merged,
Undone,
Final
}
/// <summary>Who reads the notice feed. A closed set: a new reader is a new constant (and its own token), never a string from a request. Stored as a string.</summary>
public enum AccountMergeNoticeTarget
{
Chat
}
/// <summary>
/// The one fact a notice reader adds: it has seen this notice. The notice itself is derived from the merge row on every read
/// (MergeNoticeFeed) and has no stored status; nothing but the feed writes this row.
/// </summary>
public sealed class AccountMergeNoticeAck
{
public Guid MergeId { get; set; }
public AccountMergeNoticeKind Kind { get; set; }
public AccountMergeNoticeTarget Target { get; set; }
public DateTimeOffset AckedUtc { get; set; }
}

View file

@ -36,7 +36,8 @@ public sealed class HeimdallAuthService(
HeimdallMemberClient memberClient,
LinkedAccountsSnapshots snapshots,
PasskeyCeremonies ceremonies,
PasskeyService passkeys)
PasskeyService passkeys,
MergeProofs mergeProofs)
{
public const string ErrorRedirect = "/?auth=heimdall-error";
public const string AppSecretHeader = "x-heimdall-app-secret";
@ -203,11 +204,12 @@ public sealed class HeimdallAuthService(
bool stepUp,
string? target,
CancellationToken cancellationToken,
string? proof = null) =>
string? proof = null,
string? returnPath = null) =>
StartAsync(
provider,
callbackBaseUrl,
LocalReturnPath.Parse(LinkReturnPath),
LocalReturnPath.Parse(returnPath ?? LinkReturnPath),
requireMemberAccess: false,
requestCookies,
cancellationToken,
@ -228,6 +230,32 @@ public sealed class HeimdallAuthService(
HeimdallAttemptPurpose.ListProof, ProofProviderFor(sessionAccountId, via),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken);
/// <summary>
/// A fresh sign-in as the account signed in here, held as one side of a merge (<see cref="MergeProofs"/>). The member names the
/// provider in <paramref name="via"/>: Bifrost never guesses one, and a sign-in through a login no account holds mints an unrelated
/// Heimdall account. The browser returns to <paramref name="returnPath"/> with the outcome in <c>?result=</c>.
/// </summary>
public Task<HeimdallAuthStartResult> StartMergeProveSelfAsync(
string sessionAccountId, string? via, string returnPath, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.MergeProveSelf, MergeProvider(via),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken, returnPath: returnPath);
/// <summary>
/// A fresh sign-in as the other account of a merge. <paramref name="sessionAccountId"/> is the account that must not be the one
/// proved: signing in again as oneself is not a second account.
/// </summary>
public Task<HeimdallAuthStartResult> StartMergeProveOtherAsync(
string sessionAccountId, string? via, string returnPath, string callbackBaseUrl,
IEnumerable<KeyValuePair<string, string>> requestCookies, CancellationToken cancellationToken) =>
StartProofAsync(
HeimdallAttemptPurpose.MergeProveOther, MergeProvider(via),
sessionAccountId, callbackBaseUrl, requestCookies, false, null, cancellationToken, returnPath: returnPath);
private static string MergeProvider(string? via) =>
ProofProvider(null, via) ?? throw new HeimdallAuthException("A merge proof needs the provider the member names.");
/// <summary>
/// Links another provider: an ordinary sign-in as proof through a provider on the account's current list (there is no
/// guessing without one), then the link start for <paramref name="targetProvider"/> with its bearer.
@ -345,6 +373,12 @@ public sealed class HeimdallAuthService(
/// <summary>What the attempt this browser holds for the nonce is for, or null when it holds none.</summary>
public HeimdallAttemptPurpose? PurposeOf(string? cookieValue, string? nonce) => Unprotect(cookieValue, nonce)?.Purpose;
/// <summary>The page a merge-proof attempt began on, which a refused completion goes back to; null for every other attempt.</summary>
public string? MergeReturnPathOf(string? cookieValue, string? nonce) =>
Unprotect(cookieValue, nonce) is { Purpose: HeimdallAttemptPurpose.MergeProveSelf or HeimdallAttemptPurpose.MergeProveOther } attempt
? attempt.ReturnPath.Value
: null;
/// <summary>Redeems the code of an attempt and verifies the token it carries. The token is returned to the caller's stack frame and nowhere else.</summary>
private async Task<(HeimdallVerifiedIdentity Identity, string AccessToken, HeimdallRedeemResponse Redeemed)> RedeemVerifiedAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
@ -542,11 +576,72 @@ public sealed class HeimdallAuthService(
return new HeimdallLinkStep(null, cookieName, null, await UnlinkAsync(identity.AccountId, asserted.AccessToken!, attempt.Target, cancellationToken));
}
case HeimdallAttemptPurpose.MergeProveSelf:
case HeimdallAttemptPurpose.MergeProveOther:
return new HeimdallLinkStep(
null, cookieName, null, await RecordMergeProofAsync(callbackBaseUrl, attempt, code, cancellationToken), ReturnPath: attempt.ReturnPath.Value);
default:
throw new HeimdallAuthException("Heimdall completion is not a link attempt.");
}
}
/// <summary>
/// Completes a merge-proof sign-in: redeems the code, reads the proven account's logins with its bearer and hands both to
/// <see cref="MergeProofs"/>, the one place a bearer is kept. Self must prove the session's account (RedeemOwnerAsync). Other must
/// prove a different account that has a member account here, found by its Heimdall account id and nothing else. Every refusal
/// returns before Heimdall is asked for a list, and the bearer leaves scope with this call.
/// </summary>
private async Task<HeimdallLinkOutcome> RecordMergeProofAsync(
string callbackBaseUrl, HeimdallAttempt attempt, string? code, CancellationToken cancellationToken)
{
var self = attempt.Purpose == HeimdallAttemptPurpose.MergeProveSelf;
var (identity, bearer, redeemed) = self
? await RedeemOwnerWithBodyAsync(callbackBaseUrl, attempt, code, cancellationToken)
: await RedeemVerifiedAsync(callbackBaseUrl, attempt, code, cancellationToken);
if (redeemed.Provider is not null && !string.Equals(redeemed.Provider, attempt.Provider, StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall completion came through another provider.");
}
if (!self && string.Equals(identity.AccountId, attempt.ExpectedAccountId, StringComparison.Ordinal))
{
return HeimdallLinkOutcome.MergeSameAccount;
}
var actorId = await UserAccountIdOfAsync(attempt.ExpectedAccountId!, cancellationToken);
var sideId = self ? actorId : await UserAccountIdOfAsync(identity.AccountId, cancellationToken);
if (actorId is null || sideId is null)
{
return HeimdallLinkOutcome.MergeNoAccount;
}
var listed = await memberClient.ListAsync(bearer, cancellationToken);
if (listed.Call != HeimdallMemberCall.Ok)
{
return OutcomeOf(listed.Call);
}
return mergeProofs.Put(
actorId.Value,
self ? MergeSide.Self : MergeSide.Other,
new MergeProof(bearer, identity.AccountId, attempt.Provider),
sideId.Value,
listed.Identities!) switch
{
PutOutcome.Stored => HeimdallLinkOutcome.MergeProofRecorded,
PutOutcome.AmbiguousLogins => HeimdallLinkOutcome.MergeLoginsAmbiguous,
_ => HeimdallLinkOutcome.Error
};
}
/// <summary>The member account that holds this Heimdall account id (an equality on the id, never on a name or email), or null.</summary>
private async Task<Guid?> UserAccountIdOfAsync(string heimdallAccountId, CancellationToken cancellationToken) =>
await dbContext.UserAccounts
.Where(x => x.HeimdallAccountId == heimdallAccountId)
.Select(x => (Guid?)x.Id)
.SingleOrDefaultAsync(cancellationToken);
/// <summary>
/// Heimdall's unlink with a bearer that carries fresh proof, and what the list becomes. Only Heimdall's definitive refusals leave
/// the account as the list showed it. Any other end (not found, a lost answer, a 500, a malformed 200) may have applied the
@ -958,7 +1053,8 @@ public sealed record HeimdallAttempt(
/// <summary>
/// What a held attempt is for. SignIn is the default, so a cookie written before purposes existed reads as a sign-in.
/// The other purposes belong to the Linked accounts page: the fresh sign-ins that prove an account for a list, a link or
/// an unlink, and the provider round trips of a link. They never sign anyone in. Values are persisted in cookies: append only.
/// an unlink, and the provider round trips of a link. The merge purposes are fresh sign-ins that prove the account signed in here or a
/// second one, held in <see cref="MergeProofs"/> for a merge's confirm and undo, and started by the page that is to read them. They never sign anyone in. Values are persisted in cookies: append only.
/// </summary>
public enum HeimdallAttemptPurpose
{
@ -970,7 +1066,9 @@ public enum HeimdallAttemptPurpose
UnlinkProof = 5,
LinkAwaitDirect = 6,
EnrolProof = 7,
UnlinkPasskeyProof = 8
UnlinkPasskeyProof = 8,
MergeProveSelf = 9,
MergeProveOther = 10
}
/// <summary>How a Linked accounts action ended. <see cref="HeimdallLinkOutcomes.Code"/> is its closed wire form.</summary>
@ -1001,7 +1099,11 @@ public enum HeimdallLinkOutcome
RateLimited,
StepUpUnavailable,
StepUpReplayed,
StepUpRateLimited
StepUpRateLimited,
MergeProofRecorded,
MergeSameAccount,
MergeNoAccount,
MergeLoginsAmbiguous
}
/// <summary>
@ -1012,8 +1114,9 @@ public static class HeimdallLinkOutcomes
{
public const string ResultParameter = "result";
public static string Redirect(HeimdallLinkOutcome outcome) =>
HeimdallAuthService.LinkReturnPath + "?" + ResultParameter + "=" + Code(outcome);
/// <summary>Back to the page that began the step (<paramref name="returnPath"/>, a local path), else the Linked accounts page.</summary>
public static string Redirect(HeimdallLinkOutcome outcome, string? returnPath = null) =>
(returnPath ?? HeimdallAuthService.LinkReturnPath) + "?" + ResultParameter + "=" + Code(outcome);
public static string Code(HeimdallLinkOutcome outcome) => outcome switch
{
@ -1042,6 +1145,10 @@ public static class HeimdallLinkOutcomes
HeimdallLinkOutcome.StepUpUnavailable => "step_up_unavailable",
HeimdallLinkOutcome.StepUpReplayed => "step_up_replayed",
HeimdallLinkOutcome.StepUpRateLimited => "step_up_rate_limited",
HeimdallLinkOutcome.MergeProofRecorded => "merge_proof_recorded",
HeimdallLinkOutcome.MergeSameAccount => "merge_same_account",
HeimdallLinkOutcome.MergeNoAccount => "merge_no_account",
HeimdallLinkOutcome.MergeLoginsAmbiguous => "merge_logins_ambiguous",
_ => "error"
};
@ -1072,13 +1179,17 @@ public static class HeimdallLinkOutcomes
"step_up_unavailable" => "Your passkey was verified, but Heimdall would not take Bifrost's proof. Nothing was changed.",
"step_up_replayed" => "Heimdall had already seen that proof. Nothing was changed; start the unlink again.",
"step_up_rate_limited" => "Heimdall is limiting proofs for now. Nothing was changed; wait a few minutes and start the unlink again.",
"merge_proof_recorded" => "That sign-in is recorded for the merge. It is held in memory for a few minutes and used only when you confirm.",
"merge_same_account" => "That sign-in is the account you are already signed in as, not a second one. Nothing was recorded.",
"merge_no_account" => "Bifrost has no member account for that sign-in, so there is nothing to merge. Nothing was recorded.",
"merge_logins_ambiguous" => "That account holds two logins of one provider, which Bifrost cannot prove one at a time. Nothing was recorded.",
"list_expired" => "The list was out of date. Nothing was changed; read your linked logins again.",
"error" => "That could not be completed. Nothing was changed.",
_ => null
};
}
public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null);
public sealed record HeimdallLinkStep(Uri? Next, string CookieName, string? CookieValue, HeimdallLinkOutcome? Outcome, string? EnrolTicket = null, string? ReturnPath = null);
public sealed record HeimdallConfirmLinkRequest(
[property: JsonPropertyName("pendingLinkId")] string PendingLinkId);

View file

@ -313,8 +313,29 @@ public sealed partial class HeimdallIdentityMoveClient(
}
}
public static class HeimdallIdentityMoveClientServices
public static class HeimdallClientServices
{
/// <summary>
/// Every Heimdall client Bifrost owns. The auth client carries the app secret, the member client only the member's bearer and
/// the mover the app secret and its call deadline; all share the header redaction.
/// </summary>
public static void AddHeimdallClients(this IServiceCollection services)
{
services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.WithoutRedirects()
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =>
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.WithoutRedirects()
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
services.AddHeimdallIdentityMoveClient();
}
/// <summary>A 3xx from Heimdall is its answer: following it would carry the app secret or a member's bearer to another origin.</summary>
public static IHttpClientBuilder WithoutRedirects(this IHttpClientBuilder builder) =>
builder.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) });
/// <summary>
/// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the
/// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction.
@ -325,6 +346,6 @@ public static class HeimdallIdentityMoveClientServices
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value);
client.Timeout = HeimdallIdentityMoveClient.Timeout;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) })
.WithoutRedirects()
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
}

View file

@ -0,0 +1,131 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Domain;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Membership;
/// <summary>
/// The chat host's pull of merge notices: GET the unacked feed, POST an ack. JSON only because the reader is the chat host's script (a xenos
/// boundary). The bearer is the chat host's own secret (ChatNotices:Token); an empty token turns both routes off. No cookie, no CORS, never stored.
/// </summary>
public static class MergeNoticeEndpoints
{
private const int MaxAckBodyBytes = 1024;
public static void Map(WebApplication app)
{
app.MapGet("/api/merge-notices/chat", async (HttpContext http, IOptions<ChatNoticesOptions> options, MergeNoticeFeed feed, CancellationToken ct) =>
{
if (Gate(http, options.Value) is { } refused)
{
return refused;
}
DateTimeOffset? after = null;
if (http.Request.Query.TryGetValue("after", out var raw) && raw.Count > 0)
{
if (!DateTimeOffset.TryParse(raw[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed))
{
return Results.StatusCode(StatusCodes.Status400BadRequest);
}
after = parsed;
}
int? limit = http.Request.Query.TryGetValue("limit", out var rawLimit) && int.TryParse(rawLimit[0], CultureInfo.InvariantCulture, out var n) ? n : null;
var notices = await feed.ReadAsync(AccountMergeNoticeTarget.Chat, after, limit, ct);
return Results.Text(JsonSerializer.Serialize(notices.Select(Wire)), "application/json");
}).AllowAnonymous();
app.MapPost("/api/merge-notices/chat/ack", async (HttpContext http, IOptions<ChatNoticesOptions> options, MergeNoticeFeed feed, CancellationToken ct) =>
{
if (Gate(http, options.Value) is { } refused)
{
return refused;
}
if (await ReadAckAsync(http.Request, ct) is not var (mergeId, kind))
{
return Results.StatusCode(StatusCodes.Status400BadRequest);
}
return await feed.AckAsync(AccountMergeNoticeTarget.Chat, mergeId, kind, ct)
? Results.StatusCode(StatusCodes.Status204NoContent)
: Results.StatusCode(StatusCodes.Status404NotFound);
}).AllowAnonymous();
}
private static IResult? Gate(HttpContext http, ChatNoticesOptions options)
{
http.Response.Headers.CacheControl = "no-store";
if (string.IsNullOrEmpty(options.Token))
{
return Results.StatusCode(StatusCodes.Status404NotFound);
}
var header = http.Request.Headers.Authorization.ToString();
const string scheme = "Bearer ";
var presented = header.StartsWith(scheme, StringComparison.Ordinal) ? header[scheme.Length..] : string.Empty;
// Both sides are hashed first, so the comparison is over equal-length digests and says nothing about the token's length.
var matches = CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(presented)), SHA256.HashData(Encoding.UTF8.GetBytes(options.Token)));
return matches && presented.Length > 0 ? null : Results.StatusCode(StatusCodes.Status401Unauthorized);
}
private static async Task<(Guid MergeId, AccountMergeNoticeKind Kind)?> ReadAckAsync(HttpRequest request, CancellationToken ct)
{
var buffer = new byte[MaxAckBodyBytes + 1];
var read = 0;
int n;
while (read < buffer.Length && (n = await request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0)
{
read += n;
}
if (read == 0 || read > MaxAckBodyBytes)
{
return null;
}
try
{
using var document = JsonDocument.Parse(buffer.AsMemory(0, read));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("merge_id", out var id) || id.ValueKind != JsonValueKind.String || !Guid.TryParse(id.GetString(), out var mergeId) ||
!root.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String)
{
return null;
}
AccountMergeNoticeKind? kind = type.GetString() switch
{
"merged" => AccountMergeNoticeKind.Merged,
"undone" => AccountMergeNoticeKind.Undone,
"final" => AccountMergeNoticeKind.Final,
_ => null
};
return kind is { } k ? (mergeId, k) : null;
}
catch (JsonException)
{
return null;
}
}
/// <summary>The wire shape, exactly these keys: ids in format D, times ISO 8601 UTC, no email, display name, Heimdall id or Matrix id.</summary>
private static Dictionary<string, object?> Wire(MergeNotice notice) => new()
{
["type"] = notice.Kind.ToString().ToLowerInvariant(),
["merge_id"] = notice.MergeId.ToString("D"),
["occurred_at"] = notice.OccurredAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["grace_ends_at"] = notice.GraceEndsAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture),
["old_subject"] = notice.OldSubject.ToString("D"),
["new_subject"] = notice.NewSubject.ToString("D"),
["old_preferred_username"] = notice.OldPreferredUsername,
["new_preferred_username"] = notice.NewPreferredUsername
};
}

View file

@ -0,0 +1,96 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.Membership;
/// <summary>What the chat host is told about one merge. Derived from the merge row on every read; never stored.</summary>
public sealed record MergeNotice(
AccountMergeNoticeKind Kind,
Guid MergeId,
DateTimeOffset OccurredAt,
DateTimeOffset? GraceEndsAt,
Guid OldSubject,
Guid NewSubject,
string OldPreferredUsername,
string NewPreferredUsername);
/// <summary>
/// The one owner of what a notice reader is told about merges: merged (CommittedUtc), undone (UndoneUtc) and final (FinalizedUtc) are read off
/// the AccountMerges row on every call, so a notice cannot disagree with the merge, and the reader's ack row is the only thing stored.
/// Reads only AccountMerges and UserAccounts; writes only AccountMergeNoticeAck.
/// </summary>
public sealed class MergeNoticeFeed(BifrostDbContext dbContext, TimeProvider timeProvider)
{
public const int DefaultLimit = 50;
public const int MaxLimit = 200;
private static readonly AccountMergeNoticeKind[] Kinds = [AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Undone, AccountMergeNoticeKind.Final];
private static DateTimeOffset? OccurredAt(AccountMerge merge, AccountMergeNoticeKind kind) => kind switch
{
AccountMergeNoticeKind.Merged => merge.CommittedUtc,
AccountMergeNoticeKind.Undone => merge.UndoneUtc,
_ => merge.FinalizedUtc
};
/// <summary>Unacked notices for <paramref name="target"/>, oldest first by (occurred_at, merge_id, kind), strictly later than <paramref name="after"/> when given. Acks are the cursor; <c>after</c> only narrows the scan.</summary>
public async Task<IReadOnlyList<MergeNotice>> ReadAsync(AccountMergeNoticeTarget target, DateTimeOffset? after, int? limit, CancellationToken cancellationToken)
{
var take = limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit;
var picked = new List<(AccountMerge Merge, AccountMergeNoticeKind Kind, DateTimeOffset At)>();
// Each kind's own oldest `take` rows contain every row of the overall oldest `take`, so merging three bounded lists is exact.
foreach (var kind in Kinds)
{
var rows = await dbContext.AccountMerges.AsNoTracking()
.Where(x => (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) != null)
.Where(x => after == null || (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) > after)
.Where(x => !dbContext.AccountMergeNoticeAcks.Any(a => a.MergeId == x.Id && a.Kind == kind && a.Target == target))
.OrderBy(x => kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc)
.ThenBy(x => x.Id)
.Take(take)
.ToListAsync(cancellationToken);
picked.AddRange(rows.Select(x => (x, kind, OccurredAt(x, kind)!.Value)));
}
var page = picked.OrderBy(x => x.At).ThenBy(x => x.Merge.Id).ThenBy(x => x.Kind).Take(take).ToList();
var ids = page.SelectMany(x => new[] { x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId }).Distinct().ToList();
var names = await dbContext.UserAccounts.AsNoTracking().Where(x => ids.Contains(x.Id))
.Select(x => new { x.Id, x.NormalizedBifrostIdentity }).ToDictionaryAsync(x => x.Id, x => x.NormalizedBifrostIdentity, cancellationToken);
return [.. page.Select(x => new MergeNotice(
x.Kind, x.Merge.Id, x.At, x.Merge.GraceEndsUtc, x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId,
names.GetValueOrDefault(x.Merge.FoldedUserAccountId, string.Empty), names.GetValueOrDefault(x.Merge.SurvivorUserAccountId, string.Empty)))];
}
/// <summary>Records that the reader has seen (merge, kind). False when the feed would not emit that notice now (no such merge, or its timestamp unset); true otherwise, a repeat included.</summary>
public async Task<bool> AckAsync(AccountMergeNoticeTarget target, Guid mergeId, AccountMergeNoticeKind kind, CancellationToken cancellationToken)
{
var merge = await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken);
if (merge is null || OccurredAt(merge, kind) is null)
{
return false;
}
if (await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken))
{
return true;
}
dbContext.AccountMergeNoticeAcks.Add(new AccountMergeNoticeAck { MergeId = mergeId, Kind = kind, Target = target, AckedUtc = timeProvider.GetUtcNow() });
try
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// A concurrent ack of the same key won the unique index; the notice is acked, which is what the caller asked for.
dbContext.ChangeTracker.Clear();
if (!await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken))
{
throw;
}
}
return true;
}
}

View file

@ -0,0 +1,93 @@
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Membership;
/// <summary>
/// The only wording of what a merge would do, in closed vocabulary: counts by entry kind and action from <see cref="MergePlan"/>, the
/// sentence for each block code, and the statements every merge carries. It decides nothing, and names no account: the plan holds none.
/// </summary>
public static class MergePreview
{
public static IReadOnlyList<string> Lines(MergePlan plan)
{
var lines = new List<string>();
foreach (var group in plan.Entries.GroupBy(x => (x.Kind, x.Action)))
{
var n = group.Count();
lines.Add(group.Key switch
{
(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved) =>
$"{Count(n, "open vote")} of the other account on motions you did not vote on move to the kept account.",
(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided) =>
$"{Count(n, "open vote")} of the other account are set aside: the kept account already voted on that motion or cannot vote.",
(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved) =>
$"{Count(n, "appraisal")} of the other account move to the kept account.",
(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided) =>
$"{Count(n, "appraisal")} of the other account are set aside: the kept account already appraised the same thing or cannot.",
(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed) =>
"The other account's membership closes; the kept account's membership stays.",
(AccountMergeEntryKind.Standing, _) =>
"The kept account is suspended too, because the other account is.",
(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved) =>
$"{Count(n, "patron event")} of the other account count for the kept account.",
_ => $"{Count(n, "other record")} change."
});
}
foreach (var (key, n) in plan.Left.OrderBy(x => x.Key, StringComparer.Ordinal))
{
if (n > 0 && Remains(key) is { } noun)
{
lines.Add($"{Count(n, noun)} stay{(n == 1 ? "s" : string.Empty)} where {(n == 1 ? "it is" : "they are")}.");
}
}
if (lines.Count == 0)
{
lines.Add("No votes, appraisals or patron events change.");
}
return lines;
}
/// <summary>Why a pair cannot be merged, by the plan's block code. No account is named; a code this class does not know gets a generic line, never an echo.</summary>
public static string Block(string code) => code switch
{
"same_account" => "These are the same account.",
"account_missing" => "One of the accounts no longer exists.",
"mind_account" => "An account that belongs to a Mind cannot be merged.",
"native_account" => "An account without a Heimdall sign-in cannot be merged.",
"merge_in_flight" => "One of the accounts is already part of a merge that has not finished.",
"survivor_is_folded" => "The account you would keep was already merged into another account.",
"already_folded" => "The other account was already merged into another account.",
"folded_is_survivor" => "Another account was merged into the other account, so it cannot be folded in turn.",
"sealed_ballot_pending" => "The other account has a sealed ballot on an open motion; wait until it is revealed.",
_ => "These accounts cannot be merged now."
};
/// <summary>The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named.</summary>
public static string ProviderConflict(IEnumerable<string> providerNames) =>
$"Both accounts hold a {string.Join(" and ", providerNames)} login, and Heimdall will not combine two logins of one provider. Unlink one of them first.";
/// <summary>What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone.</summary>
public static IReadOnlyList<string> Statements { get; } =
[
"Chat: your chat history, rooms and keys belong to one chat id and cannot be moved. Keep the account you use in chat. Whichever you keep, the other chat id stays behind: rooms you were in on it must be joined again with the kept one, and messages encrypted for it cannot be read on the kept one.",
"Your connections (Twitch, Discord and others) will ask for consent again.",
"If you stream or moderate with StreamPixels and one of these connections is the one it uses for your channel, that connection stops until you reconnect it.",
"Roles and admin rights of the other account end; a steward must grant them again.",
"You will have one membership and one vote per motion: where both accounts voted on an open motion, the kept account's vote counts and the other is set aside. Nothing is added together.",
"You can undo this for 7 days, from Merge status. After that it is final.",
"The merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end."
];
private static string Count(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s";
private static string? Remains(string key) => key switch
{
"ConsentReceipts" => "consent record",
"PasskeyCredentials" => "passkey",
"LedgerEntries" => "ledger entry",
_ => null
};
}

View file

@ -0,0 +1,229 @@
using Bifrost.Web.Features.Heimdall;
namespace Bifrost.Web.Features.Membership;
public enum MergeSide
{
/// <summary>The account signed in here.</summary>
Self,
/// <summary>The second account, proved by its own fresh sign-in.</summary>
Other
}
public enum PutOutcome
{
Stored,
AmbiguousLogins,
TooManyProofs
}
/// <summary>What a page may know of one side: no bearer, no Heimdall account id, no login names.</summary>
public sealed record MergeSideView(
Guid UserAccountId, IReadOnlyList<string> ProvedProviders, IReadOnlyList<string> LoginProviders, TimeSpan OldestProofAge, DateTimeOffset ExpiresAt);
public sealed record MergeProofsView(MergeSideView? Self, MergeSideView? Other);
public abstract record PeekResult
{
private PeekResult()
{
}
public sealed record Ready(MergeProof Survivor, IReadOnlyList<MergeProof> Folded) : PeekResult;
/// <summary>A side holds no live proof.</summary>
public sealed record Expired : PeekResult;
/// <summary>Logins whose provider has no live proof, by provider name only.</summary>
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : PeekResult;
/// <summary>Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only.</summary>
public sealed record ProviderConflict(IReadOnlyList<string> Providers) : PeekResult;
}
public abstract record UndoPeekResult
{
private UndoPeekResult()
{
}
public sealed record Ready(IReadOnlyList<MergeProof> Proofs) : UndoPeekResult;
public sealed record Expired : UndoPeekResult;
public sealed record LoginsUnproved(IReadOnlyList<string> Providers) : UndoPeekResult;
}
/// <summary>
/// The one holder of live Heimdall bearers between a merge-proof sign-in's completion and the page's call into the saga, keyed by the
/// acting Bifrost account. It lives in this process's memory only and is never persisted, logged or sent to a browser; after a restart
/// the member proves again. It decides nothing about the merge: the saga owns consent, the move and the lifecycle, and Heimdall judges
/// each proof's tier and freshness. <see cref="ProofLifetime"/> is only a margin under Heimdall's 300 s weak window, so a stale proof
/// is not sent to fail a merge. Provider is the only proof bookkeeping Bifrost has (the redeem names the provider, not the identity),
/// so a login list holding two logins of one provider is refused, and two sides holding a login of one provider are a conflict Peek names before consent. Every access drops expired bearers. Peek never consumes: the caller <see cref="Clear"/>s once the saga
/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof.
/// </summary>
public sealed class MergeProofs(TimeProvider timeProvider)
{
public static readonly TimeSpan ProofLifetime = TimeSpan.FromSeconds(270);
/// <summary>The saga's own bound on the proofs of one move.</summary>
public const int MaxProofsPerSide = 8;
private sealed record Held(MergeProof Proof, DateTimeOffset RedeemedAt);
private sealed class Side(Guid userAccountId, string heimdallAccountId)
{
public Guid UserAccountId { get; } = userAccountId;
public string HeimdallAccountId { get; } = heimdallAccountId;
public IReadOnlyList<LinkedIdentitySummary> Logins { get; set; } = [];
public Dictionary<string, Held> Proofs { get; } = new(StringComparer.Ordinal);
}
private readonly Dictionary<Guid, Dictionary<MergeSide, Side>> _held = [];
private readonly object _gate = new();
public PutOutcome Put(Guid actorId, MergeSide side, MergeProof proof, Guid userAccountId, IReadOnlyList<LinkedIdentitySummary> logins)
{
if (logins.GroupBy(x => x.Provider, StringComparer.Ordinal).Any(x => x.Count() > 1))
{
return PutOutcome.AmbiguousLogins;
}
lock (_gate)
{
var now = timeProvider.GetUtcNow();
Prune(now);
var sides = _held.TryGetValue(actorId, out var existing) ? existing : _held[actorId] = [];
if (!sides.TryGetValue(side, out var held) || held.UserAccountId != userAccountId || held.HeimdallAccountId != proof.HeimdallAccountId)
{
held = new Side(userAccountId, proof.HeimdallAccountId);
}
if (!held.Proofs.ContainsKey(proof.Provider) && held.Proofs.Count >= MaxProofsPerSide)
{
return PutOutcome.TooManyProofs;
}
held.Logins = logins;
held.Proofs[proof.Provider] = new Held(proof, now);
sides[side] = held;
return PutOutcome.Stored;
}
}
public MergeProofsView Snapshot(Guid actorId)
{
lock (_gate)
{
var now = timeProvider.GetUtcNow();
Prune(now);
var sides = _held.GetValueOrDefault(actorId);
return new MergeProofsView(View(sides, MergeSide.Self, now), View(sides, MergeSide.Other, now));
}
}
public PeekResult Peek(Guid actorId, MergeSide survivorSide)
{
lock (_gate)
{
Prune(timeProvider.GetUtcNow());
var sides = _held.GetValueOrDefault(actorId);
if (sides is null || !sides.TryGetValue(survivorSide, out var survivor)
|| !sides.TryGetValue(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, out var folded))
{
return new PeekResult.Expired();
}
var shared = survivor.Logins.Select(x => x.Provider).Intersect(folded.Logins.Select(x => x.Provider), StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray();
if (shared.Length > 0)
{
return new PeekResult.ProviderConflict(shared);
}
var unproved = UnprovedProviders(folded, folded.Logins.Select(x => x.Provider));
return unproved.Count > 0
? new PeekResult.LoginsUnproved(unproved)
: new PeekResult.Ready(survivor.Proofs.Values.MaxBy(x => x.RedeemedAt)!.Proof, [.. folded.Proofs.Values.Select(x => x.Proof)]);
}
}
/// <summary>The Self side's proofs for an undo, once every login whose id is in <paramref name="movedIdentityIds"/> is proved.</summary>
public UndoPeekResult PeekUndo(Guid actorId, IReadOnlyCollection<string> movedIdentityIds)
{
lock (_gate)
{
Prune(timeProvider.GetUtcNow());
if (!_held.TryGetValue(actorId, out var sides) || !sides.TryGetValue(MergeSide.Self, out var self))
{
return new UndoPeekResult.Expired();
}
var needed = self.Logins.Where(x => movedIdentityIds.Contains(x.Id)).Select(x => x.Provider).ToArray();
var unproved = UnprovedProviders(self, needed);
if (needed.Length < movedIdentityIds.Count)
{
// A moved login the list does not hold cannot be proved by it.
return new UndoPeekResult.LoginsUnproved(unproved);
}
return unproved.Count > 0
? new UndoPeekResult.LoginsUnproved(unproved)
: new UndoPeekResult.Ready([.. needed.Distinct(StringComparer.Ordinal).Select(x => self.Proofs[x].Proof)]);
}
}
public void Clear(Guid actorId)
{
lock (_gate)
{
Prune(timeProvider.GetUtcNow());
_held.Remove(actorId);
}
}
private static List<string> UnprovedProviders(Side side, IEnumerable<string> providers) =>
[.. providers.Distinct(StringComparer.Ordinal).Where(x => !side.Proofs.ContainsKey(x))];
private MergeSideView? View(Dictionary<MergeSide, Side>? sides, MergeSide which, DateTimeOffset now) =>
sides is not null && sides.TryGetValue(which, out var side)
? new MergeSideView(
side.UserAccountId,
[.. side.Proofs.Keys],
[.. side.Logins.Select(x => x.Provider)],
now - side.Proofs.Values.Min(x => x.RedeemedAt),
side.Proofs.Values.Min(x => x.RedeemedAt) + ProofLifetime)
: null;
/// <summary>Drops every proof at or past its lifetime, then every side without one, then every actor without a side.</summary>
private void Prune(DateTimeOffset now)
{
foreach (var (actor, sides) in _held.ToArray())
{
foreach (var (which, side) in sides.ToArray())
{
foreach (var (provider, held) in side.Proofs.ToArray())
{
if (now - held.RedeemedAt >= ProofLifetime)
{
side.Proofs.Remove(provider);
}
}
if (side.Proofs.Count == 0)
{
sides.Remove(which);
}
}
if (sides.Count == 0)
{
_held.Remove(actor);
}
}
}
}

View file

@ -0,0 +1,112 @@
using System.Globalization;
using Bifrost.Web.Domain;
namespace Bifrost.Web.Features.Membership;
/// <summary>
/// The one closed set of words the merge pages say, so no page ever prints what the store holds: a state, a Heimdall refusal code, a
/// saga outcome and an undo answer each map to a sentence here, and anything this class does not know gets a generic line, never an
/// echo. Dates are UTC.
/// </summary>
public static class MergeWording
{
public static string State(AccountMergeState state) => state switch
{
AccountMergeState.Proposed => "Proposed, not yet confirmed.",
AccountMergeState.Confirmed => "Confirmed; Heimdall has not yet moved the logins.",
AccountMergeState.HeimdallMoved => "The logins have moved; Bifrost is finishing the merge.",
AccountMergeState.Committed => "Merged. You can still undo it.",
AccountMergeState.Final => "Merged for good; the undo window has ended.",
AccountMergeState.UndoRequested => "Undo requested; the logins are being given back.",
AccountMergeState.HeimdallReversed => "The logins are back; Bifrost is restoring the accounts.",
AccountMergeState.Undone => "Undone; the accounts are separate again.",
AccountMergeState.Failed => "The merge did not happen and nothing was merged.",
_ => "In an unknown state."
};
/// <summary>Why Heimdall refused a merge, by its refusal code; null for a code this class does not know.</summary>
public static string? Failure(string code) => code switch
{
"app_auth_required" or "not_allowed_for_app" => "Heimdall would not take Bifrost's request.",
"not_reversible" => "Heimdall says those logins cannot be moved back.",
"proof_invalid" => "Heimdall did not accept a sign-in as proof.",
"same_account" => "Both sides were the same Heimdall account.",
"proof_stale" => "A sign-in was too old by the time Heimdall saw it.",
"identity_not_proved" => "A login was not proved by a fresh sign-in.",
"already_moved" => "Heimdall had already moved one of those logins.",
"provider_conflict" => "Both accounts hold a login of the same provider, which Heimdall will not combine.",
"identity_not_found" => "A login was no longer on its account.",
"invalid_request" => "Heimdall could not read the request.",
_ => null
};
/// <summary>The saga's last note on a merge, in words. Always a sentence: an outcome this class does not know is a retry line, never its own text.</summary>
public static string Outcome(string lastOutcome) => lastOutcome switch
{
"confirmed" => "Waiting for Heimdall to move the logins.",
"moved" => "Heimdall moved the logins.",
"refused" => "Heimdall refused the merge.",
"undo_requested" => "Waiting for Heimdall to give the logins back.",
"receipt_mismatch" => "Heimdall's answer did not match the merge. An operator must look at it.",
"proofs_unreadable" => "The stored proofs cannot be read. An operator must look at it.",
"transport" or "move_id_conflict" or "malformed or mismatched receipt" => "Heimdall could not be reached or gave an answer Bifrost could not use. It will try again.",
_ when lastOutcome.StartsWith("undo_", StringComparison.Ordinal) && Failure(lastOutcome["undo_".Length..]) is { } reason =>
"Heimdall refused the undo, so the merge stands. " + reason,
_ => "Waiting to try again."
};
/// <summary>The words for a closed undo code (<see cref="UndoCode"/> or <c>logins_unproved</c>); empty for anything else.</summary>
public static string Undo(string code) => code switch
{
"requested" => "Undo requested. The logins are being given back.",
"not_committed" => "This merge is not in a state that can be undone.",
"grace_over" => "The undo window has ended.",
"not_survivor" => "Only the account the merge kept can undo it.",
"proof_account_mismatch" => "That sign-in was not for this account. Nothing was changed.",
"logins_unproved" => "Every login that moved needs a fresh sign-in first. Nothing was changed.",
_ => string.Empty
};
/// <summary>The words for a closed code the merge page answers a confirm or a start with; empty for anything else.</summary>
public static string Confirm(string code) => code switch
{
"blocked" => "These accounts cannot be merged now. Nothing was changed.",
"preview_stale" => "Something changed since you looked; review the merge again. Nothing was changed and your sign-ins are kept.",
"not_party" => "Only one of the two accounts can confirm this merge. Nothing was changed.",
"proof_account_mismatch" => "A sign-in was not for the account it was meant for. Nothing was changed.",
"proofs_needed" => "Both accounts need a fresh sign-in, one for every login of the account that would be folded. Nothing was changed.",
"consent_needed" => "Tick both boxes to confirm. Nothing was changed.",
"bad_choice" => "Choose which account to keep. Nothing was changed.",
_ => string.Empty
};
public static string UndoCode(UndoOutcome outcome) => outcome switch
{
UndoOutcome.Requested => "requested",
UndoOutcome.NotCommitted => "not_committed",
UndoOutcome.GraceOver => "grace_over",
UndoOutcome.NotSurvivor => "not_survivor",
UndoOutcome.ProofAccountMismatch => "proof_account_mismatch",
_ => "not_committed"
};
public static string TierNote(string? fromTier, DateTimeOffset graceEnds) => fromTier == "strong"
? $"Approved with a passkey; you have until {Date(graceEnds)} to undo."
: $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo.";
/// <summary>Whole days from <paramref name="now"/> to <paramref name="end"/>, rounded up, at least one while the window is open.</summary>
public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays));
public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left";
public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status.";
public static string UndoRequestedNotice() => "A merge into your account is being undone: Heimdall is giving the logins back. Review it on Merge status.";
public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles.";
public static string FoldedNotice(DateTimeOffset mergedOn) =>
$"This account was merged on {Date(mergedOn)}; sign in with a login you had on it to reach the account that holds them.";
public static string Date(DateTimeOffset when) => when.UtcDateTime.ToString("d MMM yyyy", CultureInfo.InvariantCulture) + " UTC";
}

View file

@ -215,6 +215,13 @@
}
</div>
}
@if (Model.MergeEnabled)
{
<p class="muted">
Hold two accounts? <a asp-page="/Membership/MergeAccounts">Merge two accounts</a>
</p>
}
}
<div class="panel-actions">

View file

@ -26,6 +26,7 @@ public sealed class LinkedAccountsModel(
PasskeyService passkeys,
ICurrentBifrostActorAccessor actors,
IOptions<BifrostHostOptions> hostOptions,
IOptions<AccountMergeOptions> mergeOptions,
ILogger<LinkedAccountsModel> logger) : PageModel
{
private static readonly Dictionary<string, string> ProviderNames = new(StringComparer.Ordinal)
@ -52,6 +53,9 @@ public sealed class LinkedAccountsModel(
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
/// <summary>Whether the merge page exists on this server (a flag, dark until Heimdall can move logins).</summary>
public bool MergeEnabled => mergeOptions.Value.Enabled;
/// <summary>The wording of the outcome the page was sent back with: a closed set, null for anything else.</summary>
public string? ResultMessage { get; private set; }

View file

@ -0,0 +1,148 @@
@page
@model Bifrost.Web.Pages.Membership.MergeAccountsModel
@{
ViewData["Title"] = "Merge two accounts";
}
<section class="panel">
<span class="eyebrow">Account merge</span>
<h1>Merge two accounts</h1>
<p class="panel-intro">
If one person holds two accounts, they can be merged into one. You prove both accounts with a fresh sign-in each, choose
which one stays, see what changes and confirm; nothing is named until both proofs exist, and the other account is only
ever the one Heimdall identified from your second sign-in.
</p>
@if (Model.ResultMessage is not null)
{
<div class="callout" role="status">@Model.ResultMessage</div>
}
@if (Model.ConfirmMessage is not null)
{
<div class="callout warning" role="status">@Model.ConfirmMessage</div>
}
@if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Prove)
{
<h2>Prove both accounts</h2>
<p class="muted">
Sign-ins are held in memory for a few minutes and then dropped. For each account, name a provider that account holds:
signing in through a provider it does not hold would make an unrelated account.
</p>
<h3>This account</h3>
@if (Model.SelfProved)
{
<p>This account is proved.</p>
}
else
{
<div class="panel-actions">
@foreach (var provider in Model.LinkableProviders)
{
<form method="post" asp-page-handler="ProveSelf" class="inline-actions">
<input type="hidden" name="via" value="@provider" />
<button class="button secondary" type="submit">Prove this account with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
</form>
}
</div>
}
<h3>The other account</h3>
@if (Model.OtherProved)
{
<p>The other account is proved.</p>
}
else
{
<div class="panel-actions">
@foreach (var provider in Model.LinkableProviders)
{
<form method="post" asp-page-handler="ProveOther" class="inline-actions">
<input type="hidden" name="via" value="@provider" />
<button class="button secondary" type="submit">Prove the other account with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
</form>
}
</div>
}
}
else
{
<h2>The two accounts</h2>
<ul>
<li>This account: <strong>@Model.SelfName</strong></li>
<li>The other account: <strong>@Model.OtherName</strong></li>
</ul>
@if (Model.Keep is null)
{
<p>Choose the account that stays. The other one is folded into it.</p>
<div class="panel-actions">
<a class="button" asp-page="/Membership/MergeAccounts" asp-route-keep="self">Keep this account</a>
<a class="button" asp-page="/Membership/MergeAccounts" asp-route-keep="other">Keep the other account</a>
</div>
}
else
{
<h2>Keep @Model.KeptName</h2>
<p>@Model.FoldedName is folded into it.</p>
<ul>
@foreach (var line in Model.PreviewLines)
{
<li>@line</li>
}
</ul>
@foreach (var statement in Model.Statements)
{
<p class="muted">@statement</p>
}
<p>
<a asp-page="/Membership/MergeAccounts" asp-route-keep="@(Model.Keep == "self" ? "other" : "self")">Keep @Model.FoldedName instead</a>
</p>
@if (Model.BlockWords.Count > 0)
{
<div class="callout warning">
<ul>
@foreach (var words in Model.BlockWords)
{
<li>@words</li>
}
</ul>
</div>
}
else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.ProveRemaining)
{
<p>
@Model.FoldedName holds more logins. Sign in once more through each one, so Heimdall knows the whole account agrees.
</p>
<div class="panel-actions">
@foreach (var provider in Model.ProvidersToProve)
{
<form method="post" asp-page-handler="ProveRemaining" class="inline-actions">
<input type="hidden" name="via" value="@provider" />
<input type="hidden" name="keep" value="@Model.Keep" />
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.MergeAccountsModel.ProviderName(provider)</button>
</form>
}
</div>
}
else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Confirm)
{
<form method="post" asp-page-handler="Confirm">
<input type="hidden" name="keep" value="@Model.Keep" />
<input type="hidden" name="digest" value="@Model.Digest" />
<p>
<label><input type="checkbox" name="consentSelf" value="true" /> I agree, for the account @Model.SelfName.</label>
</p>
<p>
<label><input type="checkbox" name="consentOther" value="true" /> I agree, for the account @Model.OtherName.</label>
</p>
<button class="button" type="submit">Merge into @Model.KeptName</button>
</form>
}
}
}
</section>

View file

@ -0,0 +1,270 @@
using System.Security.Claims;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
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.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Membership;
/// <summary>
/// Where a member merges two accounts: proves both by fresh sign-ins, chooses which one stays against a preview, ticks one box for each
/// account and confirms. The page owns only that choice and that consent. Its stage is derived from <see cref="MergeProofs"/> and the
/// query on every request, nothing about progress is kept in a cookie or a hidden field except the chosen direction and the preview's
/// digest, and both are re-checked by the saga. The other account is named (by display name only) once both proofs are held, and
/// found by no email, name or login string: it is the account Heimdall identified from the second proof. The page writes no merge,
/// vote, membership or journal row: <see cref="AccountMergeSaga.ConfirmAsync"/> inserts the one Confirmed row that is the consent.
/// </summary>
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class MergeAccountsModel(
ICurrentBifrostActorAccessor actors,
BifrostDbContext dbContext,
AccountMergeLedger ledger,
AccountMergeSaga saga,
MergeProofs proofs,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
IOptions<AccountMergeOptions> mergeOptions,
ILogger<MergeAccountsModel> logger) : PageModel
{
private const string KeepParameter = "keep";
private const string MergeParameter = "merge";
private const string Self = "self";
private const string Other = "other";
public enum MergeStage
{
/// <summary>One side or both lack a proof: nothing is named.</summary>
Prove,
/// <summary>Both sides are proved: the member chooses which stays.</summary>
Choose,
/// <summary>A direction is chosen and the folded account's remaining logins are asked one sign-in each.</summary>
ProveRemaining,
/// <summary>Everything the saga needs is held: the confirm form.</summary>
Confirm
}
public MergeStage Stage { get; private set; } = MergeStage.Prove;
public string? ResultMessage { get; private set; }
public string? ConfirmMessage { get; private set; }
public bool SelfProved { get; private set; }
public bool OtherProved { get; private set; }
/// <summary>Display names, set only once both sides are proved.</summary>
public string? SelfName { get; private set; }
public string? OtherName { get; private set; }
/// <summary>The chosen direction, "self" or "other", or null.</summary>
public string? Keep { get; private set; }
public string? KeptName => Keep == Self ? SelfName : Keep == Other ? OtherName : null;
public string? FoldedName => Keep == Self ? OtherName : Keep == Other ? SelfName : null;
public IReadOnlyList<string> PreviewLines { get; private set; } = [];
public IReadOnlyList<string> BlockWords { get; private set; } = [];
public IReadOnlyList<string> Statements => MergePreview.Statements;
public IReadOnlyList<string> ProvidersToProve { get; private set; } = [];
public string Digest { get; private set; } = string.Empty;
public IReadOnlyList<string> LinkableProviders => HeimdallAuthService.LinkableProviders;
public static string ProviderName(string provider) => MergeStatusModel.ProviderName(provider);
private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null;
/// <summary>Off, the page does not exist; a folded or frozen account is forbidden it, as it is Linked accounts.</summary>
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
if (!mergeOptions.Value.Enabled)
{
context.Result = NotFound();
return;
}
var actor = await actors.GetAsync(context.HttpContext.RequestAborted);
if (actor.Merge is not null || actor.UserAccount is null || AccountId is null)
{
context.Result = Forbid();
return;
}
await base.OnPageHandlerExecutionAsync(context, next);
}
public async Task OnGetAsync(CancellationToken cancellationToken)
{
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
ConfirmMessage = MergeWording.Confirm(Request.Query[MergeParameter].ToString()) is { Length: > 0 } words ? words : null;
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
var held = proofs.Snapshot(me);
SelfProved = held.Self is not null;
OtherProved = held.Other is not null;
if (held.Self is not { } self || held.Other is not { } other)
{
return;
}
var names = await dbContext.UserAccounts.AsNoTracking()
.Where(x => x.Id == self.UserAccountId || x.Id == other.UserAccountId)
.ToDictionaryAsync(x => x.Id, x => x.DisplayName, cancellationToken);
SelfName = names.GetValueOrDefault(self.UserAccountId);
OtherName = names.GetValueOrDefault(other.UserAccountId);
Stage = MergeStage.Choose;
if (SideOf(Request.Query[KeepParameter].ToString()) is not { } survivorSide)
{
return;
}
Keep = survivorSide == MergeSide.Self ? Self : Other;
var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self);
var plan = await ledger.PlanAsync(survivor.UserAccountId, folded.UserAccountId, cancellationToken);
Digest = plan.Digest(survivor.UserAccountId, folded.UserAccountId);
PreviewLines = MergePreview.Lines(plan);
BlockWords = [.. plan.Blocks.Select(MergePreview.Block)];
switch (proofs.Peek(me, survivorSide))
{
case PeekResult.ProviderConflict conflict:
BlockWords = [.. BlockWords, MergePreview.ProviderConflict(conflict.Providers.Select(ProviderName))];
break;
case PeekResult.Ready when plan.Blocks.Count == 0:
Stage = MergeStage.Confirm;
break;
case PeekResult.LoginsUnproved unproved:
ProvidersToProve = unproved.Providers;
Stage = MergeStage.ProveRemaining;
break;
}
}
/// <summary>Starts a fresh sign-in of the account signed in here.</summary>
public Task<IActionResult> OnPostProveSelfAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Self, via, cancellationToken);
/// <summary>Starts a fresh sign-in of the other account. The member names the provider: Bifrost never guesses one.</summary>
public Task<IActionResult> OnPostProveOtherAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Other, via, cancellationToken);
/// <summary>Starts the next sign-in the chosen folded account still owes; its side follows from the chosen direction.</summary>
public Task<IActionResult> OnPostProveRemainingAsync(string? via, string? keep, CancellationToken cancellationToken) =>
SideOf(keep) is { } survivorSide
? BeginAsync(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, via, cancellationToken)
: Task.FromResult(Refused(null, "bad_choice"));
/// <summary>
/// The member's consent: both boxes, the direction and the digest of the preview they saw. A refusal writes nothing and keeps the
/// proofs; only a Confirmed row spends them.
/// </summary>
public async Task<IActionResult> OnPostConfirmAsync(string? keep, string? digest, bool consentSelf, bool consentOther, CancellationToken cancellationToken)
{
if (SideOf(keep) is not { } survivorSide)
{
return Refused(null, "bad_choice");
}
if (!consentSelf || !consentOther)
{
return Refused(keep, "consent_needed");
}
var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id;
var held = proofs.Snapshot(me);
var peeked = proofs.Peek(me, survivorSide);
if (peeked is PeekResult.ProviderConflict)
{
return Refused(keep, "blocked");
}
if (held.Self is not { } self || held.Other is not { } other || peeked is not PeekResult.Ready ready)
{
return Refused(keep, "proofs_needed");
}
var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self);
var outcome = await saga.ConfirmAsync(
new ConfirmMergeRequest(me, survivor.UserAccountId, folded.UserAccountId, digest ?? string.Empty, ready.Survivor, ready.Folded), cancellationToken);
switch (outcome)
{
case ConfirmOutcome.Confirmed confirmed:
proofs.Clear(me);
try
{
await saga.AdvanceAsync(confirmed.MergeId, cancellationToken);
}
catch (Exception error) when (error is not OperationCanceledException)
{
// The stored state is the truth and the sweeper resumes it.
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", confirmed.MergeId, error.GetType().Name);
}
return RedirectToPage("/Membership/MergeStatus");
case ConfirmOutcome.Blocked:
return Refused(keep, "blocked");
case ConfirmOutcome.PreviewStale:
return Refused(keep, "preview_stale");
case ConfirmOutcome.NotParty:
return Refused(keep, "not_party");
default:
return Refused(keep, "proof_account_mismatch");
}
}
private static MergeSide? SideOf(string? keep) => keep switch
{
Self => MergeSide.Self,
Other => MergeSide.Other,
_ => null
};
private async Task<IActionResult> BeginAsync(MergeSide side, string? via, CancellationToken cancellationToken)
{
try
{
var start = side == MergeSide.Self
? (Func<string, Task<HeimdallAuthStartResult>>)(id => heimdallAuthService.StartMergeProveSelfAsync(
id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken))
: id => heimdallAuthService.StartMergeProveOtherAsync(
id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
var started = await start(AccountId!);
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
return Redirect(started.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Merge proof start refused: {Reason}", error.Message);
return RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) });
}
}
private const string ReturnPath = "/Membership/MergeAccounts";
private IActionResult Refused(string? keep, string code)
{
var query = new Dictionary<string, string?>();
if (keep is Self or Other)
{
query[KeepParameter] = keep;
}
query[MergeParameter] = code;
return RedirectToPage(null, query);
}
}

View file

@ -0,0 +1,100 @@
@page
@model Bifrost.Web.Pages.Membership.MergeStatusModel
@{
ViewData["Title"] = "Merge status";
}
<section class="panel">
<span class="eyebrow">Account merge</span>
<h1>Merge status</h1>
<p class="panel-intro">
A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for
every login that moved.
</p>
@if (Model.ResultMessage is not null)
{
<div class="callout">@Model.ResultMessage</div>
}
@if (Model.UndoMessage is not null)
{
<div class="callout">@Model.UndoMessage</div>
}
@if (Model.Merges.Count == 0)
{
<p class="muted">There is no merge to show for this account.</p>
}
@foreach (var merge in Model.Merges)
{
<article class="status-banner">
<strong>@merge.State</strong>
@if (merge.Why is not null)
{
<p class="muted">@merge.Why</p>
}
@if (merge.Outcome is not null)
{
<p class="muted">@merge.Outcome</p>
}
<ul>
@if (merge.Confirmed is not null)
{
<li>Confirmed @merge.Confirmed</li>
}
@if (merge.Committed is not null)
{
<li>Merged @merge.Committed</li>
}
@if (merge.GraceEnds is not null)
{
<li>Undo window ends @merge.GraceEnds</li>
}
@if (merge.Final is not null)
{
<li>Final @merge.Final</li>
}
@if (merge.SurvivorProofs.Count > 0)
{
<li>Kept account proved with: @string.Join(", ", merge.SurvivorProofs)</li>
}
@if (merge.FoldedProofs.Count > 0)
{
<li>Folded account proved with: @string.Join(", ", merge.FoldedProofs)</li>
}
</ul>
@if (merge.DaysLeft is not null)
{
<p><span class="pill warning">@merge.DaysLeft</span></p>
}
@if (merge.TierNote is not null)
{
<p class="muted">@merge.TierNote</p>
}
@if (merge.CanUndo)
{
@if (merge.Ready)
{
<form method="post" asp-page-handler="Undo">
<input type="hidden" name="mergeId" value="@merge.Id" />
<button class="button" type="submit">Undo this merge</button>
</form>
}
else
{
<p class="muted">To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.</p>
@foreach (var provider in merge.NeedProof)
{
<form method="post" asp-page-handler="ProveForUndo">
<input type="hidden" name="via" value="@provider" />
<button class="button secondary" type="submit">Sign in with @Bifrost.Web.Pages.Membership.LinkedAccountsModel.ProviderName(provider)</button>
</form>
}
}
}
</article>
}
</section>

View file

@ -0,0 +1,206 @@
using System.Security.Claims;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Membership;
/// <summary>
/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in
/// <see cref="MergeWording"/>'s closed words, and the undo. It reads merge rows and writes none: undo is the saga's
/// <see cref="AccountMergeSaga.RequestUndoAsync"/> followed by its own <see cref="AccountMergeSaga.AdvanceAsync"/>, with fresh proofs
/// of every moved login held by <see cref="MergeProofs"/>. A person who signed in through a login that moved is the survivor here, so
/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and
/// no account name, id, email or login of either side is rendered.
/// </summary>
[Authorize]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class MergeStatusModel(
ICurrentBifrostActorAccessor actors,
BifrostDbContext dbContext,
AccountMergeSaga saga,
MergeProofs proofs,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
TimeProvider timeProvider,
ILogger<MergeStatusModel> logger) : PageModel
{
private const int MaxListed = 5;
private const string UndoParameter = "undo";
private const string LoginsUnproved = "logins_unproved";
public sealed record MergeRow(
Guid Id,
string State,
string? Why,
string? Outcome,
string? Confirmed,
string? Committed,
string? GraceEnds,
string? Final,
string? DaysLeft,
string? TierNote,
IReadOnlyList<string> SurvivorProofs,
IReadOnlyList<string> FoldedProofs,
bool CanUndo,
bool Ready,
IReadOnlyList<string> NeedProof);
public IReadOnlyList<MergeRow> Merges { get; private set; } = [];
public string? ResultMessage { get; private set; }
public string? UndoMessage { get; private set; }
public static string ProviderName(string provider) =>
HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider";
public async Task OnGetAsync(CancellationToken cancellationToken)
{
ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString());
UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null;
var actor = await actors.GetAsync(cancellationToken);
var me = actor.UserAccount?.Id;
var rows = me is { } id
? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken)
: [];
if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId)
&& await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named)
{
rows.Add(named);
}
Merges = [.. rows.Select(x => Row(x, me))];
}
/// <summary>Starts a fresh sign-in of this account through <paramref name="via"/>, held in memory until the undo takes it.</summary>
public async Task<IActionResult> OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken)
{
if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId)
{
return Result(HeimdallLinkOutcome.Error);
}
try
{
var started = await heimdallAuthService.StartMergeProveSelfAsync(
accountId, via, HeimdallLinkReturn, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken);
Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options());
return Redirect(started.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
logger.LogWarning("Merge undo proof start refused: {Reason}", error.Message);
return Result(HeimdallLinkOutcome.Error);
}
}
/// <summary>Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would.</summary>
public async Task<IActionResult> OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken)
{
var actor = await actors.GetAsync(cancellationToken);
if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id)
|| await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge
|| merge.SurvivorUserAccountId != me.Id)
{
return Undone("not_survivor");
}
if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready)
{
return Undone(LoginsUnproved);
}
var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken);
if (outcome is UndoOutcome.Requested)
{
proofs.Clear(me.Id);
try
{
await saga.AdvanceAsync(id, cancellationToken);
}
catch (Exception error) when (error is not OperationCanceledException)
{
// The saga's stored state is the truth and the sweeper resumes it.
logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name);
}
}
return Undone(MergeWording.UndoCode(outcome));
}
private const string HeimdallLinkReturn = "/Membership/MergeStatus";
private MergeRow Row(AccountMerge merge, Guid? me)
{
var now = timeProvider.GetUtcNow();
var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end;
var canUndo = open && me == merge.SurvivorUserAccountId;
var ready = false;
IReadOnlyList<string> need = [];
if (canUndo)
{
switch (proofs.PeekUndo(me!.Value, MovedIds(merge)))
{
case UndoPeekResult.Ready { Proofs.Count: > 0 }:
ready = true;
break;
case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved:
need = unproved.Providers;
break;
default:
need = HeimdallAuthService.LinkableProviders;
break;
}
}
var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now;
return new MergeRow(
merge.Id,
MergeWording.State(merge.State),
merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null,
merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved
or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed
? MergeWording.Outcome(merge.LastOutcome)
: null,
merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null,
merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null,
merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null,
merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null,
inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null,
inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null,
merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [],
[.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)],
canUndo,
ready,
need);
}
private static IReadOnlyCollection<string> MovedIds(AccountMerge merge)
{
try
{
return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize<string[]>(json) ?? [] : [];
}
catch (JsonException)
{
return [];
}
}
private IActionResult Undone(string code) =>
RedirectToPage(null, new Dictionary<string, string?> { [UndoParameter] = code });
private IActionResult Result(HeimdallLinkOutcome outcome) =>
RedirectToPage(null, new Dictionary<string, string?> { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) });
}

View file

@ -32,6 +32,14 @@
</div>
}
@if (Model.HasMerges)
{
<div class="panel-actions">
<a class="button secondary" asp-page="/Membership/MergeStatus">Merge status</a>
<span class="muted">See an account merge and, for seven days, undo it.</span>
</div>
}
@if (Model.Invitation is not null)
{
<div class="callout success">

View file

@ -17,6 +17,9 @@ public sealed class StatusModel(
/// <summary>Whether this session belongs to a Heimdall account whose logins the Linked accounts page can manage.</summary>
public bool CanManageLogins => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 };
/// <summary>Whether this account is in, or was in, a merge: the link to Merge status is shown only then.</summary>
public bool HasMerges { get; private set; }
public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous;
public MembershipInvitation? Invitation { get; private set; }
@ -50,6 +53,9 @@ public sealed class StatusModel(
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Actor = await actorAccessor.GetAsync(cancellationToken);
HasMerges = Actor.Merge is not null
|| (Actor.UserAccount is { } account
&& await dbContext.AccountMerges.AnyAsync(x => x.SurvivorUserAccountId == account.Id || x.FoldedUserAccountId == account.Id, cancellationToken));
if (Actor.UserAccount is null)
{

View file

@ -0,0 +1,5 @@
@model string
<aside class="merge-notice" role="status">
<span>@Model</span>
<a asp-page="/Membership/MergeStatus">Merge status</a>
</aside>

View file

@ -57,6 +57,11 @@
</div>
</header>
@if (User.Identity?.IsAuthenticated == true)
{
@await Component.InvokeAsync("MergeNotice")
}
<main class="page-frame">
@RenderBody()
</main>

View file

@ -139,8 +139,12 @@ builder.Services.AddSingleton<RegistrationChallengeService>();
builder.Services.AddSingleton<RegistrationRateLimit>();
builder.Services.AddScoped<NativeRegistration>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddOptions<AccountMergeOptions>().BindConfiguration(AccountMergeOptions.SectionName);
builder.Services.AddScoped<Bifrost.Web.Features.Membership.AccountMergeLedger>();
builder.Services.AddScoped<Bifrost.Web.Features.Membership.AccountMergeSaga>();
builder.Services.AddScoped<Bifrost.Web.Features.Membership.MergeNoticeFeed>();
builder.Services.AddOptions<ChatNoticesOptions>().BindConfiguration(ChatNoticesOptions.SectionName);
builder.Services.AddSingleton<Bifrost.Web.Features.Membership.MergeProofs>();
builder.Services.AddHostedService<Bifrost.Web.Features.Membership.AccountMergeSweeper>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -154,15 +158,8 @@ builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
// Heimdall's member routes take only the member's bearer: this client carries no app secret.
builder.Services.AddHttpClient<HeimdallMemberClient>((serviceProvider, client) =>
HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(HeimdallAuthService.RedactsHeader);
// The mover route is an app-secret call like HeimdallAuthService's: same ConfigureClient, same header redaction, no redirects.
builder.Services.AddHeimdallIdentityMoveClient();
// The app-secret and member-bearer clients and the mover: no redirects (see HeimdallClientServices).
builder.Services.AddHeimdallClients();
builder.Services.AddSingleton<LinkedAccountsSnapshots>();
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyRelyingParty>();
builder.Services.AddSingleton<Bifrost.Web.Features.Passkeys.PasskeyCeremonies>();
@ -826,6 +823,8 @@ app.MapPost("/api/sealed-motions/{id:guid}/mind", async (
: await SealedCall(async () => await service.HandleAsync(id, envelope, ct));
}).AllowAnonymous();
MergeNoticeEndpoints.Map(app);
app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) =>
{
httpContext.Response.Headers.CacheControl = "no-store";
@ -884,7 +883,7 @@ app.MapPost("/auth/heimdall/complete", async (
}
httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions());
return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value));
return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value, step.ReturnPath));
}
var signIn = await heimdallAuthService.CompleteAsync(
@ -906,7 +905,7 @@ app.MapPost("/auth/heimdall/complete", async (
}
return linking
? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error))
? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error, heimdallAuthService.MergeReturnPathOf(held?.Value, attempt)))
: Results.Redirect(HeimdallAuthService.ErrorRedirect);
}
}).AllowAnonymous();

View file

@ -0,0 +1,57 @@
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.ViewComponents;
/// <summary>
/// The layout's merge notice for a signed-in actor: the survivor of a Committed or UndoRequested merge inside its grace (days left), a
/// frozen account (a merge is in progress) or a folded one (where to sign in). It reads the merge row once per page view and keeps no
/// state of its own; the words are <see cref="MergeWording"/>'s, and no account name or id is ever in them.
/// </summary>
public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbContext dbContext, TimeProvider timeProvider) : ViewComponent
{
public async Task<IViewComponentResult> InvokeAsync()
{
var actor = await actors.GetAsync(HttpContext.RequestAborted);
var text = await TextAsync(actor, HttpContext.RequestAborted);
return text is null ? Content(string.Empty) : View("Default", text);
}
private async Task<string?> TextAsync(CurrentBifrostActor actor, CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
if (actor.Merge is { } standing)
{
if (standing is MergeStanding.Frozen)
{
return MergeWording.FrozenNotice();
}
var mergedOn = await dbContext.AccountMerges.AsNoTracking()
.Where(x => x.Id == standing.MergeId)
.Select(x => x.CommittedUtc ?? x.CreatedUtc)
.FirstOrDefaultAsync(cancellationToken);
return MergeWording.FoldedNotice(mergedOn);
}
if (actor.UserAccount is not { } account)
{
return null;
}
var open = await dbContext.AccountMerges.AsNoTracking()
.Where(x => x.SurvivorUserAccountId == account.Id
&& (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested)
&& x.GraceEndsUtc > now)
.OrderBy(x => x.GraceEndsUtc)
.Select(x => new { x.State, x.GraceEndsUtc })
.FirstOrDefaultAsync(cancellationToken);
return open is null ? null
: open.State == AccountMergeState.UndoRequested ? MergeWording.UndoRequestedNotice()
: MergeWording.SurvivorNotice(MergeWording.DaysUntil(open.GraceEndsUtc!.Value, now));
}
}

View file

@ -1083,4 +1083,39 @@ public sealed class AccountMergeLedgerTests
Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status);
}
[Fact]
public async Task The_digest_names_the_survivor_so_two_memberless_survivors_with_identical_records_digest_differently()
{
// A memberless survivor leaves no entry that mentions it: the plan for (A, folded) and for (C, folded) is the same list of
// entries, so only the pair the digest is made for can tell them apart (heimdall-body:finding:cut-merge-saga.s2.digest-pair-binding-unpinned).
var world = new World();
var folded = await world.AccountAsync("folded");
var a = await MemberlessAsync(world, "survivor-a");
var c = await MemberlessAsync(world, "survivor-c");
await using var db = world.Db();
var forA = await world.Ledger(db).PlanAsync(a.Id, folded.Id, default);
var forC = await world.Ledger(db).PlanAsync(c.Id, folded.Id, default);
Assert.Empty(forA.Blocks);
Assert.Empty(forC.Blocks);
Assert.Equal(forA.Entries.Select(x => (x.Kind, x.Action, x.PriorText)), forC.Entries.Select(x => (x.Kind, x.Action, x.PriorText)));
Assert.NotEqual(forA.Digest(a.Id, folded.Id), forC.Digest(c.Id, folded.Id));
Assert.NotEqual(forA.Digest(a.Id, folded.Id), forA.Digest(c.Id, folded.Id)); // a digest made for one pair does not confirm the other
Assert.Equal(forA.Digest(a.Id, folded.Id), (await world.Ledger(db).PlanAsync(a.Id, folded.Id, default)).Digest(a.Id, folded.Id));
}
private static async Task<UserAccount> MemberlessAsync(World world, string name)
{
await using var db = world.Db();
var account = new UserAccount
{
DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = name, HeimdallAccountId = $"hd-{name}-{Guid.NewGuid():N}",
CreatedAtUtc = T0, LastSeenAtUtc = T0
};
db.UserAccounts.Add(account);
await db.SaveChangesAsync();
return account;
}
}

View file

@ -407,6 +407,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
{
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261113120000_AddPasskeyCredentials"));
Assert.Contains("roll-forward only", Pg(error).MessageText);
// The acks migration reverts first (it holds only acks); the saga migration is the one that refuses.
Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last());
}
@ -415,7 +416,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
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 index and all
Assert.Equal("20261114130000_AddAccountMergeSaga", (await down.Database.GetAppliedMigrationsAsync()).Last());
Assert.Equal("20261114140000_AddAccountMergeNoticeAcks", (await down.Database.GetAppliedMigrationsAsync()).Last());
}
// ---- restage and intake journal ----
@ -873,6 +874,7 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
{
var error = await Assert.ThrowsAnyAsync<Exception>(() => db.GetService<IMigrator>().MigrateAsync("20261114120000_AddAccountMerges"));
Assert.Contains("AddAccountMergeSaga cannot be reverted", Pg(error).MessageText);
// The acks migration reverts first (it holds only acks); the saga migration is the one that refuses.
Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last());
}

View file

@ -20,12 +20,12 @@ public sealed class AccountMergeSagaTests
{
private const string Passkey = "bifrost-passkey";
private sealed record Party(Guid Id, string HeimdallId, IReadOnlyDictionary<string, string> Logins);
internal sealed record Party(Guid Id, string HeimdallId, IReadOnlyDictionary<string, string> Logins);
/// <summary>A host plus the helpers every test needs: accounts with logins at the fake Heimdall, proofs, a scope per operation.</summary>
private sealed class Rig : IDisposable
internal sealed class Rig(bool realSessions = false, bool accountMerge = false, string? chatNoticesToken = null) : IDisposable
{
public HeimdallHost Host { get; } = new();
public HeimdallHost Host { get; } = new(realSessions: realSessions, accountMerge: accountMerge, chatNoticesToken: chatNoticesToken);
public FakeMoves Moves => Host.Heimdall.Moves;
@ -42,7 +42,8 @@ public sealed class AccountMergeSagaTests
var account = new UserAccount
{
DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name),
HeimdallAccountId = heimdallId, CreatedAtUtc = Clock.Now, LastSeenAtUtc = Clock.Now
HeimdallAccountId = heimdallId, CreatedAtUtc = Clock.Now, LastSeenAtUtc = Clock.Now,
MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = Clock.Now }
};
account.Membership = new Membership { UserAccountId = account.Id, Status = MembershipStatus.Active };
db.UserAccounts.Add(account);

View file

@ -556,7 +556,7 @@ public sealed class HeimdallGoogleLinkTests
}
[Theory]
[InlineData("9")]
[InlineData("11")]
[InlineData("-1")]
[InlineData("99")]
public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose)

View file

@ -0,0 +1,76 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Heimdall;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
/// <summary>
/// Every Heimdall client as Program registers it (<see cref="HeimdallClientServices.AddHeimdallClients"/>): a 3xx from Heimdall's origin
/// is the answer, never a second request. The app secret and a member's bearer reach only the origin they were configured for.
/// </summary>
public sealed class HeimdallRedirectTests
{
private static int FreePort()
{
var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
[Theory]
[InlineData(typeof(HeimdallAuthService), 307)]
[InlineData(typeof(HeimdallAuthService), 302)]
[InlineData(typeof(HeimdallMemberClient), 307)]
[InlineData(typeof(HeimdallMemberClient), 302)]
[InlineData(typeof(HeimdallIdentityMoveClient), 307)]
public async Task A_redirect_from_Heimdall_is_not_followed_and_no_secret_or_bearer_reaches_the_other_host(Type client, int code)
{
int first = FreePort(), second = FreePort();
using var origin = new HttpListener();
origin.Prefixes.Add($"http://127.0.0.1:{first}/");
origin.Start();
using var other = new HttpListener();
other.Prefixes.Add($"http://127.0.0.1:{second}/");
other.Start();
var redirected = Task.Run(async () =>
{
var context = await origin.GetContextAsync();
context.Response.StatusCode = code;
context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal";
context.Response.Close();
});
var stolen = new List<string>();
_ = Task.Run(async () =>
{
try
{
var context = await other.GetContextAsync();
stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + context.Request.Headers["Authorization"]);
context.Response.StatusCode = 200;
context.Response.Close();
}
catch (Exception error) when (error is HttpListenerException or ObjectDisposedException)
{
}
});
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = $"http://127.0.0.1:{first}", AppSecret = "APP-SECRET-VALUE" }));
services.AddHeimdallClients();
using var provider = services.BuildServiceProvider();
using var http = provider.GetRequiredService<IHttpClientFactory>().CreateClient(client.Name);
using var request = new HttpRequestMessage(HttpMethod.Get, "/v1/me/identities");
request.Headers.Authorization = new("Bearer", "MEMBER-BEARER");
using var response = await http.SendAsync(request);
await redirected;
await Task.Delay(300);
Assert.Equal(code, (int)response.StatusCode);
Assert.Empty(stolen);
}
}

View file

@ -0,0 +1,574 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static Bifrost.Web.Tests.AccountMergeSagaTests;
namespace Bifrost.Web.Tests;
/// <summary>
/// The merge page from the member's side: browser sessions of the real host over the saga rig and the fake Heimdall. Stages derive
/// from the proofs held; the other account is unnamed until both proofs exist; consent is two boxes and the saga's Confirmed row.
/// </summary>
public sealed class MergeAccountsPageTests
{
private const string Url = MergePage.AccountsUrl;
private const string OtherName = "banks-2d7c";
private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text);
private sealed class World : IDisposable
{
public Rig Rig { get; private init; } = null!;
public Party Me { get; private init; } = null!;
public Party Other { get; private init; } = null!;
public string Session { get; private init; } = string.Empty;
/// <summary>The name the account carries after signing in (a sign-in refreshes it from Heimdall).</summary>
public string MyName { get; private init; } = string.Empty;
public MergePage.Antiforgery? Token { get; private set; }
public HeimdallHost Host => Rig.Host;
public List<string> Surface { get; } = [];
public List<string> Bearers { get; } = [];
public MergeProofs Proofs => Host.Services.GetRequiredService<MergeProofs>();
public static async Task<World> Make(bool enabled = true, string[]? otherProviders = null)
{
var rig = new Rig(realSessions: true, accountMerge: enabled);
var me = await rig.AccountAsync("quill-9f3a", "discord");
var other = await rig.AccountAsync(OtherName, otherProviders ?? ["patreon", "google"]);
var session = await rig.Host.SignInSessionAsync(me.HeimdallId, "discord");
var myName = await rig.InScopeAsync(async sp => (await sp.GetRequiredService<BifrostDbContext>().UserAccounts.AsNoTracking().SingleAsync(x => x.Id == me.Id)).DisplayName);
var world = new World { Rig = rig, Me = me, Other = other, Session = session, MyName = myName };
if (enabled)
{
world.Token = await MergePage.TokenAsync(rig.Host, session, Url);
}
return world;
}
public void Dispose() => Rig.Dispose();
public async Task<(HttpResponseMessage Response, string Html)> Get(string query = "", string? session = null)
{
var (response, html) = await MergePage.GetAsync(Host, session ?? Session, query, Url);
Surface.Add(html + "\n" + MergePage.Location(response));
return (response, html);
}
public async Task<string> Page(string query = "")
{
var (response, html) = await Get(query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return html;
}
public async Task<HttpResponseMessage> Post(string handler, params (string, string)[] form)
{
var response = await MergePage.PostAsync(Host, Session, Token!, handler, form.ToDictionary(x => x.Item1, x => x.Item2), Url);
Surface.Add(MergePage.Location(response) + "\n" + string.Join('\n', response.Headers.Select(x => x.Key + string.Concat(x.Value))));
return response;
}
/// <summary>One fresh sign-in of <paramref name="proved"/> through its login of <paramref name="provider"/>, started by the page's own button and finished as Heimdall's return does.</summary>
public async Task Prove(string handler, Party proved, string provider)
{
var before = Host.Heimdall.StartBodies.Count;
var started = await Post(handler, ("via", provider), ("keep", handler == "ProveRemaining" ? "self" : string.Empty));
Assert.Equal(HttpStatusCode.Redirect, started.StatusCode);
var attempt = Host.AttemptFrom(started, before);
Assert.NotEmpty(attempt.Nonce);
Host.Heimdall.Identities.Clear();
foreach (var (name, id) in proved.Logins)
{
Host.Heimdall.AddIdentity(name, id: id);
}
var bearer = Host.Token(proved.HeimdallId);
Rig.Moves.Issue(proved.HeimdallId, proved.Logins[provider], bearer: bearer);
Bearers.Add(bearer);
var done = await Host.PostCompleteAsync(
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, proved.HeimdallId, bearer, provider: provider) },
$"{BifrostCookies.Session}={Session}; {attempt.CookieName}={attempt.Cookie}");
Surface.Add(MergePage.Location(done));
Assert.Equal(Url + "?result=merge_proof_recorded", MergePage.Location(done));
}
public Task ProveSelf(string provider = "discord") => Prove("ProveSelf", Me, provider);
public Task ProveOther(string provider) => Prove("ProveOther", Other, provider);
/// <summary>Both accounts proved in full, so either can be kept.</summary>
public async Task ProveAll()
{
await ProveSelf();
await ProveOther("patreon");
await ProveOther("google");
}
public static string DigestIn(string html) => Regex.Match(html, "name=\"digest\"[^>]*value=\"([0-9a-f]{64})\"").Groups[1].Value;
public Task<HttpResponseMessage> Confirm(string keep, string digest, bool self = true, bool other = true)
{
var form = new List<(string, string)> { ("keep", keep), ("digest", digest) };
if (self)
{
form.Add(("consentSelf", "true"));
}
if (other)
{
form.Add(("consentOther", "true"));
}
return Post("Confirm", [.. form]);
}
}
private static string Where(string keep, string code) => $"{Url}?keep={keep}&merge={code}";
// ---- the flag and the gates ----
[Fact]
public async Task Disabled_the_page_is_404_and_Linked_accounts_renders_no_link()
{
using var w = await World.Make(enabled: false);
var (response, _) = await w.Get();
var (_, linked) = await w.Host.GetLinkedAccountsAsync(w.Session);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.DoesNotContain("Merge two accounts", linked);
Assert.DoesNotContain("MergeAccounts", linked);
}
[Fact]
public async Task Enabled_Linked_accounts_links_to_the_page()
{
using var w = await World.Make();
var (_, linked) = await w.Host.GetLinkedAccountsAsync(w.Session);
Assert.Contains("Merge two accounts", linked);
Assert.Contains(Url, linked);
}
[Fact]
public async Task A_frozen_account_is_forbidden_the_page()
{
using var w = await World.Make();
await w.Rig.ConfirmedAsync(w.Me, w.Other, w.Rig.Weak(w.Me, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google"));
var folded = await w.Host.SignInSessionAsync(w.Other.HeimdallId, "patreon");
var (response, _) = await w.Get(session: folded);
AssertRefused(response);
}
/// <summary>Forbid answers 403 or sends the browser to the access-denied page; either way the page is not served.</summary>
private static void AssertRefused(HttpResponseMessage response) =>
Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString());
// ---- stages derive from the proofs held ----
[Fact]
public async Task With_no_proof_the_page_asks_for_both_and_says_nothing_is_named_until_both_exist()
{
using var w = await World.Make();
var page = await w.Page();
Assert.Contains("ProveSelf", page);
Assert.Contains("ProveOther", page);
Assert.Contains("nothing is named until both", page);
Assert.DoesNotContain("Keep this account", page);
}
[Fact]
public async Task Nothing_about_the_other_account_appears_before_its_proof_is_stored()
{
using var w = await World.Make();
await w.ProveSelf();
var onlySelf = await w.Page();
var onlySelfKept = await w.Page("?keep=other");
Assert.DoesNotContain(OtherName, onlySelf + onlySelfKept);
Assert.DoesNotContain(w.Other.HeimdallId, onlySelf + onlySelfKept);
Assert.DoesNotContain(w.Other.Id.ToString(), onlySelf + onlySelfKept);
Assert.DoesNotContain("Keep this account", onlySelf);
}
[Fact]
public async Task The_other_proof_alone_names_neither_account()
{
using var w = await World.Make();
await w.ProveOther("patreon");
var page = await w.Page();
Assert.DoesNotContain(OtherName, page);
Assert.DoesNotContain("Keep this account", page);
}
[Fact]
public async Task Both_proofs_name_both_accounts_by_display_name_and_offer_each_survivor()
{
using var w = await World.Make();
await w.ProveSelf();
await w.ProveOther("patreon");
var page = await w.Page();
Assert.Contains(w.MyName, page);
Assert.Contains(OtherName, page);
Assert.Contains("Keep this account", page);
Assert.Contains("Keep the other account", page);
Assert.Contains("keep=self", page);
Assert.Contains("keep=other", page);
Assert.DoesNotContain(w.Other.HeimdallId, page);
Assert.DoesNotContain(w.Other.Id.ToString(), page);
}
[Fact]
public async Task The_folded_sides_unproved_logins_are_asked_one_sign_in_each_and_the_kept_side_needs_none()
{
using var w = await World.Make();
await w.ProveSelf();
await w.ProveOther("patreon");
var keepSelf = await w.Page("?keep=self");
var keepOther = await w.Page("?keep=other");
Assert.Contains("ProveRemaining", keepSelf);
Assert.Contains("Google", keepSelf);
Assert.DoesNotContain("name=\"digest\"", keepSelf);
Assert.DoesNotContain("ProveRemaining", keepOther);
Assert.Matches("name=\"digest\"", keepOther);
await w.Prove("ProveRemaining", w.Other, "google");
Assert.Matches("name=\"digest\"", await w.Page("?keep=self"));
}
[Fact]
public async Task Proofs_older_than_their_lifetime_fall_out_and_the_page_asks_again()
{
using var w = await World.Make();
await w.ProveAll();
Assert.Matches("name=\"digest\"", await w.Page("?keep=other"));
w.Rig.Clock.Advance(MergeProofs.ProofLifetime + TimeSpan.FromSeconds(1));
var page = await w.Page("?keep=other");
Assert.Contains("ProveSelf", page);
Assert.DoesNotContain("name=\"digest\"", page);
Assert.DoesNotContain(OtherName, page);
}
// ---- the preview and the digest ----
[Fact]
public async Task The_two_directions_preview_with_different_digests_and_a_stale_one_is_PreviewStale_and_keeps_the_proofs()
{
using var w = await World.Make();
await w.ProveAll();
var keepSelf = World.DigestIn(await w.Page("?keep=self"));
var keepOther = World.DigestIn(await w.Page("?keep=other"));
Assert.NotEqual(keepSelf, keepOther);
Assert.Equal(await w.Rig.DigestAsync(w.Me, w.Other), keepSelf);
Assert.Equal(await w.Rig.DigestAsync(w.Other, w.Me), keepOther);
var response = await w.Confirm("self", keepOther);
Assert.Equal(Where("self", "preview_stale"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self);
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other);
Assert.Contains(Enc(MergeWording.Confirm("preview_stale")), await w.Page("?keep=self&merge=preview_stale"));
}
[Fact]
public async Task A_change_after_the_member_looked_makes_the_digest_stale()
{
using var w = await World.Make();
await w.ProveAll();
var seen = World.DigestIn(await w.Page("?keep=self"));
var motion = await w.Rig.MotionAsync(w.Me);
await w.Rig.VoteAsync(motion, w.Other, VoteChoice.For);
var response = await w.Confirm("self", seen);
Assert.Equal(Where("self", "preview_stale"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
}
[Fact]
public async Task The_preview_shows_the_plans_counts_and_the_static_statements_and_never_the_digests_inputs()
{
using var w = await World.Make();
var motion = await w.Rig.MotionAsync(w.Me);
await w.Rig.VoteAsync(motion, w.Other, VoteChoice.For);
await w.ProveAll();
var page = await w.Page("?keep=self");
Assert.Contains("1 open vote ", page);
foreach (var statement in MergePreview.Statements)
{
Assert.Contains(Enc(statement), page);
}
}
[Fact]
public async Task A_blocked_pair_shows_the_blocks_words_and_no_confirm_form()
{
using var w = await World.Make();
var third = await w.Rig.AccountAsync("third-77aa", "discord");
await w.Rig.ConfirmedAsync(third, w.Other, w.Rig.Weak(third, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google"));
await w.ProveAll();
var page = await w.Page("?keep=other");
Assert.Contains(Enc(MergePreview.Block("merge_in_flight")), page);
Assert.DoesNotContain("consentSelf", page);
}
[Theory]
[InlineData("self")]
[InlineData("other")]
public async Task Two_accounts_holding_a_login_of_one_provider_are_blocked_before_consent_and_the_block_names_only_the_provider(string keep)
{
using var w = await World.Make(otherProviders: ["discord", "patreon"]);
await w.ProveSelf();
await w.ProveOther("discord");
await w.ProveOther("patreon");
var page = await w.Page($"?keep={keep}");
Assert.Contains("Both accounts hold a Discord login", page);
Assert.DoesNotContain("consentSelf", page);
Assert.DoesNotContain("name=\"digest\"", page);
Assert.DoesNotContain("ProveRemaining", page);
var words = Regex.Match(page, "Both accounts hold a[^<]*").Value;
Assert.DoesNotContain(OtherName, words);
Assert.DoesNotContain(w.MyName, words);
Assert.DoesNotContain(w.Other.HeimdallId, words);
}
[Fact]
public async Task A_confirm_for_accounts_holding_one_providers_login_is_Blocked_writes_nothing_and_keeps_the_proofs()
{
using var w = await World.Make(otherProviders: ["discord", "patreon"]);
await w.ProveSelf();
await w.ProveOther("discord");
await w.ProveOther("patreon");
var digest = await w.Rig.DigestAsync(w.Me, w.Other);
var response = await w.Confirm("self", digest);
Assert.Equal(Where("self", "blocked"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self);
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other);
Assert.Empty(w.Rig.Moves.Requests);
}
// ---- confirm ----
[Fact]
public async Task Confirm_with_only_the_self_proof_does_not_reach_the_saga()
{
using var w = await World.Make();
await w.ProveSelf();
var digest = await w.Rig.DigestAsync(w.Me, w.Other);
var response = await w.Confirm("self", digest);
Assert.Equal(Where("self", "proofs_needed"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
Assert.Empty(w.Rig.Moves.Requests);
}
[Fact]
public async Task Confirm_with_the_folded_sides_logins_not_all_proved_does_not_reach_the_saga()
{
using var w = await World.Make();
await w.ProveSelf();
await w.ProveOther("patreon");
var digest = await w.Rig.DigestAsync(w.Me, w.Other);
var response = await w.Confirm("self", digest);
Assert.Equal(Where("self", "proofs_needed"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Confirm_without_both_consent_boxes_writes_nothing_and_keeps_the_proofs(bool self, bool other)
{
using var w = await World.Make();
await w.ProveAll();
var digest = World.DigestIn(await w.Page("?keep=other"));
var response = await w.Confirm("other", digest, self, other);
Assert.Equal(Where("other", "consent_needed"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other);
}
[Fact]
public async Task A_confirm_that_is_not_self_or_other_is_BadChoice()
{
using var w = await World.Make();
await w.ProveAll();
var response = await w.Confirm("both", "x");
Assert.Equal(Url + "?merge=bad_choice", MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
}
[Fact]
public async Task A_blocked_confirm_is_Blocked_and_keeps_the_proofs()
{
using var w = await World.Make();
await w.ProveAll();
var digest = World.DigestIn(await w.Page("?keep=other"));
var third = await w.Rig.AccountAsync("third-77aa", "discord");
await w.Rig.ConfirmedAsync(third, w.Other, w.Rig.Weak(third, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google"));
var merges = await w.Rig.MergeCountAsync();
var response = await w.Confirm("other", digest);
Assert.Equal(Where("other", "blocked"), MergePage.Location(response));
Assert.Equal(merges, await w.Rig.MergeCountAsync());
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self);
}
[Fact]
public async Task A_proof_of_a_Heimdall_account_the_member_account_no_longer_has_is_ProofAccountMismatch_and_keeps_the_proofs()
{
using var w = await World.Make();
await w.ProveAll();
var digest = World.DigestIn(await w.Page("?keep=other"));
await w.Rig.InScopeAsync(async sp =>
{
var db = sp.GetRequiredService<BifrostDbContext>();
(await db.UserAccounts.SingleAsync(x => x.Id == w.Me.Id)).HeimdallAccountId = HeimdallShapes.NewAccountId();
return await db.SaveChangesAsync();
});
var response = await w.Confirm("other", digest);
Assert.Equal(Where("other", "proof_account_mismatch"), MergePage.Location(response));
Assert.Equal(0, await w.Rig.MergeCountAsync());
Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other);
}
[Theory]
[InlineData("self")]
[InlineData("other")]
public async Task A_confirm_in_either_direction_leaves_one_row_with_the_typed_consent_columns_sealed_proofs_and_spent_proofs(string keep)
{
using var w = await World.Make();
await w.ProveAll();
var digest = World.DigestIn(await w.Page("?keep=" + keep));
var response = await w.Confirm(keep, digest);
Assert.Equal(MergePage.Url, MergePage.Location(response));
var survivor = keep == "self" ? w.Me : w.Other;
var merge = Assert.Single(await w.Rig.InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().AccountMerges.AsNoTracking().ToListAsync()));
Assert.Equal(AccountMergeState.Committed, merge.State);
Assert.Equal(survivor.Id, merge.SurvivorUserAccountId);
Assert.Equal(w.Me.Id, merge.ConfirmedByUserAccountId);
Assert.Equal(digest, merge.PreviewDigest);
Assert.Null(merge.SealedProofs); // spent by the move that landed
if (keep == "self")
{
Assert.Equal("discord", merge.SurvivorProofProvider);
Assert.Equal("google,patreon", merge.FoldedProofProviders);
}
else
{
Assert.Contains(merge.SurvivorProofProvider, new[] { "patreon", "google" });
Assert.Equal("discord", merge.FoldedProofProviders);
}
Assert.Equal(new MergeProofsView(null, null), w.Proofs.Snapshot(w.Me.Id));
}
[Fact]
public async Task A_confirm_while_Heimdall_is_unreachable_still_lands_on_Merge_status_with_the_row_Confirmed_and_the_proofs_sealed()
{
using var w = await World.Make();
await w.ProveAll();
var digest = World.DigestIn(await w.Page("?keep=self"));
w.Rig.Moves.MoveDown = new HttpRequestException("down");
var response = await w.Confirm("self", digest);
Assert.Equal(MergePage.Url, MergePage.Location(response));
var merge = Assert.Single(await w.Rig.InScopeAsync(sp => sp.GetRequiredService<BifrostDbContext>().AccountMerges.AsNoTracking().ToListAsync()));
Assert.Equal(AccountMergeState.Confirmed, merge.State);
Assert.NotNull(merge.SealedProofs);
Assert.Equal(new MergeProofsView(null, null), w.Proofs.Snapshot(w.Me.Id));
}
[Fact]
public async Task The_whole_flow_by_sign_ins_ends_on_the_survivors_status_with_a_notice_and_the_folded_cookie_has_no_actor()
{
using var w = await World.Make();
var foldedSession = await w.Host.SignInSessionAsync(w.Other.HeimdallId, "patreon");
await w.ProveSelf();
await w.ProveOther("patreon");
await w.Prove("ProveRemaining", w.Other, "google");
var digest = World.DigestIn(await w.Page("?keep=self"));
var response = await w.Confirm("self", digest);
Assert.Equal(MergePage.Url, MergePage.Location(response));
var (_, status) = await MergePage.GetAsync(w.Host, w.Session);
Assert.Contains(Enc(MergeWording.State(AccountMergeState.Committed)), status);
Assert.Contains("Undo window ends", status);
Assert.Contains("can still be undone", status);
var (foldedResponse, _) = await w.Host.GetLinkedAccountsAsync(foldedSession);
AssertRefused(foldedResponse);
var everything = string.Join('\n', w.Surface) + status;
Assert.All(w.Bearers, bearer => Assert.DoesNotContain(bearer, everything));
Assert.DoesNotContain(w.Other.HeimdallId, everything);
}
[Fact]
public async Task Proving_without_naming_a_provider_starts_nothing_and_comes_back_with_the_error_code()
{
using var w = await World.Make();
var started = w.Host.Heimdall.StartBodies.Count;
var response = await w.Post("ProveOther");
Assert.Equal(Url + "?result=error", MergePage.Location(response));
Assert.Equal(started, w.Host.Heimdall.StartBodies.Count);
}
}

View file

@ -0,0 +1,128 @@
using System.Net;
using System.Text;
using Bifrost.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static Bifrost.Web.Tests.AccountMergeSagaTests;
namespace Bifrost.Web.Tests;
/// <summary>Who may pull merge notices: the chat host's own bearer and nobody else, whatever else the request carries (the test host signs every request in as an admin).</summary>
public sealed class MergeNoticeAuthTests
{
private const string Token = "chat-notices-test-token-0123456789";
private const string Get = "/api/merge-notices/chat";
private const string Ack = "/api/merge-notices/chat/ack";
private static HttpRequestMessage Request(HttpMethod method, string url, string? authorization, string? body = null)
{
var request = new HttpRequestMessage(method, url);
if (authorization is not null)
{
request.Headers.TryAddWithoutValidation("Authorization", authorization);
}
if (body is not null)
{
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
return request;
}
private static string AckBody(Guid id) => $"{{\"merge_id\":\"{id}\",\"type\":\"merged\"}}";
[Theory]
[InlineData(null)]
[InlineData("")]
public async Task With_no_token_configured_both_routes_are_404_even_for_a_bearer_that_looks_right(string? token)
{
using var rig = new Rig(chatNoticesToken: token);
using var client = rig.Host.NewClient();
foreach (var authorization in new[] { null, "Bearer " + Token, "Bearer " })
{
using var read = await client.SendAsync(Request(HttpMethod.Get, Get, authorization));
using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, authorization, AckBody(Guid.NewGuid())));
Assert.Equal(HttpStatusCode.NotFound, read.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, ack.StatusCode);
}
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Bearer")]
[InlineData("Bearer ")]
[InlineData("Bearer wrong-wrong-wrong-wrong-wrong-wrong")]
[InlineData("Bearer chat-notices-test-token-012345678")] // one short
[InlineData("Bearer chat-notices-test-token-01234567890")] // one long
[InlineData("Bearer c")]
[InlineData("chat-notices-test-token-0123456789")]
[InlineData("Basic chat-notices-test-token-0123456789")]
[InlineData("Bearer chat-notices-test-token-0123456789, Bearer other")]
public async Task A_missing_wrong_short_or_long_token_is_401_with_no_body_on_both_routes_and_acks_nothing(string? authorization)
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, _, _) = await Committed(rig);
using var client = rig.Host.NewClient();
using var read = await client.SendAsync(Request(HttpMethod.Get, Get, authorization));
using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, authorization, AckBody(merge)));
foreach (var response in new[] { read, ack })
{
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Empty(await response.Content.ReadAsStringAsync());
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
}
Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService<BifrostDbContext>().AccountMergeNoticeAcks.CountAsync()));
}
[Fact]
public async Task A_token_in_the_query_or_a_cookie_is_not_a_bearer()
{
using var rig = new Rig(chatNoticesToken: Token);
using var client = rig.Host.NewClient();
using var query = await client.SendAsync(Request(HttpMethod.Get, $"{Get}?token={Token}&access_token={Token}", null));
using var cookie = Request(HttpMethod.Get, Get, null);
cookie.Headers.Add("Cookie", $"token={Token}");
using var withCookie = await client.SendAsync(cookie);
Assert.Equal(HttpStatusCode.Unauthorized, query.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, withCookie.StatusCode);
}
[Fact]
public async Task The_right_token_reads_and_acks_with_no_store_and_no_cors_or_cookie()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, _, _) = await Committed(rig);
using var client = rig.Host.NewClient();
using var read = await client.SendAsync(Request(HttpMethod.Get, Get, "Bearer " + Token));
using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, "Bearer " + Token, AckBody(merge)));
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, ack.StatusCode);
foreach (var response in new[] { read, ack })
{
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(response.Headers.Contains("Set-Cookie"));
}
Assert.Contains("application/json", read.Content.Headers.ContentType?.ToString());
}
internal static async Task<(Guid Merge, Party Survivor, Party Folded)> Committed(Rig rig)
{
var survivor = await rig.AccountAsync("survivor", "twitch");
var folded = await rig.AccountAsync("folded", "discord");
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"));
Assert.Equal(Bifrost.Web.Domain.AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
return (id, survivor, folded);
}
}

View file

@ -0,0 +1,341 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static Bifrost.Web.Tests.AccountMergeSagaTests;
namespace Bifrost.Web.Tests;
/// <summary>
/// The merge notice feed from the chat host's side: pulled over HTTP with the bearer, acked over HTTP, against merges the saga rig made;
/// bounds and the unique ack key from the feed itself over seeded rows. Notices are derived from the merge row: no stored status.
/// </summary>
public sealed class MergeNoticeFeedTests(MergePostgres postgres) : IClassFixture<MergePostgres>
{
private const string Token = "chat-notices-test-token-0123456789";
private static readonly string[] PayloadKeys =
["type", "merge_id", "occurred_at", "grace_ends_at", "old_subject", "new_subject", "old_preferred_username", "new_preferred_username"];
private static async Task<JsonElement[]> PullAsync(Rig rig, string query = "")
{
using var client = rig.Host.NewClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/merge-notices/chat" + query);
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.EnumerateArray().Select(x => x.Clone()).ToArray();
}
private static async Task<HttpStatusCode> AckAsync(Rig rig, Guid merge, string type)
{
using var client = rig.Host.NewClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/merge-notices/chat/ack")
{
Content = new StringContent($"{{\"merge_id\":\"{merge}\",\"type\":\"{type}\"}}", Encoding.UTF8, "application/json")
};
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
using var response = await client.SendAsync(request);
return response.StatusCode;
}
private static string[] Types(JsonElement[] notices) => [.. notices.Select(x => x.GetProperty("type").GetString()!)];
private static Task<(Guid Merge, Party Survivor, Party Folded)> CommittedAsync(Rig rig) => MergeNoticeAuthTests.Committed(rig);
private static async Task FinalizeAsync(Rig rig)
{
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1));
await rig.InScopeAsync(async sp => await sp.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(rig.Clock.Now, default));
}
// ---- what a notice says ----
[Fact]
public async Task A_committed_merge_yields_one_merged_notice_with_exactly_the_documented_keys_and_nothing_that_names_a_person()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, survivor, folded) = await CommittedAsync(rig);
var committed = await rig.MergeAsync(merge);
var notice = Assert.Single(await PullAsync(rig));
Assert.Equal(PayloadKeys.Order(), notice.EnumerateObject().Select(x => x.Name).Order());
Assert.Equal("merged", notice.GetProperty("type").GetString());
Assert.Equal(merge.ToString("D"), notice.GetProperty("merge_id").GetString());
Assert.Equal(committed.CommittedUtc, notice.GetProperty("occurred_at").GetDateTimeOffset());
Assert.Equal(committed.GraceEndsUtc, notice.GetProperty("grace_ends_at").GetDateTimeOffset());
Assert.Equal(folded.Id.ToString("D"), notice.GetProperty("old_subject").GetString());
Assert.Equal(survivor.Id.ToString("D"), notice.GetProperty("new_subject").GetString());
Assert.Equal("folded", notice.GetProperty("old_preferred_username").GetString());
Assert.Equal("survivor", notice.GetProperty("new_preferred_username").GetString());
Assert.DoesNotContain(folded.HeimdallId, notice.GetRawText());
Assert.DoesNotContain(survivor.HeimdallId, notice.GetRawText());
}
[Fact]
public async Task An_undone_merge_yields_merged_then_undone_and_each_is_acked_alone()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, survivor, folded) = await CommittedAsync(rig);
rig.Clock.Advance(TimeSpan.FromHours(1));
Assert.IsType<UndoOutcome.Requested>(await rig.InScopeAsync(sp =>
sp.GetRequiredService<AccountMergeSaga>().RequestUndoAsync(merge, survivor.Id, [rig.Moved(survivor, folded, "discord")], default)));
Assert.Equal(AccountMergeState.Undone, (await rig.AdvanceAsync(merge)).State);
var stored = await rig.MergeAsync(merge);
var notices = await PullAsync(rig);
Assert.Equal(["merged", "undone"], Types(notices));
Assert.Equal(stored.UndoneUtc, notices[1].GetProperty("occurred_at").GetDateTimeOffset());
Assert.Equal(stored.GraceEndsUtc, notices[1].GetProperty("grace_ends_at").GetDateTimeOffset());
Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged"));
Assert.Equal(["undone"], Types(await PullAsync(rig)));
}
[Fact]
public async Task A_finalized_merge_yields_merged_then_final()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, _, _) = await CommittedAsync(rig);
await FinalizeAsync(rig);
var stored = await rig.MergeAsync(merge);
Assert.Equal(AccountMergeState.Final, stored.State);
var notices = await PullAsync(rig);
Assert.Equal(["merged", "final"], Types(notices));
Assert.Equal(stored.FinalizedUtc, notices[1].GetProperty("occurred_at").GetDateTimeOffset());
}
[Fact]
public async Task A_confirmed_a_frozen_and_a_failed_merge_yield_nothing()
{
using var rig = new Rig(chatNoticesToken: Token);
var survivor = await rig.AccountAsync("survivor", "twitch");
var frozen = await rig.AccountAsync("frozen", "discord");
var stale = await rig.AccountAsync("stale", "patreon");
var confirmed = await rig.ConfirmedAsync(survivor, frozen, rig.Weak(survivor, "twitch"), rig.Weak(frozen, "discord"));
Assert.Equal(AccountMergeState.Confirmed, (await rig.MergeAsync(confirmed)).State);
Assert.IsType<MergeStanding.Frozen>(await rig.StandingAsync(frozen));
Assert.Empty(await PullAsync(rig));
var other = await rig.AccountAsync("other", "twitch2");
var failing = await rig.ConfirmedAsync(other, stale, rig.Weak(other, "twitch2"), rig.Weak(stale, "patreon"));
rig.Clock.Advance(TimeSpan.FromSeconds(400));
Assert.Equal(AccountMergeState.Failed, (await rig.AdvanceAsync(failing)).State);
// The first merge is still Confirmed and the second Failed: neither committed, neither is told.
Assert.Empty(await PullAsync(rig));
}
// ---- ack ----
[Fact]
public async Task An_ack_is_idempotent_and_the_notice_is_gone_from_the_next_read()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, _, _) = await CommittedAsync(rig);
Assert.Single(await PullAsync(rig));
Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged"));
Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged"));
Assert.Empty(await PullAsync(rig));
Assert.Equal(1, await rig.InScopeAsync(async sp => await sp.GetRequiredService<BifrostDbContext>().AccountMergeNoticeAcks.CountAsync()));
}
[Fact]
public async Task An_ack_of_a_notice_the_feed_would_not_emit_is_refused_and_stores_nothing()
{
using var rig = new Rig(chatNoticesToken: Token);
var (merge, _, _) = await CommittedAsync(rig); // merged only: not undone, not final
Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, merge, "undone"));
Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, merge, "final"));
Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, Guid.NewGuid(), "merged"));
Assert.Equal(HttpStatusCode.BadRequest, await AckAsync(rig, merge, "created"));
Assert.Equal(HttpStatusCode.BadRequest, await AckAsync(rig, merge, "Merged"));
Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService<BifrostDbContext>().AccountMergeNoticeAcks.CountAsync()));
Assert.Single(await PullAsync(rig));
}
[Fact]
public async Task A_malformed_ack_body_is_400_and_stores_nothing()
{
using var rig = new Rig(chatNoticesToken: Token);
await CommittedAsync(rig);
using var client = rig.Host.NewClient();
foreach (var body in new[] { "", "not json", "{}", "{\"merge_id\":\"nope\",\"type\":\"merged\"}", "{\"type\":\"merged\"}", "[]" })
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/merge-notices/chat/ack") { Content = new StringContent(body, Encoding.UTF8, "application/json") };
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService<BifrostDbContext>().AccountMergeNoticeAcks.CountAsync()));
}
// ---- order, bounds, after: the feed over seeded rows ----
private static readonly DateTimeOffset T0 = AccountMergeLedgerTests.T0;
private static async Task<(AccountMergeLedgerTests.World World, UserAccount Survivor, UserAccount Folded)> SeededAsync(Func<Guid, Guid, IEnumerable<AccountMerge>> merges, string? postgres = null)
{
var world = postgres is null ? new AccountMergeLedgerTests.World() : AccountMergeLedgerTests.World.OnPostgres(postgres);
await using (var migrate = world.Db())
{
if (postgres is not null)
{
await migrate.Database.MigrateAsync();
}
}
var survivor = await world.AccountAsync("survivor");
var folded = await world.AccountAsync("folded");
await using var db = world.Db();
db.AccountMerges.AddRange(merges(survivor.Id, folded.Id));
await db.SaveChangesAsync();
return (world, survivor, folded);
}
private static AccountMerge Committed(Guid survivor, Guid folded, DateTimeOffset at, Guid? id = null) => new()
{
Id = id ?? Guid.NewGuid(), State = AccountMergeState.Committed, SurvivorUserAccountId = survivor, FoldedUserAccountId = folded,
CreatedUtc = at, CommittedUtc = at, GraceEndsUtc = at + AccountMergeLedger.Grace
};
private static MergeNoticeFeed Feed(BifrostDbContext db) => new(db, TimeProvider.System);
[Fact]
public async Task Notices_come_ordered_by_occurred_at_then_merge_id_then_kind_and_after_keeps_only_later_ones()
{
var low = new Guid("00000000-0000-0000-0000-000000000001");
var high = new Guid("00000000-0000-0000-0000-000000000002");
var (world, _, _) = await SeededAsync((s, f) =>
[
Committed(s, f, T0.AddHours(3), high),
Committed(s, f, T0.AddHours(3), low),
Committed(s, f, T0.AddHours(1), Guid.NewGuid())
]);
await using var db = world.Db();
var all = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default);
var later = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, T0.AddHours(1), null, default);
Assert.Equal([T0.AddHours(1), T0.AddHours(3), T0.AddHours(3)], all.Select(x => x.OccurredAt));
Assert.Equal([low, high], all.Skip(1).Select(x => x.MergeId));
Assert.Equal([low, high], later.Select(x => x.MergeId));
}
[Fact]
public async Task A_merge_that_is_undone_and_one_that_is_final_order_their_two_notices_by_their_own_times()
{
var (world, _, _) = await SeededAsync((s, f) =>
{
var undone = Committed(s, f, T0);
undone.State = AccountMergeState.Undone;
undone.UndoneUtc = T0.AddHours(5);
var final = Committed(s, f, T0.AddHours(1));
final.State = AccountMergeState.Final;
final.FinalizedUtc = T0.AddHours(2);
return [undone, final];
});
await using var db = world.Db();
var kinds = (await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default)).Select(x => x.Kind);
Assert.Equal([AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Final, AccountMergeNoticeKind.Undone], kinds);
}
[Theory]
[InlineData(null, 50)]
[InlineData(0, 50)]
[InlineData(-5, 50)]
[InlineData(3, 3)]
[InlineData(200, 200)]
[InlineData(100000, 200)]
public async Task The_limit_is_bounded_default_fifty_maximum_two_hundred(int? asked, int expected)
{
var (world, _, _) = await SeededAsync((s, f) => Enumerable.Range(0, 230).Select(i => Committed(s, f, T0.AddMinutes(i))));
await using var db = world.Db();
var read = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, asked, default);
Assert.Equal(expected, read.Count);
Assert.Equal(T0.AddMinutes(expected - 1), read[^1].OccurredAt); // the oldest first, cut at the limit
}
[Fact]
public async Task The_feed_writes_no_merge_row_only_acks()
{
var (world, _, _) = await SeededAsync((s, f) => [Committed(s, f, T0)]);
await using var db = world.Db();
var before = await db.AccountMerges.AsNoTracking().SingleAsync();
var feed = Feed(db);
await feed.ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default);
Assert.True(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Merged, default));
Assert.True(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Merged, default));
Assert.False(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Final, default));
await using var fresh = world.Db();
var after = await fresh.AccountMerges.AsNoTracking().SingleAsync();
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before), System.Text.Json.JsonSerializer.Serialize(after));
Assert.Equal(1, await fresh.AccountMergeNoticeAcks.CountAsync());
}
// ---- the unique key, on a real database ----
[PostgresFact]
public async Task Two_concurrent_acks_of_one_notice_both_succeed_and_leave_one_row()
{
var name = $"bifrost_notices_{Guid.NewGuid():N}";
var admin = Environment.GetEnvironmentVariable(PostgresGate.Variable)!;
await using (var connection = new Npgsql.NpgsqlConnection(admin))
{
await connection.OpenAsync();
await using var create = new Npgsql.NpgsqlCommand($"CREATE DATABASE \"{name}\"", connection);
await create.ExecuteNonQueryAsync();
}
try
{
var (world, _, _) = await SeededAsync((s, f) => [Committed(s, f, T0)], new Npgsql.NpgsqlConnectionStringBuilder(admin) { Database = name }.ConnectionString);
Guid id;
await using (var read = world.Db())
{
id = (await read.AccountMerges.SingleAsync()).Id;
}
for (var round = 0; round < 5; round++) // a fresh race each round
{
const AccountMergeNoticeKind kind = AccountMergeNoticeKind.Merged;
var results = await Task.WhenAll(Enumerable.Range(0, 6).Select(async _ =>
{
await using var db = world.Db();
return await Feed(db).AckAsync(AccountMergeNoticeTarget.Chat, id, kind, default);
}));
Assert.All(results, Assert.True);
}
await using var check = world.Db();
Assert.Equal(1, await check.AccountMergeNoticeAcks.CountAsync());
Assert.Empty(await Feed(check).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default));
}
finally
{
Npgsql.NpgsqlConnection.ClearAllPools();
await using var connection = new Npgsql.NpgsqlConnection(admin);
await connection.OpenAsync();
await using var drop = new Npgsql.NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", connection);
await drop.ExecuteNonQueryAsync();
}
}
}

View file

@ -0,0 +1,95 @@
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
namespace Bifrost.Web.Tests;
/// <summary>The wording of a merge plan: closed vocabulary, counts from the plan's entries, the statements a member must always see.</summary>
public sealed class MergePreviewTests
{
private static readonly string[] BlockCodes =
[
"same_account", "account_missing", "mind_account", "native_account", "merge_in_flight",
"survivor_is_folded", "already_folded", "folded_is_survivor", "sealed_ballot_pending"
];
private static MergePlanEntry Entry(AccountMergeEntryKind kind, AccountMergeEntryAction action) => new(kind, action, Guid.NewGuid());
private static MergePlan Plan(IEnumerable<MergePlanEntry> entries, IReadOnlyDictionary<string, int>? left = null) =>
new([], [.. entries], left ?? new Dictionary<string, int>());
[Fact]
public void Every_block_code_has_its_own_words_and_none_names_an_account()
{
var words = BlockCodes.Select(MergePreview.Block).ToList();
Assert.All(words, x => Assert.NotEmpty(x));
Assert.Equal(BlockCodes.Length, words.Distinct().Count());
Assert.All(words, x => Assert.DoesNotContain("@", x));
Assert.NotEmpty(MergePreview.Block("a code nobody wrote"));
Assert.DoesNotContain("a code nobody wrote", MergePreview.Block("a code nobody wrote"));
}
[Fact]
public void The_counts_come_from_the_plans_entries_by_kind_and_action()
{
var plan = Plan(
[
.. Enumerable.Range(0, 3).Select(_ => Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided)),
Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved),
.. Enumerable.Range(0, 2).Select(_ => Entry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided)),
Entry(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed),
Entry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended),
.. Enumerable.Range(0, 4).Select(_ => Entry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved))
],
new Dictionary<string, int> { ["ConsentReceipts"] = 5, ["PasskeyCredentials"] = 1, ["LedgerEntries"] = 0 });
var text = string.Join('\n', MergePreview.Lines(plan));
Assert.Contains("3 open votes", text);
Assert.Contains("1 open vote ", text);
Assert.Contains("2 appraisals", text);
Assert.Contains("membership", text);
Assert.Contains("suspended", text);
Assert.Contains("4 patron events", text);
Assert.Contains("5 consent records", text);
Assert.Contains("1 passkey ", text);
Assert.DoesNotContain("0 ledger", text);
Assert.Contains("2 open votes", string.Join('\n', MergePreview.Lines(Plan(
Enumerable.Range(0, 2).Select(_ => Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided))))));
}
[Theory]
[InlineData("chat id")]
[InlineData("consent again")]
[InlineData("StreamPixels")]
[InlineData("Roles and admin")]
[InlineData("one vote per motion")]
[InlineData("7 days")]
[InlineData("ordinary sign-ins")]
[InlineData("Merge status")]
public void The_statements_always_carry_what_a_merge_cannot_carry_over(string statement)
{
Assert.Contains(MergePreview.Statements, x => x.Contains(statement, StringComparison.Ordinal));
}
[Fact]
public void The_statements_promise_no_passkey_because_no_page_offers_one()
{
Assert.DoesNotContain(MergePreview.Statements, x => x.Contains("passkey", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("blocked")]
[InlineData("preview_stale")]
[InlineData("not_party")]
[InlineData("proof_account_mismatch")]
[InlineData("proofs_needed")]
[InlineData("consent_needed")]
[InlineData("bad_choice")]
public void Every_refusal_the_page_can_answer_has_closed_words(string code)
{
Assert.NotEmpty(MergeWording.Confirm(code));
Assert.Empty(MergeWording.Confirm("not a code"));
}
}

View file

@ -0,0 +1,230 @@
using System.Net;
using System.Text.Json;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
/// <summary>
/// The two merge-proof sign-ins from the browser's side, against a fake Heimdall: a fresh sign-in as the account signed in here (Self)
/// or as a second account (Other), each held in <see cref="MergeProofs"/> and nowhere else. No page starts them yet, so the tests start
/// them through the service and finish them as the browser does, by posting the completion code with the attempt cookie.
/// </summary>
public sealed class MergeProofCompletionTests
{
private const string Page = "/Membership/MergeStandIn";
private const string Callback = "https://localhost";
private sealed record Member(string Account, string Session, Guid UserAccountId);
private static async Task<Member> SignedIn(HeimdallHost host)
{
var account = HeimdallShapes.NewAccountId();
var session = await host.SignInSessionAsync(account);
return new Member(account, session, (await host.Account(account))!.Id);
}
private static async Task<StartedAttempt> Begin(HeimdallHost host, Member session, MergeSide side, string via = "discord")
{
var auth = host.Services.GetRequiredService<HeimdallAuthService>();
var started = side == MergeSide.Self
? await auth.StartMergeProveSelfAsync(session.Account, via, Page, Callback, [], CancellationToken.None)
: await auth.StartMergeProveOtherAsync(session.Account, via, Page, Callback, [], CancellationToken.None);
var returnTo = JsonDocument.Parse(host.Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!;
var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString();
return new StartedAttempt(nonce, started.CookieName, started.CookieValue, returnTo, new HttpResponseMessage());
}
private static async Task<HttpResponseMessage> Finish(
HeimdallHost host, Member session, StartedAttempt attempt, string provedAccount, string token, string provider = "discord", string? sessionOverride = null)
{
var code = host.CodeFor(attempt, provedAccount, token, provider: provider);
return await host.PostCompleteAsync(
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = code },
$"{BifrostCookies.Session}={sessionOverride ?? session.Session}; {attempt.CookieName}={attempt.Cookie}");
}
private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString;
private static MergeProofs Proofs(HeimdallHost host) => host.Services.GetRequiredService<MergeProofs>();
private static void SetList(HeimdallHost host, params string[] providers)
{
host.Heimdall.Identities.Clear();
foreach (var provider in providers)
{
host.Heimdall.AddIdentity(provider, "Name of " + provider);
}
}
[Theory]
[InlineData(MergeSide.Self)]
[InlineData(MergeSide.Other)]
public async Task A_completion_through_another_provider_than_the_attempts_records_no_proof(MergeSide side)
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var other = await SignedIn(host);
SetList(host, "discord", "patreon");
var proved = side == MergeSide.Self ? me : other;
var attempt = await Begin(host, me, side, via: "discord");
var done = await Finish(host, me, attempt, proved.Account, host.Token(proved.Account), provider: "patreon");
Assert.NotEqual(Page + "?result=merge_proof_recorded", Location(done));
var held = Proofs(host).Snapshot(me.UserAccountId);
Assert.Null(held.Self);
Assert.Null(held.Other);
}
[Fact]
public async Task A_self_proof_for_the_signed_in_account_is_held_and_the_browser_goes_back_with_a_closed_code()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
SetList(host, "discord", "patreon");
var bearer = host.Token(me.Account);
var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, bearer);
Assert.Equal(Page + "?result=merge_proof_recorded", Location(done));
var view = Assert.IsType<MergeSideView>(Proofs(host).Snapshot(me.UserAccountId).Self);
Assert.Equal(me.UserAccountId, view.UserAccountId);
Assert.Equal(["discord"], view.ProvedProviders);
Assert.Equal(["discord", "patreon"], view.LoginProviders.Order().ToArray());
Assert.Equal($"Bearer {bearer}", Assert.Single(host.Heimdall.ListAuthorizations));
var surface = Location(done) + string.Join('\n', done.Headers.Select(x => x.Key + string.Concat(x.Value)));
Assert.DoesNotContain(bearer, surface);
Assert.DoesNotContain(me.Account, surface);
Assert.DoesNotContain(bearer, string.Join('\n', host.Logs.Lines));
}
[Fact]
public async Task A_self_proof_of_a_different_account_is_refused_and_stores_nothing()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var someoneElse = HeimdallShapes.NewAccountId();
SetList(host, "discord");
var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), someoneElse, host.Token(someoneElse));
Assert.Equal(Page + "?result=error", Location(done));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
Assert.Empty(host.Heimdall.ListAuthorizations);
}
[Fact]
public async Task An_other_proof_of_the_sessions_own_account_is_MergeSameAccount_and_stores_nothing()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
SetList(host, "discord");
var done = await Finish(host, me, await Begin(host, me, MergeSide.Other), me.Account, host.Token(me.Account));
Assert.Equal(Page + "?result=merge_same_account", Location(done));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
Assert.Empty(host.Heimdall.ListAuthorizations);
}
[Fact]
public async Task An_other_proof_for_a_Heimdall_account_without_a_member_account_is_MergeNoAccount_and_the_bearer_is_dropped()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var stranger = HeimdallShapes.NewAccountId();
SetList(host, "patreon");
var done = await Finish(host, me, await Begin(host, me, MergeSide.Other, "patreon"), stranger, host.Token(stranger), "patreon");
Assert.Equal(Page + "?result=merge_no_account", Location(done));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
Assert.Empty(host.Heimdall.ListAuthorizations);
Assert.Null(await host.Account(stranger));
}
[Fact]
public async Task Both_sides_proved_give_the_actor_the_bearers_that_were_redeemed()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var them = await SignedIn(host);
SetList(host, "discord");
var mine = host.Token(me.Account);
await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, mine);
SetList(host, "patreon");
var theirs = host.Token(them.Account);
var done = await Finish(host, me, await Begin(host, me, MergeSide.Other, "patreon"), them.Account, theirs, "patreon");
Assert.Equal(Page + "?result=merge_proof_recorded", Location(done));
var ready = Assert.IsType<PeekResult.Ready>(Proofs(host).Peek(me.UserAccountId, MergeSide.Self));
Assert.Equal((mine, me.Account, "discord"), (ready.Survivor.AccessToken, ready.Survivor.HeimdallAccountId, ready.Survivor.Provider));
var folded = Assert.Single(ready.Folded);
Assert.Equal((theirs, them.Account, "patreon"), (folded.AccessToken, folded.HeimdallAccountId, folded.Provider));
var view = Assert.IsType<MergeSideView>(Proofs(host).Snapshot(me.UserAccountId).Other);
Assert.Equal(them.UserAccountId, view.UserAccountId);
}
[Fact]
public async Task A_list_Heimdall_does_not_give_stores_nothing()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
SetList(host, "discord");
host.Heimdall.ListStatus = HttpStatusCode.InternalServerError;
var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account));
Assert.Equal(Page + "?result=error", Location(done));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
}
[Fact]
public async Task A_side_holding_two_logins_of_one_provider_is_MergeLoginsAmbiguous_and_stores_nothing()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
SetList(host, "discord", "discord");
var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account));
Assert.Equal(Page + "?result=merge_logins_ambiguous", Location(done));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
}
[Fact]
public async Task A_completion_in_another_browsers_session_holds_nothing()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var them = await SignedIn(host);
SetList(host, "discord");
var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account), sessionOverride: them.Session);
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId));
Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(them.UserAccountId));
Assert.Empty(host.Heimdall.ListAuthorizations);
Assert.Equal(Page + "?result=error", Location(done));
}
[Fact]
public async Task A_merge_proof_starts_only_through_a_provider_the_member_names()
{
using var host = new HeimdallHost(realSessions: true);
var me = await SignedIn(host);
var auth = host.Services.GetRequiredService<HeimdallAuthService>();
var before = host.Heimdall.StartBodies.Count;
await Assert.ThrowsAsync<HeimdallAuthException>(() => auth.StartMergeProveSelfAsync(me.Account, null, Page, Callback, [], CancellationToken.None));
await Assert.ThrowsAsync<HeimdallAuthException>(() => auth.StartMergeProveOtherAsync(me.Account, "github", Page, Callback, [], CancellationToken.None));
Assert.Equal(before, host.Heimdall.StartBodies.Count);
}
}

View file

@ -0,0 +1,229 @@
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
/// <summary>The in-memory holder of merge proofs: per actor, expiring, never consuming, bearer-free in every view.</summary>
public sealed class MergeProofsTests
{
private const string Secret = "BEARER-CANARY";
private readonly TestClock _clock = new();
private readonly MergeProofs _proofs;
private readonly Guid _actor = Guid.NewGuid();
private readonly Guid _self = Guid.NewGuid();
private readonly Guid _other = Guid.NewGuid();
private readonly string _selfAccount = HeimdallShapes.NewAccountId();
private readonly string _otherAccount = HeimdallShapes.NewAccountId();
public MergeProofsTests() => _proofs = new MergeProofs(_clock);
private static LinkedIdentitySummary Login(string provider) => new(Guid.NewGuid().ToString(), provider, "Name of " + provider, null);
private MergeProof Proof(string account, string provider, string? token = null) => new(token ?? Secret + provider, account, provider);
private PutOutcome PutSelf(string provider, params LinkedIdentitySummary[] logins) =>
_proofs.Put(_actor, MergeSide.Self, Proof(_selfAccount, provider), _self, logins);
private PutOutcome PutOther(string provider, params LinkedIdentitySummary[] logins) =>
_proofs.Put(_actor, MergeSide.Other, Proof(_otherAccount, provider), _other, logins);
[Fact]
public void Peek_gives_one_actor_their_own_proofs_and_never_consumes()
{
var selfLogin = Login("discord");
var otherLogin = Login("patreon");
Assert.Equal(PutOutcome.Stored, PutSelf("discord", selfLogin));
Assert.Equal(PutOutcome.Stored, PutOther("patreon", otherLogin));
var first = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
var second = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal("discord", first.Survivor.Provider);
Assert.Equal("patreon", Assert.Single(first.Folded).Provider);
Assert.Equal(first.Survivor, second.Survivor);
Assert.Equal(first.Folded, second.Folded);
Assert.IsType<PeekResult.Expired>(_proofs.Peek(Guid.NewGuid(), MergeSide.Self));
Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Self);
Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Other);
}
[Fact]
public void Peek_runs_both_ways_the_survivor_is_chosen_by_the_caller()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var ready = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Other));
Assert.Equal(_otherAccount, ready.Survivor.HeimdallAccountId);
Assert.Equal(_selfAccount, Assert.Single(ready.Folded).HeimdallAccountId);
}
[Fact]
public void Clear_removes_both_sides_and_only_that_actors()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var stranger = Guid.NewGuid();
_proofs.Put(stranger, MergeSide.Self, Proof(_selfAccount, "discord"), _self, [Login("discord")]);
_proofs.Clear(_actor);
Assert.IsType<PeekResult.Expired>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(new MergeProofsView(null, null), _proofs.Snapshot(_actor));
Assert.NotNull(_proofs.Snapshot(stranger).Self);
}
[Theory]
[InlineData(269, true)]
[InlineData(270, false)]
[InlineData(271, false)]
public void A_proof_lives_270_seconds_from_its_redeem(int seconds, bool alive)
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
_clock.Advance(TimeSpan.FromSeconds(seconds));
Assert.Equal(alive, _proofs.Peek(_actor, MergeSide.Self) is PeekResult.Ready);
Assert.Equal(alive, _proofs.Snapshot(_actor).Self is not null);
}
[Fact]
public void One_lapsed_proof_costs_only_that_provider_and_a_newer_proof_replaces_the_older()
{
var discord = Login("discord");
var patreon = Login("patreon");
PutSelf("twitch", Login("twitch"));
PutOther("discord", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(200));
PutOther("patreon", discord, patreon);
_clock.Advance(TimeSpan.FromSeconds(100)); // the discord proof is 300 s old, the patreon one 100 s
var self = PutSelf("twitch", Login("twitch")); // keeps the self side alive
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(PutOutcome.Stored, self);
Assert.Equal(["discord"], unproved.Providers);
PutOther("discord", discord, patreon);
var ready = Assert.IsType<PeekResult.Ready>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(["discord", "patreon"], ready.Folded.Select(x => x.Provider).Order().ToArray());
var replacedAt = Assert.IsType<MergeSideView>(_proofs.Snapshot(_actor).Other);
Assert.Equal(["discord", "patreon"], replacedAt.ProvedProviders.Order().ToArray());
}
[Fact]
public void A_login_list_with_two_logins_of_one_provider_is_refused_and_stores_nothing()
{
var outcome = PutOther("discord", Login("discord"), Login("discord"));
Assert.Equal(PutOutcome.AmbiguousLogins, outcome);
Assert.Null(_proofs.Snapshot(_actor).Other);
}
[Fact]
public void A_folded_side_with_an_unproved_provider_names_providers_and_nothing_else()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"), Login("twitch"), Login("google"));
var unproved = Assert.IsType<PeekResult.LoginsUnproved>(_proofs.Peek(_actor, MergeSide.Self));
Assert.Equal(["google", "twitch"], unproved.Providers.Order().ToArray());
Assert.DoesNotContain(Secret, unproved.ToString());
}
[Fact]
public void A_proof_for_another_account_on_a_side_replaces_the_side_and_the_old_proofs_are_not_mixed_in()
{
PutOther("patreon", Login("patreon"), Login("twitch"));
var someoneElse = Guid.NewGuid();
var elsewhere = HeimdallShapes.NewAccountId();
_proofs.Put(_actor, MergeSide.Other, Proof(elsewhere, "twitch"), someoneElse, [Login("twitch")]);
var view = Assert.IsType<MergeSideView>(_proofs.Snapshot(_actor).Other);
Assert.Equal(someoneElse, view.UserAccountId);
Assert.Equal(["twitch"], view.ProvedProviders);
}
[Fact]
public void A_side_holds_at_most_eight_proofs()
{
var providers = Enumerable.Range(0, MergeProofs.MaxProofsPerSide + 1).Select(x => "p" + x).ToArray();
var logins = providers.Select(Login).ToArray();
var outcomes = providers.Select(p => PutOther(p, logins)).ToArray();
Assert.All(outcomes.Take(MergeProofs.MaxProofsPerSide), x => Assert.Equal(PutOutcome.Stored, x));
Assert.Equal(PutOutcome.TooManyProofs, outcomes[^1]);
Assert.Equal(MergeProofs.MaxProofsPerSide, _proofs.Snapshot(_actor).Other!.ProvedProviders.Count);
}
[Fact]
public void Undo_needs_every_moved_login_proved_and_returns_only_the_selfs_proofs()
{
var discord = Login("discord");
var patreon = Login("patreon");
var twitch = Login("twitch");
PutSelf("discord", discord, patreon, twitch);
PutOther("twitch", Login("twitch"));
var missing = Assert.IsType<UndoPeekResult.LoginsUnproved>(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id]));
PutSelf("patreon", discord, patreon, twitch);
var ready = Assert.IsType<UndoPeekResult.Ready>(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id]));
Assert.Equal(["patreon"], missing.Providers);
Assert.Equal(["discord", "patreon"], ready.Proofs.Select(x => x.Provider).Order().ToArray());
Assert.All(ready.Proofs, x => Assert.Equal(_selfAccount, x.HeimdallAccountId));
Assert.IsType<UndoPeekResult.LoginsUnproved>(_proofs.PeekUndo(_actor, [discord.Id, Guid.NewGuid().ToString()]));
Assert.IsType<UndoPeekResult.Expired>(_proofs.PeekUndo(Guid.NewGuid(), [discord.Id]));
}
[Fact]
public void No_view_or_printed_result_holds_a_bearer()
{
PutSelf("discord", Login("discord"));
PutOther("patreon", Login("patreon"));
var ready = _proofs.Peek(_actor, MergeSide.Self);
var printed = string.Join('\n', _proofs.Snapshot(_actor), ready, ready is PeekResult.Ready r ? r.Survivor : null, _proofs.PeekUndo(_actor, ["x"]));
Assert.DoesNotContain(Secret, printed);
Assert.DoesNotContain(_selfAccount, printed);
Assert.DoesNotContain(_otherAccount, printed);
}
/// <summary>Actors with anything in memory: pruning drops a side with no proof and an actor with no side.</summary>
private int HeldProofs() =>
((System.Collections.IDictionary)typeof(MergeProofs).GetField("_held", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(_proofs)!)
.Count;
[Fact]
public void An_expired_bearer_is_dropped_by_any_later_access_even_one_for_another_actor()
{
PutSelf("discord", Login("discord"));
Assert.Equal(1, HeldProofs());
_clock.Advance(MergeProofs.ProofLifetime + TimeSpan.FromSeconds(1));
_proofs.Clear(Guid.NewGuid());
Assert.Equal(0, HeldProofs());
}
[Fact]
public void Two_sides_holding_a_login_of_one_provider_are_a_conflict_named_by_provider_only_in_both_directions()
{
PutSelf("discord", Login("discord"), Login("twitch"));
PutOther("patreon", Login("patreon"), Login("discord"), Login("twitch"));
foreach (var survivor in new[] { MergeSide.Self, MergeSide.Other })
{
var conflict = Assert.IsType<PeekResult.ProviderConflict>(_proofs.Peek(_actor, survivor));
Assert.Equal(["discord", "twitch"], conflict.Providers);
}
}
}

View file

@ -0,0 +1,222 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static Bifrost.Web.Tests.AccountMergeSagaTests;
namespace Bifrost.Web.Tests;
/// <summary>
/// What the merge pages show each member, from browser sessions of the real host over the saga rig: the notice in the layout and the
/// page itself, for the survivor inside the grace, for a frozen or folded account, and for everyone else (nothing).
/// </summary>
public sealed class MergeStatusTests
{
private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text);
private static int Notices(string html) => Regex.Matches(html, "class=\"merge-notice\"").Count;
private sealed record World(Rig Rig, Party Survivor, Party Folded, Party Stranger, string SurvivorSession, string FoldedSession, string StrangerSession, Guid MergeId);
private static async Task<World> Open(string[]? foldedLogins = null)
{
var rig = new Rig(realSessions: true);
var survivor = await rig.AccountAsync("quill-9f3a", "twitch");
var folded = await rig.AccountAsync("banks-2d7c", foldedLogins ?? ["discord"]);
var stranger = await rig.AccountAsync("stranger-51e0", "twitch");
// Sessions exist before the merge, as a member's browser does.
var sessions = new[] { survivor, folded, stranger };
var cookies = new List<string>();
foreach (var who in sessions)
{
cookies.Add(await rig.Host.SignInSessionAsync(who.HeimdallId));
}
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), [.. (foldedLogins ?? ["discord"]).Select(x => rig.Weak(folded, x))]);
return new World(rig, survivor, folded, stranger, cookies[0], cookies[1], cookies[2], id);
}
private static async Task<World> Committed(string[]? foldedLogins = null)
{
var world = await Open(foldedLogins);
Assert.Equal(AccountMergeState.Committed, (await world.Rig.AdvanceAsync(world.MergeId)).State);
return world;
}
private static async Task<string> Html(World world, string session, string url = MergePage.Url)
{
using var client = world.Rig.Host.NewClient();
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Cookie", $"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
return await response.Content.ReadAsStringAsync();
}
private static async Task Edit(World world, Action<AccountMerge> change)
{
using var scope = world.Rig.Host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var merge = await db.AccountMerges.SingleAsync(x => x.Id == world.MergeId);
change(merge);
await db.SaveChangesAsync();
}
[Fact]
public async Task The_survivor_sees_days_left_and_the_weak_note_on_the_page_and_one_notice_in_the_layout()
{
var w = await Committed();
using var rig = w.Rig;
rig.Clock.Advance(TimeSpan.FromDays(2));
var merge = await rig.MergeAsync(w.MergeId);
var page = await Html(w, w.SurvivorSession);
Assert.Contains(Enc(MergeWording.DaysLeft(5)), page);
Assert.Contains(Enc(MergeWording.TierNote("weak", merge.GraceEndsUtc!.Value)), page);
Assert.DoesNotContain(Enc(MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value)), page);
Assert.Contains(Enc(MergeWording.State(AccountMergeState.Committed)), page);
Assert.Equal(1, Notices(page));
Assert.Contains(Enc(MergeWording.SurvivorNotice(5)), page);
Assert.Equal(1, Notices(await Html(w, w.SurvivorSession, "/Membership/Status")));
}
[Fact]
public async Task Past_the_grace_and_before_the_sweeper_there_is_no_notice_and_no_undo_offer()
{
var w = await Committed();
using var rig = w.Rig;
var inside = await Html(w, w.SurvivorSession);
Assert.Equal(1, Notices(inside));
Assert.Contains("To undo, sign in again", inside);
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromHours(1));
Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(w.MergeId)).State);
var late = await Html(w, w.SurvivorSession);
Assert.Equal(0, Notices(late));
Assert.DoesNotContain("To undo, sign in again", late);
Assert.DoesNotContain("Undo this merge", late);
Assert.DoesNotContain("ProveForUndo", late);
Assert.DoesNotContain("can still be undone", late);
}
[Fact]
public async Task Once_the_undo_is_requested_no_page_says_the_merge_can_still_be_undone()
{
var w = await Committed();
using var rig = w.Rig;
await Edit(w, x => x.State = AccountMergeState.UndoRequested);
var page = await Html(w, w.SurvivorSession);
Assert.Equal(1, Notices(page));
Assert.DoesNotContain("can still be undone", page);
Assert.DoesNotContain("can still undo", page);
Assert.Contains("Undo requested", page);
Assert.DoesNotContain("To undo, sign in again", page);
}
[Fact]
public async Task A_strong_proof_merge_says_so_in_its_own_words()
{
var w = await Committed();
using var rig = w.Rig;
await Edit(w, x => x.ProofFromTier = "strong");
var merge = await rig.MergeAsync(w.MergeId);
var page = await Html(w, w.SurvivorSession);
Assert.NotEqual(MergeWording.TierNote("weak", merge.GraceEndsUtc!.Value), MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value));
Assert.Contains(Enc(MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value)), page);
}
[Fact]
public async Task No_notice_for_a_merge_that_is_Final_Undone_or_Failed_and_none_for_another_member()
{
var w = await Committed();
using var rig = w.Rig;
Assert.Equal(1, Notices(await Html(w, w.SurvivorSession)));
Assert.Equal(0, Notices(await Html(w, w.StrangerSession)));
Assert.DoesNotContain(Enc(MergeWording.State(AccountMergeState.Committed)), await Html(w, w.StrangerSession));
rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromHours(1));
await rig.InScopeAsync(sp => sp.GetRequiredService<AccountMergeLedger>().FinalizeDueAsync(rig.Clock.Now, default));
var final = await Html(w, w.SurvivorSession);
Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(w.MergeId)).State);
Assert.Equal(0, Notices(final));
Assert.Contains("/Membership/MergeStatus", await Html(w, w.SurvivorSession, "/Membership/Status"));
Assert.DoesNotContain("/Membership/MergeStatus", await Html(w, w.StrangerSession, "/Membership/Status"));
Assert.Contains(Enc(MergeWording.State(AccountMergeState.Final)), final);
foreach (var state in new[] { AccountMergeState.Undone, AccountMergeState.Failed })
{
await Edit(w, x => { x.State = state; x.FinalizedUtc = null; });
var page = await Html(w, w.SurvivorSession);
Assert.Equal(0, Notices(page));
Assert.Contains(Enc(MergeWording.State(state)), page);
}
}
[Theory]
[InlineData(AccountMergeState.Undone)]
[InlineData(AccountMergeState.Failed)]
[InlineData(AccountMergeState.Final)]
public async Task A_merge_that_ended_has_no_notice_even_with_its_grace_date_still_ahead(AccountMergeState state)
{
var w = await Committed();
using var rig = w.Rig;
await Edit(w, x => x.State = state);
var page = await Html(w, w.SurvivorSession);
Assert.Equal(0, Notices(page));
Assert.Contains(Enc(MergeWording.State(state)), page);
}
[Fact]
public async Task A_failed_merge_says_why_in_closed_words_and_never_echoes_the_stored_text()
{
var w = await Committed();
using var rig = w.Rig;
await Edit(w, x => { x.State = AccountMergeState.Failed; x.FailureCode = "proof_stale"; x.LastOutcome = "HEIMDALL FREE TEXT"; });
var page = await Html(w, w.SurvivorSession);
Assert.Contains(Enc(MergeWording.Failure("proof_stale")!), page);
Assert.Contains(Enc(MergeWording.Outcome("HEIMDALL FREE TEXT")!), page);
Assert.DoesNotContain("HEIMDALL FREE TEXT", page);
Assert.DoesNotContain("proof_stale", page);
}
[Fact]
public async Task A_frozen_account_reaches_the_page_and_is_told_the_merge_is_in_progress()
{
var w = await Open();
using var rig = w.Rig;
var page = await Html(w, w.FoldedSession);
Assert.Equal(1, Notices(page));
Assert.Contains(Enc(MergeWording.FrozenNotice()), page);
Assert.Contains(Enc(MergeWording.State(AccountMergeState.Confirmed)), page);
}
[Fact]
public async Task A_folded_account_reaches_the_page_is_told_when_it_was_merged_and_cannot_undo()
{
var w = await Committed();
using var rig = w.Rig;
var merge = await rig.MergeAsync(w.MergeId);
var page = await Html(w, w.FoldedSession);
Assert.Equal(1, Notices(page));
Assert.Contains(Enc(MergeWording.FoldedNotice(merge.CommittedUtc!.Value)), page);
Assert.DoesNotContain("<form", page.Split("<main")[1]);
}
}

View file

@ -0,0 +1,255 @@
using System.Net;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Tests.Support;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using static Bifrost.Web.Tests.AccountMergeSagaTests;
namespace Bifrost.Web.Tests;
/// <summary>
/// The merge status page's undo from the person's side: browser sessions of the real host over the saga rig and the fake Heimdall's
/// account model. The folded person signs in through a login the merge moved and is the survivor there.
/// </summary>
public sealed class MergeUndoPageTests
{
private const string Callback = "https://localhost";
/// <summary>The Heimdall account that holds a login now, by the fake's own model of where a move left it.</summary>
private static string OwnerOf(Rig rig, string loginId) =>
rig.Moves.Logins.Single(x => x.Value.Any(l => l.Id == loginId)).Key;
private sealed class World(Rig rig, Party survivor, Party folded, Guid mergeId) : IDisposable
{
public Rig Rig { get; } = rig;
public Party Survivor { get; } = survivor;
public Party Folded { get; } = folded;
public Guid MergeId { get; } = mergeId;
public HeimdallHost Host => Rig.Host;
/// <summary>Everything a response put on the wire: bodies, Location, Set-Cookie. The privacy test reads it at the end.</summary>
public List<string> Surface { get; } = [];
/// <summary>Every bearer the test redeemed; none may appear on the surface.</summary>
public List<string> Bearers { get; } = [];
public void Dispose() => Rig.Dispose();
public async Task<string> SignInThrough(string provider) => await Host.SignInSessionAsync(OwnerOf(Rig, Folded.Logins[provider]), provider);
public async Task<(MergePage.Antiforgery Token, HttpResponseMessage Response)> Post(string session, MergePage.Antiforgery? token, string handler, params (string, string)[] form)
{
token ??= await MergePage.TokenAsync(Host, session);
var response = await MergePage.PostAsync(Host, session, token, handler, form.ToDictionary(x => x.Item1, x => x.Item2));
Record(response, string.Empty);
return (token, response);
}
public void Record(HttpResponseMessage response, string body) =>
Surface.Add(body + "\n" + MergePage.Location(response) + "\n" + string.Join('\n', response.Headers.Select(x => x.Key + string.Concat(x.Value))));
public async Task<string> Page(string session, string query = "")
{
var (response, html) = await MergePage.GetAsync(Host, session, query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Record(response, html);
return html;
}
public IReadOnlyList<string> MovedIds(AccountMerge merge) => JsonSerializer.Deserialize<string[]>(merge.MovedIdentityIds!)!;
/// <summary>One fresh sign-in of the survivor's account through a moved login of <paramref name="provider"/>, as the page's button and Heimdall's return do.</summary>
public async Task Prove(string session, MergePage.Antiforgery token, string provider)
{
var merge = await Rig.MergeAsync(MergeId);
var before = Host.Heimdall.StartBodies.Count;
var (_, started) = await Post(session, token, "ProveForUndo", ("via", provider));
Assert.Equal(HttpStatusCode.Redirect, started.StatusCode);
var attempt = Host.AttemptFrom(started, before);
Assert.NotEmpty(attempt.Nonce);
Host.Heimdall.Identities.Clear();
Host.Heimdall.AddIdentity("twitch", id: Survivor.Logins["twitch"]);
foreach (var moved in MovedIds(merge))
{
Host.Heimdall.AddIdentity(Rig.Moves.Logins.Values.SelectMany(x => x).Single(x => x.Id == moved).Provider, id: moved);
}
var bearer = Host.Token(Survivor.HeimdallId);
Rig.Moves.Issue(Survivor.HeimdallId, Folded.Logins[provider], bearer: bearer);
Bearers.Add(bearer);
var done = await Host.PostCompleteAsync(
new Dictionary<string, string> { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, Survivor.HeimdallId, bearer, provider: provider) },
$"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}");
Record(done, string.Empty);
Assert.Equal(MergePage.Url + "?result=merge_proof_recorded", MergePage.Location(done));
}
}
private static async Task<World> Committed(params string[] foldedLogins)
{
var rig = new Rig(realSessions: true);
var survivor = await rig.AccountAsync("quill-9f3a", "twitch");
var folded = await rig.AccountAsync("banks-2d7c", foldedLogins);
var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), [.. foldedLogins.Select(x => rig.Weak(folded, x))]);
Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State);
return new World(rig, survivor, folded, id);
}
private static string Undo(World w, string code) => $"{MergePage.Url}?undo={code}";
[Fact]
public async Task A_sign_in_through_a_login_the_merge_moved_lands_on_the_survivors_account_not_the_folded_one()
{
using var w = await Committed("discord");
// Heimdall answers a sign-in through the moved login with the account that holds the login now.
var landedOn = OwnerOf(w.Rig, w.Folded.Logins["discord"]);
Assert.Equal(w.Survivor.HeimdallId, landedOn);
var session = await w.Host.SignInSessionAsync(landedOn, "discord");
// The survivor acts (the folded account would be refused on this page: its actor has no account).
var (response, _) = await w.Host.GetLinkedAccountsAsync(session);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Undo_needs_a_fresh_proof_of_every_moved_login_and_the_missing_provider_is_named()
{
using var w = await Committed("discord", "patreon");
var session = await w.SignInThrough("discord");
var token = await MergePage.TokenAsync(w.Host, session);
await w.Prove(session, token, "discord");
var page = await w.Page(session);
Assert.Contains("Patreon", page);
var asked = w.Rig.Moves.Requests.Count;
var (_, refused) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.Equal(Undo(w, "logins_unproved"), MergePage.Location(refused));
Assert.Equal(asked, w.Rig.Moves.Requests.Count);
var merge = await w.Rig.MergeAsync(w.MergeId);
Assert.Equal(AccountMergeState.Committed, merge.State);
Assert.Null(merge.UndoRequestedUtc);
Assert.Contains(Enc(MergeWording.Undo("logins_unproved")), await w.Page(session, "?undo=logins_unproved"));
}
[Fact]
public async Task The_folded_person_proves_every_moved_login_and_the_undo_runs_once_to_Undone_and_spends_the_proofs()
{
using var w = await Committed("discord", "patreon");
var session = await w.SignInThrough("patreon");
var token = await MergePage.TokenAsync(w.Host, session);
await w.Prove(session, token, "discord");
await w.Prove(session, token, "patreon");
var asked = w.Rig.Moves.Requests.Count;
var (_, done) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.Equal(Undo(w, "requested"), MergePage.Location(done));
Assert.Equal(AccountMergeState.Undone, (await w.Rig.MergeAsync(w.MergeId)).State);
Assert.Equal(asked + 1, w.Rig.Moves.Requests.Count);
Assert.Equal(new MergeProofsView(null, null), w.Host.Services.GetRequiredService<MergeProofs>().Snapshot(w.Survivor.Id));
// A replay has no proofs left and starts nothing.
var (_, replay) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.NotEqual(Undo(w, "requested"), MergePage.Location(replay));
Assert.Equal(asked + 1, w.Rig.Moves.Requests.Count);
}
[Fact]
public async Task Another_member_posting_the_survivors_undo_is_NotSurvivor_and_the_saga_is_never_asked()
{
using var w = await Committed("discord");
var other = await w.Rig.AccountAsync("other-survivor-77c1", "twitch");
var otherFolded = await w.Rig.AccountAsync("other-folded-08ab", "patreon");
var otherMerge = await w.Rig.ConfirmedAsync(other, otherFolded, w.Rig.Weak(other, "twitch"), w.Rig.Weak(otherFolded, "patreon"));
await w.Rig.AdvanceAsync(otherMerge);
var session = await w.Host.SignInSessionAsync(other.HeimdallId);
var asked = w.Rig.Moves.Requests.Count;
var (_, response) = await w.Post(session, null, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.Equal(Undo(w, "not_survivor"), MergePage.Location(response));
Assert.Equal(asked, w.Rig.Moves.Requests.Count);
var merge = await w.Rig.MergeAsync(w.MergeId);
Assert.Equal(AccountMergeState.Committed, merge.State);
Assert.Null(merge.UndoRequestedUtc);
}
[Fact]
public async Task A_post_after_the_grace_ended_is_GraceOver_with_live_proofs_and_nothing_is_requested()
{
using var w = await Committed("discord");
var session = await w.SignInThrough("discord");
var token = await MergePage.TokenAsync(w.Host, session);
w.Rig.Clock.Advance(AccountMergeLedger.Grace - TimeSpan.FromSeconds(60));
await w.Prove(session, token, "discord");
w.Rig.Clock.Advance(TimeSpan.FromSeconds(61));
var asked = w.Rig.Moves.Requests.Count;
var (_, response) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.Equal(Undo(w, "grace_over"), MergePage.Location(response));
Assert.Equal(asked, w.Rig.Moves.Requests.Count);
Assert.Equal(AccountMergeState.Committed, (await w.Rig.MergeAsync(w.MergeId)).State);
}
[Fact]
public async Task A_reversal_Heimdall_refuses_returns_the_merge_to_Committed_and_the_page_words_the_refusal()
{
using var w = await Committed("discord");
var session = await w.SignInThrough("discord");
var token = await MergePage.TokenAsync(w.Host, session);
await w.Prove(session, token, "discord");
w.Rig.Moves.MoveStatus = HttpStatusCode.BadRequest;
var (_, response) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
Assert.Equal(Undo(w, "requested"), MergePage.Location(response));
var merge = await w.Rig.MergeAsync(w.MergeId);
Assert.Equal(AccountMergeState.Committed, merge.State);
Assert.StartsWith("undo_", merge.LastOutcome);
Assert.Contains(Enc(MergeWording.Outcome(merge.LastOutcome)!), await w.Page(session));
}
[Fact]
public async Task No_response_cookie_or_redirect_of_the_merge_pages_carries_an_account_id_name_or_bearer()
{
using var w = await Committed("discord", "patreon");
var session = await w.SignInThrough("discord");
var token = await MergePage.TokenAsync(w.Host, session);
await w.Page(session);
await w.Prove(session, token, "discord");
await w.Prove(session, token, "patreon");
await w.Page(session);
await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString()));
await w.Page(session, "?undo=requested");
var everything = string.Join('\n', w.Surface);
var secrets = new List<string> { "quill-9f3a", "banks-2d7c", w.Survivor.HeimdallId, w.Folded.HeimdallId };
foreach (var id in new[] { w.Survivor.Id, w.Folded.Id })
{
secrets.AddRange([id.ToString(), id.ToString("N")]);
}
secrets.AddRange(w.Survivor.Logins.Values);
secrets.AddRange(w.Folded.Logins.Values);
secrets.AddRange(w.Bearers);
Assert.Equal(2, w.Bearers.Count);
Assert.All(secrets, x => Assert.DoesNotContain(x, everything));
Assert.DoesNotContain("PUID-CANARY", everything);
Assert.DoesNotContain("username-canary", everything);
}
private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text);
}

View file

@ -0,0 +1,67 @@
using System.Text.RegularExpressions;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
namespace Bifrost.Web.Tests;
/// <summary>The merge pages' words are a closed set: every state, refusal code and outcome the saga can store has one, and nothing the store holds is echoed.</summary>
public sealed class MergeWordingTests
{
private static string Snake(HeimdallMoveRefusal code) =>
code == HeimdallMoveRefusal.RequestInvalid ? "invalid_request" : Regex.Replace(code.ToString(), "(?<!^)([A-Z])", "_$1").ToLowerInvariant();
[Fact]
public void Every_state_has_words_and_they_differ()
{
var words = Enum.GetValues<AccountMergeState>().Select(MergeWording.State).ToArray();
Assert.All(words, x => Assert.NotEmpty(x));
Assert.Equal(words.Length, words.Distinct().Count());
}
[Fact]
public void Every_refusal_Heimdall_can_give_has_words_and_the_code_itself_is_not_one_of_them()
{
foreach (var code in Enum.GetValues<HeimdallMoveRefusal>().Select(Snake))
{
var words = MergeWording.Failure(code);
Assert.False(string.IsNullOrWhiteSpace(words), code);
Assert.DoesNotContain(code, words!);
Assert.False(string.IsNullOrWhiteSpace(MergeWording.Outcome("undo_" + code)), code);
}
}
[Theory]
[InlineData("confirmed")]
[InlineData("moved")]
[InlineData("refused")]
[InlineData("undo_requested")]
[InlineData("receipt_mismatch")]
[InlineData("proofs_unreadable")]
[InlineData("transport")]
[InlineData("move_id_conflict")]
[InlineData("malformed or mismatched receipt")]
public void Every_outcome_the_saga_stores_has_words(string outcome) => Assert.False(string.IsNullOrWhiteSpace(MergeWording.Outcome(outcome)), outcome);
[Theory]
[InlineData("HTTP 502")]
[InlineData("HEIMDALL FREE TEXT")]
[InlineData("undo_made_up_code")]
public void An_outcome_the_wording_does_not_know_is_a_generic_line_that_does_not_echo_it(string outcome)
{
var words = MergeWording.Outcome(outcome);
Assert.False(string.IsNullOrWhiteSpace(words));
Assert.DoesNotContain(outcome, words!);
Assert.Null(MergeWording.Failure(outcome));
}
[Fact]
public void Every_undo_answer_has_distinct_words_and_a_closed_code()
{
UndoOutcome[] all = [new UndoOutcome.Requested(), new UndoOutcome.NotCommitted(), new UndoOutcome.GraceOver(), new UndoOutcome.NotSurvivor(), new UndoOutcome.ProofAccountMismatch()];
var codes = all.Select(MergeWording.UndoCode).Append("logins_unproved").ToArray();
Assert.Equal(6, codes.Distinct().Count());
Assert.All(codes, x => Assert.False(string.IsNullOrWhiteSpace(MergeWording.Undo(x)), x));
Assert.True(string.IsNullOrEmpty(MergeWording.Undo("anything else")));
}
}

View file

@ -31,6 +31,7 @@ public sealed class MigrationDiscoveryTests
"20261113120000_AddPasskeyCredentials",
"20261114120000_AddAccountMerges",
"20261114130000_AddAccountMergeSaga",
"20261114140000_AddAccountMergeNoticeAcks",
];
// Database.GetMigrations() reads assembly metadata only; no connection is opened.

View file

@ -77,12 +77,12 @@ public sealed class FakeMoves(Func<DateTimeOffset> now)
/// <summary>
/// Issues a live bearer of <paramref name="account"/> for the bifrost app. A weak proof is a sign-in through <paramref name="authIdentityId"/>
/// at <paramref name="authAgo"/> before now; a step-up (<paramref name="stepUpMethod"/>) adds step_up_at at <paramref name="stepUpAgo"/> before now.
/// at <paramref name="authAgo"/> before now (<paramref name="bearer"/> registers a token another fake minted, such as the sign-in's JWT, so one string is a proof at both); a step-up (<paramref name="stepUpMethod"/>) adds step_up_at at <paramref name="stepUpAgo"/> before now.
/// </summary>
public string Issue(string account, string? authIdentityId = null, string? stepUpMethod = null, int authAgo = 0, int? stepUpAgo = null, string aud = App)
public string Issue(string account, string? authIdentityId = null, string? stepUpMethod = null, int authAgo = 0, int? stepUpAgo = null, string aud = App, string? bearer = null)
{
var at = now().ToUnixTimeSeconds();
var token = "bearer-" + Guid.NewGuid().ToString("N");
var token = bearer ?? "bearer-" + Guid.NewGuid().ToString("N");
_tokens[token] = new Claims(
account, Guid.NewGuid().ToString(), aud, at - authAgo, authIdentityId,
stepUpMethod is null ? null : at - (stepUpAgo ?? 0), stepUpMethod, at + Lifetime);

View file

@ -740,6 +740,8 @@ public sealed class HeimdallHost : TestWebApplicationFactory
private readonly bool _discordRoles;
private readonly bool _traceHttp;
private readonly bool _realSessions;
private readonly bool _accountMerge;
private readonly string? _chatNoticesToken;
public TestClock Clock { get; } = new();
@ -756,8 +758,12 @@ public sealed class HeimdallHost : TestWebApplicationFactory
bool discordRoles = true,
string appSlug = "bifrost",
bool traceHttp = false,
bool realSessions = false)
bool realSessions = false,
bool accountMerge = false,
string? chatNoticesToken = null)
{
_accountMerge = accountMerge;
_chatNoticesToken = chatNoticesToken;
_traceHttp = traceHttp;
_realSessions = realSessions;
_appSecret = appSecret;
@ -786,6 +792,16 @@ public sealed class HeimdallHost : TestWebApplicationFactory
["Heimdall:DiscordGuildId"] = "guild-1",
["Host:PublicBaseUrl"] = _publicBaseUrl
});
if (_accountMerge)
{
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["AccountMerge:Enabled"] = "true" });
}
if (_chatNoticesToken is not null)
{
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["ChatNotices:Token"] = _chatNoticesToken });
}
if (_discordRoles)
{
configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Heimdall:DiscordAllowedRoleIds:0"] = "role-1" });

View file

@ -0,0 +1,45 @@
using System.Text.RegularExpressions;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests.Support;
/// <summary>The merge status page as a browser uses it: a GET with the session, a POST with the antiforgery pair the page itself handed out.</summary>
internal static class MergePage
{
public const string Url = "/Membership/MergeStatus";
public const string AccountsUrl = "/Membership/MergeAccounts";
public sealed record Antiforgery(string Token, string Cookie);
public static async Task<(HttpResponseMessage Response, string Html)> GetAsync(HeimdallHost host, string session, string query = "", string url = Url)
{
using var client = host.NewClient();
using var request = new HttpRequestMessage(HttpMethod.Get, url + query);
request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}");
var response = await client.SendAsync(request);
return (response, await response.Content.ReadAsStringAsync());
}
/// <summary>The antiforgery pair from a page of this member that carries a form.</summary>
public static async Task<Antiforgery> TokenAsync(HeimdallHost host, string session, string url = Url)
{
var (response, html) = await GetAsync(host, session, "", url);
var token = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value;
Assert.NotEmpty(token);
return new Antiforgery(token, HeimdallHost.CookieValue(response, BifrostCookies.Antiforgery)!);
}
public static async Task<HttpResponseMessage> PostAsync(
HeimdallHost host, string session, Antiforgery antiforgery, string handler, IDictionary<string, string> form, string url = Url)
{
using var client = host.NewClient();
var fields = new Dictionary<string, string>(form) { ["__RequestVerificationToken"] = antiforgery.Token };
using var request = new HttpRequestMessage(HttpMethod.Post, $"{url}?handler={handler}") { Content = new FormUrlEncodedContent(fields) };
request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}; {BifrostCookies.Antiforgery}={antiforgery.Cookie}");
return await client.SendAsync(request);
}
public static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString;
}