diff --git a/migrations/0013_claim_intent.sql b/migrations/0013_claim_intent.sql new file mode 100644 index 00000000..0c455bd3 --- /dev/null +++ b/migrations/0013_claim_intent.sql @@ -0,0 +1,30 @@ +-- spec §8.4, §8.5 — what a second person publishing a token on the same server MEANS. +-- +-- §8.5 says a game may have several owners, each having verified a token of their own. §8.4 says a +-- counter-claim — a different account proving control now — is the correct handling of a game +-- changing hands. Both are true, they are opposite outcomes, and NOTHING IN A PROBE CAN TELL THEM +-- APART: a co-founder joining and a new operator taking over publish the identical line in the +-- identical config file, and the crawler reads the identical string. +-- +-- So the difference is not inferred, it is DECLARED, and it is declared before the token is +-- published rather than after it verifies. The claimant says which they are doing at the moment they +-- ask for a token, on a page that explains both, and the answer is stored here beside the token it +-- belongs to. A design that guessed — "a claim on an already-claimed game must be a takeover" — +-- would silently unclaim a co-founder's partner the first time two people ran one game; guessing the +-- other way would leave a departed operator holding a listing they no longer run. +-- +-- Neither is more trusted than the other. Both publish a token on the server, which is the whole +-- test (§8.1), so an account choosing 'assume' has proved exactly what an account choosing 'join' +-- proved. The choice decides what happens to the OTHER claims, not how hard this one was to make. +ALTER TABLE game_claim + ADD COLUMN intent text NOT NULL DEFAULT 'join'; + +ALTER TABLE game_claim + ADD CONSTRAINT game_claim_intent_vocabulary CHECK (intent IN ('join', 'assume')); + +-- Every claim that existed before this column did was a first claim on an unclaimed game, so 'join' +-- is the honest backfill: none of them displaced anybody, and the DEFAULT records that rather than +-- inventing an intent nobody expressed. +COMMENT ON COLUMN game_claim.intent IS + 'join: become one of the game''s owners. assume: take it over, revoking the others on ' + 'verification (§8.4''s counter-claim). Declared when the token is issued, never inferred.'; diff --git a/src/MUI.Catalog/Claims.cs b/src/MUI.Catalog/Claims.cs index bf1e2a85..01a24b12 100644 --- a/src/MUI.Catalog/Claims.cs +++ b/src/MUI.Catalog/Claims.cs @@ -16,6 +16,36 @@ public enum ClaimChannel ConnectScreen, } +/// +/// Whether a claimant is joining a game's owners or taking it over (spec §8.4, §8.5). +/// +/// +/// +/// Declared, never inferred. §8.5 allows a game several owners, each having verified a token +/// of their own; §8.4 makes a counter-claim the way a game changes hands. Both are real, they are +/// opposite outcomes, and nothing in a probe can tell them apart — a co-founder joining and a new +/// operator taking over publish the identical line in the identical config file. So the claimant says +/// which, when they ask for the token, on a page that explains both. +/// +/// +/// Guessing was the alternative and it is wrong in both directions. "A claim on an already-claimed +/// game is a takeover" silently unclaims a partner the first time two people run one game; the +/// opposite leaves a departed operator holding a listing they no longer run. +/// +/// +/// Neither is the more trusted. Both publish a token on the server, which is the whole test — so the +/// choice decides what happens to the other claims, not how hard this one was to make. +/// +/// +public enum ClaimIntent +{ + /// Become one of the game's owners, alongside whoever else has proved it. + Join, + + /// Take the game over. On verification, every other verified claim is revoked. + Assume, +} + /// /// A claim on a game by an account: pending while is null, verified after. /// @@ -44,6 +74,9 @@ public sealed record GameClaim public required string Token { get; init; } + /// Whether verifying this joins the game's owners or displaces them (spec §8.4). + public ClaimIntent Intent { get; init; } = ClaimIntent.Join; + public required DateTimeOffset IssuedAt { get; init; } /// @@ -206,6 +239,11 @@ public enum ClaimVerdict /// A token we issued, matching a claim that has expired or been revoked. Stale, + + /// + /// A counter-claim completed: this account now owns the game and the others were revoked (§8.4). + /// + Assumed, } /// diff --git a/src/MUI.Catalog/Persistence/ClaimService.cs b/src/MUI.Catalog/Persistence/ClaimService.cs index c4f8396c..a7babf89 100644 --- a/src/MUI.Catalog/Persistence/ClaimService.cs +++ b/src/MUI.Catalog/Persistence/ClaimService.cs @@ -47,6 +47,7 @@ public sealed class ClaimService( public async Task IssueAsync( Guid gameId, Guid userId, + ClaimIntent intent = ClaimIntent.Join, CancellationToken cancellationToken = default) { var now = time.GetUtcNow(); @@ -63,6 +64,7 @@ public async Task IssueAsync( GameId = gameId, UserId = userId, Token = ClaimToken.Mint(), + Intent = intent, IssuedAt = now, ExpiresAt = now + ClaimToken.PendingLifetime, }; @@ -124,7 +126,40 @@ await claims.RecordEventAsync( // reachable before now. await games.SetClaimedAsync(gameId, true, cancellationToken); - return ClaimVerdict.Verified; + if (pending.Intent is not ClaimIntent.Assume) + { + return ClaimVerdict.Verified; + } + + // §8.4's counter-claim, which is how a game changes hands. The displaced owners are + // revoked here and nowhere else: this is the ONE revocation nobody typed, and it is + // sound because the account that caused it published a token on the same server the + // others did. It proves control now, which is the only thing ownership here ever meant. + // + // The game stays claimed throughout — SetClaimedAsync was called above — so a takeover + // never flickers the listing badge off and on. + var displaced = (await claims.ForGameAsync(gameId, cancellationToken)) + .Where(other => other.Id != pending.Id && other.IsVerified) + .ToList(); + + foreach (var other in displaced) + { + await claims.UpdateAsync( + other with + { + RevokedAt = now, + RevokedReason = "counter-claim: another account proved control of this game", + }, + cancellationToken); + + // On the LOSING claim, because that is whose record changed. An owner reading their + // own history has to be able to see what happened to them and when. + await claims.RecordEventAsync( + new ClaimEvent(other.Id, now, ClaimEventKind.CounterClaimed), + cancellationToken); + } + + return displaced.Count > 0 ? ClaimVerdict.Assumed : ClaimVerdict.Verified; } var onGame = await claims.ForGameAsync(gameId, cancellationToken); @@ -209,6 +244,44 @@ await claims.RecordEventAsync( /// claimed only when no verified claim is left, because §8.5 allows several owners and one /// walking away does not unclaim the game for the others. /// + /// + /// An owner giving up a game they hold. + /// + /// + /// Scoped to the account, because takes a claim id and a claim id is + /// not a credential — anybody who learns one could otherwise unclaim somebody else's game. The + /// caller is the account, so the check belongs here rather than in whichever page happens to + /// call it. + /// + public async Task ResignAsync( + Guid claimId, + Guid userId, + CancellationToken cancellationToken = default) + { + if (await claims.FindAsync(claimId, cancellationToken) is not { } claim + || claim.UserId != userId + || !claim.IsVerified) + { + return false; + } + + await RevokeAsync(claimId, "the owner gave up this claim", cancellationToken); + + return true; + } + + /// Every account that has proved control of a game, newest first (spec §8.5). + public async Task> OwnersAsync( + Guid gameId, + CancellationToken cancellationToken = default) => + [.. (await claims.ForGameAsync(gameId, cancellationToken)).Where(claim => claim.IsVerified)]; + + /// One claim's audit log, oldest first (spec §8.5). + public Task> HistoryAsync( + Guid claimId, + CancellationToken cancellationToken = default) => + claims.EventsAsync(claimId, cancellationToken); + public async Task RevokeAsync(Guid claimId, string reason, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(reason); diff --git a/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs index d38a7364..3984f318 100644 --- a/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs +++ b/src/MUI.Catalog/Persistence/NpgsqlClaimStore.cs @@ -17,7 +17,7 @@ namespace MUI.Catalog.Persistence; public sealed class NpgsqlClaimStore(NpgsqlDataSource source) : IClaimStore { private const string Columns = """ - id AS Id, game_id AS GameId, user_id AS UserId, token AS Token, + id AS Id, game_id AS GameId, user_id AS UserId, token AS Token, intent AS Intent, issued_at AS IssuedAt, expires_at AS ExpiresAt, claimed_at AS ClaimedAt, beacon_last_seen_at AS BeaconLastSeenAt, verified_via AS VerifiedVia, revoked_at AS RevokedAt, revoked_reason AS RevokedReason, last_checked_at AS LastCheckedAt @@ -105,11 +105,11 @@ public async Task InsertAsync(GameClaim claim, CancellationToken cancellationTok await connection.ExecuteAsync(new CommandDefinition( """ INSERT INTO game_claim ( - id, game_id, user_id, token, issued_at, expires_at, + id, game_id, user_id, token, intent, issued_at, expires_at, claimed_at, beacon_last_seen_at, verified_via, revoked_at, revoked_reason, last_checked_at) VALUES ( - @Id, @GameId, @UserId, @Token, @IssuedAt, @ExpiresAt, + @Id, @GameId, @UserId, @Token, @Intent, @IssuedAt, @ExpiresAt, @ClaimedAt, @BeaconLastSeenAt, @VerifiedVia, @RevokedAt, @RevokedReason, @LastCheckedAt) """, @@ -182,6 +182,7 @@ public async Task> EventsAsync( claim.GameId, claim.UserId, claim.Token, + Intent = SqlEnums.ToDb(claim.Intent), claim.IssuedAt, claim.ExpiresAt, claim.ClaimedAt, @@ -207,6 +208,8 @@ private sealed class Row public string Token { get; init; } = string.Empty; + public string Intent { get; init; } = "join"; + public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } @@ -229,6 +232,7 @@ private sealed class Row GameId = GameId, UserId = UserId, Token = Token, + Intent = SqlEnums.ToClaimIntent(Intent), IssuedAt = IssuedAt, ExpiresAt = ExpiresAt, ClaimedAt = ClaimedAt, diff --git a/src/MUI.Catalog/Persistence/SqlEnums.cs b/src/MUI.Catalog/Persistence/SqlEnums.cs index 898c0691..fd2be9c1 100644 --- a/src/MUI.Catalog/Persistence/SqlEnums.cs +++ b/src/MUI.Catalog/Persistence/SqlEnums.cs @@ -33,6 +33,21 @@ public static class SqlEnums _ => throw Unread(value, nameof(FieldSource)), }; + /// Whether a claim joins a game's owners or displaces them (spec §8.4). + public static string ToDb(ClaimIntent intent) => intent switch + { + ClaimIntent.Join => "join", + ClaimIntent.Assume => "assume", + _ => throw Unmapped(intent), + }; + + public static ClaimIntent ToClaimIntent(string value) => value switch + { + "join" => ClaimIntent.Join, + "assume" => ClaimIntent.Assume, + _ => throw Unread(value, nameof(ClaimIntent)), + }; + /// /// The channel a claim token was read from (spec §8.3). DNS is absent from the enum, not merely /// unmapped here — a TXT record proves control of a hostname, and a hostname is not a game. diff --git a/src/MUI.Web/Accounts/OwnershipWrites.cs b/src/MUI.Web/Accounts/OwnershipWrites.cs new file mode 100644 index 00000000..e5a53268 --- /dev/null +++ b/src/MUI.Web/Accounts/OwnershipWrites.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Identity; + +using MUI.Catalog; +using MUI.Catalog.Persistence; + +namespace MUI.Web.Accounts; + +/// +/// The two ownership decisions a person makes with a form (spec §8.4, §8.5). +/// +/// +/// Both are plain posts and redirects, like the rest of the account surface, and both delegate every +/// decision to — including who is allowed to make it. A claim id is not a +/// credential and travels in URLs and logs, so the account has to be checked against the claim +/// rather than assumed from the fact that somebody knew its id. +/// +public static class OwnershipWrites +{ + public static void MapMuiOwnership(this WebApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + // Asking for a token on a game somebody already owns, having said which of the two things + // §8.5 allows you are doing. The choice is made HERE, before the token exists, because it + // is stored on the claim the token belongs to — a token published as a co-owner must not be + // settleable as a takeover. + app.MapPost("/g/{slug}/claim/start", async ( + HttpContext context, + UserManager users, + IGameQueries queries, + ClaimService claims, + string slug, + IFormCollection form) => + { + if (await users.GetUserAsync(context.User) is not { } user + || await queries.FindAsync(slug) is not { } page) + { + return Results.Redirect($"/g/{slug}/claim"); + } + + var intent = string.Equals(form["intent"], "assume", StringComparison.Ordinal) + ? ClaimIntent.Assume + : ClaimIntent.Join; + + await claims.IssueAsync(page.Summary.Id, user.Id, intent); + + return Results.Redirect($"/g/{slug}/claim"); + }).RequireAuthorization(); + + // Giving up a game. Explicit, which §8.4 requires of every revocation that is not a + // counter-claim: a claim never lapses on its own and absence never revokes. + app.MapPost("/account/claims/{claimId:guid}/resign", async ( + HttpContext context, + UserManager users, + ClaimService claims, + Guid claimId, + IFormCollection form) => + { + if (await users.GetUserAsync(context.User) is not { } user) + { + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + // A typed confirmation rather than a second page. Resigning is irreversible in the sense + // that getting back in means publishing a fresh token — recoverable, but not by pressing + // undo — and a bare button beside a game's name is one misclick from a listing an + // operator no longer owns. + if (!string.Equals(form["confirm"], "resign", StringComparison.Ordinal)) + { + return Results.Redirect($"{Passkeys.DashboardPath}?resign={claimId}"); + } + + return await claims.ResignAsync(claimId, user.Id) + ? Results.Redirect($"{Passkeys.DashboardPath}?resigned=1") + : Results.StatusCode(StatusCodes.Status403Forbidden); + }).RequireAuthorization(); + } +} diff --git a/src/MUI.Web/Accounts/Passkeys.cs b/src/MUI.Web/Accounts/Passkeys.cs index f029b86f..cfc09b69 100644 --- a/src/MUI.Web/Accounts/Passkeys.cs +++ b/src/MUI.Web/Accounts/Passkeys.cs @@ -277,6 +277,9 @@ await signIn.MakePasskeyRequestOptionsAsync(user: null), // §8.5's enrichment and §11's suppression, which are the only writes a claim grants. app.MapMuiOwnerWrites(); + + // §8.4's counter-claim and §8.5's several owners, as the two forms that reach them. + app.MapMuiOwnership(); } /// What the page posts back after the authenticator has answered. diff --git a/src/MUI.Web/Components/Pages/Account.razor b/src/MUI.Web/Components/Pages/Account.razor index bf204cd6..075426ee 100644 --- a/src/MUI.Web/Components/Pages/Account.razor +++ b/src/MUI.Web/Components/Pages/Account.razor @@ -52,7 +52,19 @@ else

} - @if (Saved is not null) + @* + One banner slot, and the three things a POST to this page can come back saying. An + else-if chain rather than three independent blocks: a redirect carries exactly one + outcome, and two banners at once would be two answers to one action. + *@ + @if (Resigned) + { +

+ Given up. The record of it is kept, and you can prove control again + any time by publishing a fresh token. +

+ } + else if (Saved is not null) {

Saved. @Outcome

} @@ -91,8 +103,24 @@ else @* §8.5's scorecard. Owner-only, so it is reachable from here and nowhere. *@ check your MSSP
+ + @if (CoOwners(claim) is { Count: > 0 } others) + { + @* §8.5 — a game may have several owners, and each of them should know. *@ +

+ Also owned by @string.Join(", ", others.Select(Name)) — each having + verified a token of their own. +

+ } + + @* + Name is prefixed, and that is not decoration: a component parameter of type + string written as Name="game.Name" is a LITERAL, so every owner was shown + "What only you can tell us about game.Name". Non-string parameters beside it + are parsed as expressions either way, which is what hid it. + *@ @@ -115,6 +143,39 @@ else your own.

+ +
+ history +
    + @foreach (var entry in History(claim)) + { +
  1. + @entry.At.ToString("yyyy-MM-dd HH:mm") + @Word(entry.Kind)@(entry.Detail is { Length: > 0 } d ? $" · {d}" : string.Empty) +
  2. + } +
+
+ + @* + Explicit, and typed. §8.4 lets a claim end only by an owner saying so or by a + counter-claim — never by lapsing — and a bare button beside a game's name is + one misclick from a listing somebody no longer owns. Last in the block, after + everything an owner came here to do. + *@ +
+ give up this claim +
+ +

+ Type resign to confirm. Nothing is deleted and you can + prove control again by publishing a fresh token; the game stays + claimed if anybody else owns it. +

+ + + +
} } @@ -211,6 +272,25 @@ else [SupplyParameterFromQuery(Name = "because")] private string? Because { get; set; } + /// Every verified claim on each of this account's games, so co-owners can be named. + private Dictionary> Owners { get; } = []; + + /// Each of this account's claims' audit log (spec §8.5). + private Dictionary> Events { get; } = []; + + /// Display names for the accounts that co-own something here. + private Dictionary Names { get; } = []; + + [SupplyParameterFromQuery(Name = "resigned")] + private string? ResignedFlag { get; set; } + + [SupplyParameterFromQuery(Name = "resign")] + private string? ResignFlag { get; set; } + + private bool Resigned => ResignedFlag is not null; + + private Guid? Confirming => Guid.TryParse(ResignFlag, out var id) ? id : null; + [CascadingParameter] private HttpContext? HttpContext { get; set; } @@ -303,6 +383,23 @@ else { Owned[game.Id] = await enrichment.DeclaredAsync(game.Id); } + + if (Services.GetService() is { } service) + { + Owners[claim.Id] = await service.OwnersAsync(game.Id); + Events[claim.Id] = await service.HistoryAsync(claim.Id); + + foreach (var owner in Owners[claim.Id].Where(o => o.UserId != User.Id)) + { + // A co-owner's display name, which they chose and which appears only to the + // people they share a game with. Never an address and never an identity + // claim (§8.2). + Names.TryAdd( + owner.UserId, + (await Users.FindByIdAsync(owner.UserId.ToString()))?.DisplayName + ?? "another account"); + } + } } else if (claim.IsPending(now)) { @@ -320,6 +417,39 @@ else /// do with the owner, and alarming them about it would be the interface arguing for a rule the /// system does not have. /// + private IReadOnlyList CoOwners(GameClaim mine) => + Owners.TryGetValue(mine.Id, out var all) + ? [.. all.Where(other => other.UserId != mine.UserId)] + : []; + + private IReadOnlyList History(GameClaim mine) => + Events.TryGetValue(mine.Id, out var events) ? events : []; + + private string Name(GameClaim claim) => + Names.TryGetValue(claim.UserId, out var name) ? name : "another account"; + + /// + /// One audit entry in words. The vocabulary is the enum's, spelled for a person. + /// + /// + /// BeaconMissing reads as an observation and not as a warning: a probe not reading the + /// token happens for reasons that have nothing to do with the owner, and absence never revokes + /// (§8.4). Alarming somebody about it would be the interface arguing for a rule the system does + /// not have. + /// + private static string Word(ClaimEventKind kind) => kind switch + { + ClaimEventKind.Issued => "token issued", + ClaimEventKind.Reissued => "token issued again", + ClaimEventKind.Verified => "verified — we read your token", + ClaimEventKind.BeaconSeen => "token still published", + ClaimEventKind.BeaconMissing => "token not read this time", + ClaimEventKind.Revoked => "claim given up", + ClaimEventKind.Expired => "token expired unused", + ClaimEventKind.CounterClaimed => "another account proved control and took the game over", + _ => "check requested", + }; + private static string BeaconNote(GameClaim claim) => claim.BeaconLastSeenAt is { } seen ? $", token last seen {seen:d MMM yyyy}" diff --git a/src/MUI.Web/Components/Pages/Claim.razor b/src/MUI.Web/Components/Pages/Claim.razor index 6d1feecf..0ba4691b 100644 --- a/src/MUI.Web/Components/Pages/Claim.razor +++ b/src/MUI.Web/Components/Pages/Claim.razor @@ -44,6 +44,44 @@ else if (User is null)

Sign in or create an account

} +else if (Pending is null) +{ + @* + The game already has owners, so arriving here is not by itself the ask: §8.5 allows several + owners and §8.4 makes a counter-claim how a game changes hands, and NOTHING IN A PROBE CAN + TELL THEM APART — both publish the identical line in the identical config file. So the + choice is made before the token exists, by the person who knows which they mean. + *@ +
+

Claim @Game.Name

+ +

+ This game already has @(Owners.Count == 1 ? "an owner" : $"{Owners.Count} owners") who + proved control of the server. You can prove it too — the test is the same either way — + but we need to know what you mean by it, because we cannot tell from the token. +

+ +
+ + + +

+ Everyone keeps their claim. This is two people running one game. +

+ + +
+ + + +

+ When your token verifies, the existing @(Owners.Count == 1 ? "claim is" : "claims are") + revoked and the game is yours. They will see why in their own history. Nothing is + deleted, and they can prove control again the same way you are about to. +

+ +
+} else {
@@ -71,6 +109,15 @@ else whole test.

+ @if (Pending.Intent is ClaimIntent.Assume) + { +

+ This is a transfer. When we read this token, the + @(Owners.Count == 1 ? "current owner's claim" : "current owners' claims") on this + game are revoked and it becomes yours. +

+ } +

@Pending.Token

Either of these will do

@@ -135,6 +182,9 @@ else private GameClaim? Pending { get; set; } + /// Whoever has already proved control of this game (spec §8.5). + private IReadOnlyList Owners { get; set; } = []; + private bool CanCheck => Pending is not null && Claims!.MayRecheck(Pending); /// @@ -169,7 +219,25 @@ else User = await users.GetUserAsync(principal); } - if (User is not null) + if (User is null) + { + return; + } + + Owners = await Claims.OwnersAsync(Game.Id); + + var mine = (await Services.GetRequiredService().ForUserAsync(User.Id)) + .Where(claim => claim.GameId == Game.Id) + .ToList(); + + Pending = mine.FirstOrDefault(claim => claim.IsVerified) + ?? mine.FirstOrDefault(claim => claim.IsPending(Clock.GetUtcNow())); + + // Minting on view is right for a game nobody owns — arriving here IS the ask, and + // IssueAsync returns an existing token rather than replacing one already pasted into a + // config file. It is wrong for a game that has owners, where the ask has two meanings and + // only the claimant knows which: that case falls through to the choice above. + if (Pending is null && Owners.Count == 0) { Pending = await Claims.IssueAsync(Game.Id, User.Id); } diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 9398f4a1..482a0df4 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -1117,3 +1117,23 @@ details.publish pre { white-space: pre-wrap; word-break: break-all; } + +/* ── ownership: co-owners, the audit log, and giving up a claim ──────────── + §8.5's several owners and §8.4's explicit revocation. The history is a + details block because it is the sort of thing somebody reads once, when + something has happened that they want explained. + + The resign form is deliberately unstyled as a danger: no red, no warning + triangle. Giving up a claim is recoverable — publish a fresh token — and + dressing it as destruction would overstate what it does. The typed + confirmation is the guard, not the colour. */ + +details.history, details.resign { margin-top: 6px; } +details.history > summary, details.resign > summary { cursor: pointer; font-size: 12px; } + +ol.events { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12px; } +ol.events li { padding: 2px 0; color: var(--dim); } +ol.events .faint { margin-right: 8px; } + +details.resign form { margin-top: 6px; } +details.resign input[type="text"] { display: block; margin: 6px 0; max-width: 16ch; } diff --git a/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs new file mode 100644 index 00000000..b112beb4 --- /dev/null +++ b/tests/MUI.Catalog.Tests/Persistence/OwnershipPostgresTests.cs @@ -0,0 +1,354 @@ +using Dapper; + +using MUI.Catalog.Persistence; +using MUI.Catalog.Tests.Persistence.Support; + +using Npgsql; + +namespace MUI.Catalog.Tests.Persistence; + +/// +/// Several owners, a game changing hands, and the log that says which happened (spec §8.4, §8.5). +/// +/// +/// The whole of this file turns on one thing: a co-founder joining and a new operator taking over +/// publish the identical line in the identical config file, so the difference cannot be measured and +/// must be declared. These assert that it is — and that neither outcome can be reached by accident. +/// +public class OwnershipPostgresTests +{ + private static readonly DateTimeOffset Now = Seed.Now; + + /// §8.5 — a game may have several owners, each having verified a token of their own. + [Test] + public async Task TwoAccountsMayBothOwnOneGame() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var one = await UserAsync(db, "one"); + var two = await UserAsync(db, "two"); + + var first = await service.IssueAsync(game, one); + await service.OfferBeaconAsync(game, first.Token, ClaimChannel.Mssp); + + var second = await service.IssueAsync(game, two); + var verdict = await service.OfferBeaconAsync(game, second.Token, ClaimChannel.ConnectScreen); + + // Joining is the default, so the first owner is untouched. + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Verified); + await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(2); + await Assert.That(await IsClaimedAsync(db, game)).IsTrue(); + } + + /// + /// §8.4 — a counter-claim is how a game changes hands, and it says so before it is published. + /// + [Test] + public async Task ACounterClaimTakesTheGameOverAndRevokesTheOthers() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var departing = await UserAsync(db, "departing"); + var arriving = await UserAsync(db, "arriving"); + + var old = await service.IssueAsync(game, departing); + await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp); + + var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume); + var verdict = await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Assumed); + + var owners = await service.OwnersAsync(game); + + await Assert.That(owners.Count).IsEqualTo(1); + await Assert.That(owners.Single().UserId).IsEqualTo(arriving); + + // The game never stops being claimed on the way through, so the listing badge does not + // flicker off and on while a handover completes. + await Assert.That(await IsClaimedAsync(db, game)).IsTrue(); + } + + /// + /// The displaced owner's own log says what happened to them, and when. + /// + /// + /// The event goes on the losing claim rather than the winning one, because that is whose record + /// changed. An owner who finds their claim gone must be able to read why from their own history + /// rather than by inferring it from somebody else's. + /// + [Test] + public async Task TheDisplacedOwnerCanReadWhatHappenedInTheirOwnHistory() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var departing = await UserAsync(db, "departing"); + var arriving = await UserAsync(db, "arriving"); + + var old = await service.IssueAsync(game, departing); + await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp); + + var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume); + await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp); + + var history = await service.HistoryAsync(old.Id); + + await Assert.That(history.Select(e => e.Kind)).Contains(ClaimEventKind.CounterClaimed); + + var revoked = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)) + .Single(c => c.Id == old.Id); + + await Assert.That(revoked.RevokedAt).IsNotNull(); + await Assert.That(revoked.RevokedReason).Contains("counter-claim"); + } + + /// + /// A takeover that displaces nobody is an ordinary first claim, and is not reported as a seizure. + /// + [Test] + public async Task AssumingAnUnclaimedGameIsJustAClaim() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var user = await UserAsync(db, "first"); + + var claim = await service.IssueAsync(game, user, ClaimIntent.Assume); + var verdict = await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Verified); + await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(1); + } + + /// + /// Intent is recorded when the token is issued, so it cannot be changed after it is published. + /// + /// + /// The claimant declares it on the page that explains both, and the row carries it from that + /// moment. A design that read intent at verification time would let an account publish a token + /// as a co-owner and settle it as a takeover. + /// + [Test] + public async Task IntentIsStoredWithTheTokenAndSurvivesARoundTrip() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var user = await UserAsync(db, "owner"); + + var claim = await service.IssueAsync(game, user, ClaimIntent.Assume); + var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single(); + + await Assert.That(stored.Intent).IsEqualTo(ClaimIntent.Assume); + await Assert.That(claim.Intent).IsEqualTo(ClaimIntent.Assume); + } + + /// The schema refuses an intent nothing maps, as it does every other vocabulary. + [Test] + public async Task TheSchemaRefusesAnIntentWeDoNotKnow() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await Assert.That(async () => await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at) + VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', 'seize', @now, @later) + """, + new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) })) + .Throws(); + } + + /// A claim that existed before intent did was a first claim, and reads as one. + /// + /// The column's DEFAULT is the backfill: none of those claims displaced anybody, so recording + /// them as joins states what happened rather than inventing an intent nobody expressed. + /// + [Test] + public async Task AClaimWrittenWithoutAnIntentIsAJoin() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await UserAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, issued_at, expires_at) + VALUES (@id, @game, @user, 'muidx-aaaaaaaaaaaaaaaaaaaa', @now, @later) + """, + new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) }); + + var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single(); + + await Assert.That(stored.Intent).IsEqualTo(ClaimIntent.Join); + } + + /// An owner may give up a game, and the others keep theirs (§8.5). + [Test] + public async Task AnOwnerMayResignWithoutUnclaimingTheGameForTheRest() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var leaving = await UserAsync(db, "leaving"); + var staying = await UserAsync(db, "staying"); + + var theirs = await service.IssueAsync(game, leaving); + await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp); + var others = await service.IssueAsync(game, staying); + await service.OfferBeaconAsync(game, others.Token, ClaimChannel.Mssp); + + await Assert.That(await service.ResignAsync(theirs.Id, leaving)).IsTrue(); + + await Assert.That((await service.OwnersAsync(game)).Single().UserId).IsEqualTo(staying); + await Assert.That(await IsClaimedAsync(db, game)).IsTrue(); + } + + /// + /// A claim id is not a credential, so resigning is scoped to the account that holds the claim. + /// + /// + /// RevokeAsync takes a claim id and nothing else, which is right for the service and wrong + /// for a caller reached from a form. Anybody who learned an id could otherwise unclaim somebody + /// else's game, and ids travel — they are in URLs, in logs and in this test. + /// + [Test] + public async Task NobodyCanResignSomebodyElsesClaim() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var owner = await UserAsync(db, "owner"); + var stranger = await UserAsync(db, "stranger"); + + var theirs = await service.IssueAsync(game, owner); + await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp); + + await Assert.That(await service.ResignAsync(theirs.Id, stranger)).IsFalse(); + await Assert.That((await service.OwnersAsync(game)).Count).IsEqualTo(1); + } + + /// The last owner leaving unclaims the game, and the badge goes with it. + [Test] + public async Task TheLastOwnerLeavingUnclaimsTheGame() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var owner = await UserAsync(db, "owner"); + + var theirs = await service.IssueAsync(game, owner); + await service.OfferBeaconAsync(game, theirs.Token, ClaimChannel.Mssp); + + await service.ResignAsync(theirs.Id, owner); + + await Assert.That(await service.OwnersAsync(game)).IsEmpty(); + await Assert.That(await IsClaimedAsync(db, game)).IsFalse(); + + // Nothing is deleted: the claim is still there, revoked, with its reason and its history. + var stored = (await new NpgsqlClaimStore(db.DataSource).ForGameAsync(game)).Single(); + await Assert.That(stored.RevokedAt).IsNotNull(); + await Assert.That((await service.HistoryAsync(theirs.Id)).Select(e => e.Kind)) + .Contains(ClaimEventKind.Revoked); + } + + /// + /// The audit log is the whole story of a claim, in order (§8.5). + /// + [Test] + public async Task TheHistoryReadsAsWhatActuallyHappened() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var owner = await UserAsync(db, "owner"); + + var claim = await service.IssueAsync(game, owner); + await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + await service.OfferBeaconAsync(game, claim.Token, ClaimChannel.Mssp); + await service.ResignAsync(claim.Id, owner); + + var kinds = (await service.HistoryAsync(claim.Id)).Select(e => e.Kind).ToList(); + + await Assert.That(kinds[0]).IsEqualTo(ClaimEventKind.Issued); + await Assert.That(kinds).Contains(ClaimEventKind.Verified); + await Assert.That(kinds).Contains(ClaimEventKind.BeaconSeen); + await Assert.That(kinds[^1]).IsEqualTo(ClaimEventKind.Revoked); + } + + /// + /// A revoked claim's token stops working, so a takeover cannot be undone by the old beacon. + /// + /// + /// The displaced operator's token is very likely still sitting in the config file — that is the + /// normal state of a handover — and every probe reads it. It must settle nothing: presence + /// establishes and absence never revokes (§8.4), but a revoked claim is not a claim. + /// + [Test] + public async Task ADisplacedOwnersTokenStillOnTheServerSettlesNothing() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var service = Service(db); + var departing = await UserAsync(db, "departing"); + var arriving = await UserAsync(db, "arriving"); + + var old = await service.IssueAsync(game, departing); + await service.OfferBeaconAsync(game, old.Token, ClaimChannel.Mssp); + + var takeover = await service.IssueAsync(game, arriving, ClaimIntent.Assume); + await service.OfferBeaconAsync(game, takeover.Token, ClaimChannel.Mssp); + + // The next probe still reads the old token off the connect screen. + var verdict = await service.OfferBeaconAsync(game, old.Token, ClaimChannel.ConnectScreen); + + await Assert.That(verdict).IsEqualTo(ClaimVerdict.Stale); + await Assert.That((await service.OwnersAsync(game)).Single().UserId).IsEqualTo(arriving); + } + + private static ClaimService Service(TestDatabase db) => + new( + new NpgsqlClaimStore(db.DataSource), + new NpgsqlGameStore(db.DataSource), + TimeProvider.System); + + private static async Task UserAsync(TestDatabase db, string name) + { + var id = Guid.CreateVersion7(); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + await connection.ExecuteAsync( + """ + INSERT INTO app_user (id, display_name, normalised_name, security_stamp, + concurrency_stamp, created_at) + VALUES (@id, @name, @normalised, @stamp, @stamp, @now) + """, + new + { + id, + name, + normalised = name.ToUpperInvariant(), + stamp = Guid.NewGuid().ToString(), + now = Now, + }); + + return id; + } + + private static async Task IsClaimedAsync(TestDatabase db, Guid game) + { + await using var connection = await db.DataSource.OpenConnectionAsync(); + + return await connection.ExecuteScalarAsync( + "SELECT is_claimed FROM game WHERE id = @game", new { game }); + } +} diff --git a/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs index 43f68c54..1b673cbf 100644 --- a/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs +++ b/tests/MUI.Catalog.Tests/Persistence/OwnershipSchemaTests.cs @@ -152,6 +152,54 @@ await connection.ExecuteAsync( } } + /// + /// Both directions of intent (spec §8.4): the schema refuses what we cannot write, and + /// accepts everything we can. + /// + /// + /// The second half matters more than it looks. A CHECK that refuses a value the code + /// really produces does not fail here — it fails the first time somebody tries to take over a + /// game, in production, at the end of a flow they have already published a token for. + /// + [Test] + public async Task TheSchemaAndTheCodeAgreeAboutWhatAClaimCanMean() + { + await using var db = await PostgresFixture.MigratedAsync(); + var game = await Seed.GameAsync(db); + var user = await AccountAsync(db, "owner"); + + await using var connection = await db.DataSource.OpenConnectionAsync(); + + // One account per intent, because a partial unique index allows an account only one pending + // claim per game — which is §8.1's own rule and not something to work around here. + foreach (var intent in Enum.GetValues()) + { + await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at) + VALUES (@id, @game, @user, @token, @intent, @now, @later) + """, + new + { + id = Guid.CreateVersion7(), + game, + user = await AccountAsync(db, $"claimant-{intent}"), + token = "muidx-" + intent.ToString().ToLowerInvariant().PadRight(20, 'a'), + intent = SqlEnums.ToDb(intent), + now = Now, + later = Now.AddDays(30), + }); + } + + await Assert.That(async () => await connection.ExecuteAsync( + """ + INSERT INTO game_claim (id, game_id, user_id, token, intent, issued_at, expires_at) + VALUES (@id, @game, @user, 'muidx-bbbbbbbbbbbbbbbbbbbb', 'seize', @now, @later) + """, + new { id = Guid.CreateVersion7(), game, user, now = Now, later = Now.AddDays(30) })) + .Throws(); + } + private static Task InsertClaimAsync(NpgsqlConnection connection, Guid game, Guid user, string token) => connection.ExecuteAsync( """ diff --git a/tests/MUI.Web.Tests/AccountSurfaceTests.cs b/tests/MUI.Web.Tests/AccountSurfaceTests.cs new file mode 100644 index 00000000..4b420b64 --- /dev/null +++ b/tests/MUI.Web.Tests/AccountSurfaceTests.cs @@ -0,0 +1,642 @@ +using System.Security.Claims; +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using MUI.Catalog; +using MUI.Catalog.Persistence; +using MUI.Web; +using MUI.Web.Accounts; +using MUI.Web.Components.Pages; +using MUI.Web.Fixtures; + +namespace MUI.Web.Tests; + +/// +/// The owner dashboard, rendered in each state an operator can actually be in. +/// +/// +/// +/// Five features' worth of markup landed in this one page across a merge chain — the owner panel, +/// the MSSP scorecard link, the badge snippet, the co-owner line, the history block, the resign +/// form and the status banner — and nothing rendered it. A component that only ever compiled is a +/// component nobody has looked at. +/// +/// +/// On authentication. The signed-out and no-database states go through +/// end to end, because nothing stands between a visitor and those. The signed-in states cannot: +/// §8.2 makes passkeys the only way in, so a loopback host has no way to produce an authenticated +/// session without an authenticator. They are rendered at component level with an +/// cascaded in, which is what the framework would have supplied — the +/// page's own guard on Identity.IsAuthenticated still runs, and the claims it filters are +/// real records in a real . What is stood in for is the user store +/// and the credential ceremony, neither of which is the authorisation under test. +/// +/// +public class AccountSurfaceTests +{ + private static readonly DateTimeOffset Now = FixtureGameQueries.Now; + + /// A game from the fixture, so the page's own lookup by id has something to find. + private static readonly Guid Ashen = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007"); + + private static readonly Guid Mush = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"); + + // ── the states an operator is in ────────────────────────────────────────── + + /// A site with no database says so, rather than offering half a claim flow. + /// + /// Through the real host: this is the state a reader of the demo site is in, and it is reached + /// by the ordinary pipeline rather than by a component rendered out of context. + /// + [Test] + public async Task WithNoDatabaseThePageSaysAccountsNeedOne() + { + await using var host = await SiteHost.StartAsync(); + + var body = Render.Words(await host.Client.GetStringAsync("/account")); + + await Assert.That(body).Contains("Accounts need a database"); + await Assert.That(body).DoesNotContain("Sign in"); + await Assert.That(body).DoesNotContain("give up this claim"); + } + + /// A signed-out visitor is offered the way in and nothing else. + [Test] + public async Task ASignedOutVisitorIsOfferedSignInAndNoWriteSurface() + { + var markup = await World.New().Anonymous().RenderAsync(); + + await Assert.That(markup).Contains("/account/sign-in"); + await Assert.That(markup).DoesNotContain("An account with nothing claimed is told where to start. + [Test] + public async Task AnAccountWithNoClaimsIsPointedAtTheListing() + { + var markup = await World.New().SignedIn().RenderAsync(); + var words = Render.Words(markup); + + await Assert.That(words).Contains("You have not claimed anything yet"); + await Assert.That(words).Contains("/games"); + await Assert.That(words).DoesNotContain("Waiting on a token"); + await Assert.That(markup).DoesNotContain("resign"); + } + + /// A pending claim is a link back to the page with the token on it. + [Test] + public async Task APendingClaimLinksToItsToken() + { + var markup = await World.New().SignedIn().Pending(Mush).RenderAsync(); + var words = Render.Words(markup); + + await Assert.That(words).Contains("Waiting on a token"); + await Assert.That(markup).Contains("/g/m-u-s-h/claim"); + + // Pending is not owning: none of the owner surfaces appear for it. + await Assert.That(words).DoesNotContain("Claimed"); + await Assert.That(markup).DoesNotContain("badge.svg"); + } + + /// + /// A verified claim brings every surface a claim grants, in one block. + /// + /// + /// The whole point of the merge chain, asserted in one place: §8.5's enrichment fields, its MSSP + /// scorecard, its owner-published badge, the audit log and §8.4's explicit resignation. Each + /// arrived on a different branch and none of them was ever rendered beside the others. + /// + [Test] + public async Task AVerifiedClaimShowsEverythingAClaimGrants() + { + var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync(); + var words = Render.Words(markup); + + await Assert.That(words).Contains("Claimed"); + await Assert.That(markup).Contains("/g/ashen-court"); + + // §8.5's enrichment, through OwnerPanel. + await Assert.That(markup).Contains($"{OwnerWrites.FieldPrefix}FANDOM"); + await Assert.That(markup).Contains($"/account/games/{Ashen}/enrichment"); + + // §11's suppression. + await Assert.That(markup).Contains($"/account/games/{Ashen}/connect-screen"); + + // §8.5's scorecard, its badge, its audit log, and §8.4's resignation. + await Assert.That(markup).Contains("/g/ashen-court/mssp"); + await Assert.That(markup).Contains("/g/ashen-court/badge.svg"); + await Assert.That(markup).Contains("/g/ashen-court/badge.json"); + await Assert.That(words).Contains("history"); + await Assert.That(markup).Contains("/resign"); + } + + /// + /// The badge snippet is the exact line to paste, and it names this game. + /// + /// + /// It is markup inside markup, so the thing to check is that it survived escaping as something a + /// person can copy rather than as rendered HTML. + /// + [Test] + public async Task TheBadgeSnippetIsCopyableRatherThanRendered() + { + var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync(); + + // Entity-encoded, quotes included. Read off the rendered bytes rather than off an + // assertion message: a failure report that HTML-decodes what it shows makes a correctly + // escaped snippet look like markup that got away. + await Assert.That(markup).Contains("<img src="/g/ashen-court/badge.svg""); + await Assert.That(markup).DoesNotContain("Several games each get their own block, and none is opened by default.
+ /// + /// An operator holds a handful of games, and the page collapses them for that reason — a page of + /// five open forms is a page nobody reads. One game is the exception and stays open. + /// + [Test] + public async Task SeveralGamesAreEachTheirOwnCollapsedBlock() + { + var one = await World.New().SignedIn().Verified(Ashen).RenderAsync(); + var two = await World.New().SignedIn().Verified(Ashen).Verified(Mush).RenderAsync(); + + await Assert.That(one).Contains("
§8.5 — a game may have several owners, and each of them should know.
+ [Test] + public async Task ACoOwnedGameNamesTheOtherOwners() + { + var markup = await World.New().SignedIn().Verified(Ashen).CoOwnedBy("thistle").RenderAsync(); + var words = Render.Words(markup); + + await Assert.That(words).Contains("Also owned by thistle"); + await Assert.That(words).Contains("verified a token of their own"); + + // The resign copy is the one that differs when somebody else holds the game too. + await Assert.That(words).Contains("the game stays claimed if anybody else owns it"); + } + + /// A sole owner sees no co-owner line at all. + [Test] + public async Task ASoleOwnerIsNotToldAboutOwnersWhoDoNotExist() + { + var words = Render.Words(await World.New().SignedIn().Verified(Ashen).RenderAsync()); + + await Assert.That(words).DoesNotContain("Also owned by"); + } + + // ── the status banner ───────────────────────────────────────────────────── + + /// + /// Each outcome reports itself, and an else-if chain reports exactly one. + /// + /// + /// This page has already told an operator the wrong thing once: hiding a connect screen came + /// back saying the page "now shows it as owner-declared", which is the enrichment sentence. The + /// chain grew a third arm afterwards, and a chain is the shape that silently reports the wrong + /// branch — so every arm is driven here, by the querystring the redirect actually carries. + /// + [Test] + [Arguments("?saved={game}&did=fields", "now shows it as owner-declared")] + [Arguments("?saved={game}&did=screen-hidden", "stopped republishing")] + [Arguments("?saved={game}&did=screen-shown", "connect screen is on its page again")] + [Arguments("?resigned=1", "Given up.")] + [Arguments("?refused=CODEBASE&because=NotEnrichable", "CODEBASE was not changed")] + public async Task EveryOutcomeReportsTheActionThatHappened(string query, string expected) + { + var words = Render.Words(await World.New() + .SignedIn() + .Verified(Ashen) + .RenderAsync(query.Replace("{game}", Ashen.ToString(), StringComparison.Ordinal))); + + await Assert.That(words).Contains(expected); + } + + /// + /// Two outcomes at once is one banner, and it is the one the chain says it is. + /// + /// + /// A redirect carries exactly one outcome, so this combination cannot arise from the endpoints — + /// which is precisely why it is worth pinning. If somebody later reorders the arms, a page that + /// silently reported the other action would look identical to one that worked. + /// + [Test] + public async Task ACraftedUrlCarryingTwoOutcomesStillReportsOnlyOne() + { + var words = Render.Words(await World.New() + .SignedIn() + .Verified(Ashen) + .RenderAsync($"?resigned=1&saved={Ashen}&did=fields&refused=CODEBASE")); + + await Assert.That(words).Contains("Given up."); + await Assert.That(words).DoesNotContain("now shows it as owner-declared"); + await Assert.That(words).DoesNotContain("was not changed"); + } + + /// An enrichment refusal names the field and says which rule refused it. + [Test] + public async Task ARefusalOverLengthSaysSoRatherThanBlamingTheField() + { + var words = Render.Words(await World.New() + .SignedIn() + .Verified(Ashen) + .RenderAsync("?refused=FANDOM&because=TooLong")); + + await Assert.That(words).Contains("FANDOM was not changed"); + await Assert.That(words).Contains($"{OwnerEnrichment.MaxValueLength} characters"); + await Assert.That(words).DoesNotContain("That field is measured"); + } + + // ── the routes the page posts to ────────────────────────────────────────── + + /// + /// Every form on this page posts to a route the site actually maps. + /// + /// + /// A form whose action nobody maps fails at the browser, not at build — it is a 404 an operator + /// meets after typing into a box, and no compiler, no renderer and no unit test would say a word + /// about it. Five features' worth of forms arrived here across a merge chain, each mapped in a + /// different file, and this is the only check that they all still line up. + /// + [Test] + public async Task EveryFormOnThePagePostsToARouteTheSiteMaps() + { + var markup = await World.New().SignedIn().Verified(Ashen).RenderAsync(); + + var actions = Regex + .Matches(markup, "]*action=\"([^\"]+)\"", RegexOptions.None, TimeSpan.FromSeconds(5)) + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToList(); + + await Assert.That(actions).IsNotEmpty(); + + var routes = MappedRoutes(); + + foreach (var action in actions) + { + await Assert.That(routes.Any(route => Fits(route, action))) + .IsTrue() + .Because($"the page posts to {action}, which no route matches"); + } + } + + /// + /// The route patterns the deployable maps, with a database configured. + /// + /// + /// Built through 's own two calls and never started: the account + /// routes exist only when a connection string does, and the string here is never dialled. + /// + private static IReadOnlyList MappedRoutes() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Production, + }); + + builder.Logging.ClearProviders(); + builder.Services.AddMuiSite(builder.Configuration, "Host=127.0.0.1;Port=1;Database=m;Username=m"); + + var app = builder.Build(); + app.UseMuiSite("Host=127.0.0.1;Port=1;Database=m;Username=m"); + + // Off the builder rather than out of the container: the composite EndpointDataSource in DI + // is empty until the app starts, so resolving it here would have compared the page against + // no routes at all and passed for the wrong reason. + return + [ + .. ((IEndpointRouteBuilder)app).DataSources + .SelectMany(source => source.Endpoints) + .OfType() + .Select(e => e.RoutePattern.RawText ?? string.Empty), + ]; + } + + /// Whether a mapped pattern would match a concrete path, segment by segment. + private static bool Fits(string pattern, string path) + { + var patternParts = pattern.Trim('/').Split('/'); + var pathParts = path.Trim('/').Split('/'); + + if (patternParts.Length != pathParts.Length) + { + return false; + } + + return patternParts.Zip(pathParts).All(pair => + pair.First.StartsWith('{') + || string.Equals(pair.First, pair.Second, StringComparison.OrdinalIgnoreCase)); + } + + private static int Matches(string haystack, string needle) => + haystack.Split(needle).Length - 1; + + /// + /// One operator, their claims, and the page rendered as they would see it. + /// + /// + /// The stores are in memory and the services over them are the real ones, so what the page + /// branches on — verified against pending, one owner against several — is decided by + /// rather than by the test. + /// + private sealed class World + { + private readonly MuiUser _user = new() { DisplayName = "corvid-admin", CreatedAt = Now }; + private readonly List _accounts = []; + private readonly List _claims = []; + + private bool _signedIn; + + public static World New() => new(); + + public World SignedIn() + { + _signedIn = true; + _accounts.Add(_user); + return this; + } + + public World Anonymous() => this; + + public World Verified(Guid game) + { + _claims.Add(Claim(game, _user.Id) with + { + ClaimedAt = Now.AddDays(-30), + BeaconLastSeenAt = Now.AddHours(-2), + VerifiedVia = ClaimChannel.Mssp, + }); + + return this; + } + + public World Pending(Guid game) + { + _claims.Add(Claim(game, _user.Id)); + return this; + } + + /// A second account that has proved control of the game claimed most recently. + public World CoOwnedBy(string name) + { + var other = new MuiUser { DisplayName = name, CreatedAt = Now }; + _accounts.Add(other); + + _claims.Add(Claim(_claims[^1].GameId, other.Id) with + { + ClaimedAt = Now.AddDays(-10), + VerifiedVia = ClaimChannel.ConnectScreen, + }); + + return this; + } + + public Task RenderAsync(string query = "") => + Render.ComponentAsync(new Dictionary(), services => + { + var fixture = new FixtureGameQueries(); + var claims = new InMemoryClaims(_claims); + var fields = new InMemoryFields(); + var games = new InMemoryGames(); + + services.AddLogging(); + services.AddSingleton(fixture); + services.AddSingleton(new Frozen(Now)); + services.AddSingleton(new At(query)); + + // What supplies [SupplyParameterFromQuery] outside the endpoint infrastructure. + // Without it every one of those properties is null and the status banner silently + // never renders — which is indistinguishable from a banner that decided not to. + services.AddSupplyValueFromQueryProvider(); + services.AddSingleton(); + + // Cascaded rather than authenticated through a scheme: this is the object the + // framework hands a page, and the page's own guard on it is what is under test. + services.AddCascadingValue(_ => Context(_signedIn ? _user : null)); + + services.AddSingleton(claims); + services.AddSingleton(fields); + services.AddSingleton(new ClaimService(claims, games, new Frozen(Now))); + services.AddSingleton(new OwnerEnrichment( + claims, fields, new FieldReconciler(fields), FieldRegistry.Instance, new Frozen(Now))); + + services.AddSingleton>(new Accounts(_accounts)); + services.AddIdentityCore(); + }); + + private static GameClaim Claim(Guid game, Guid user) => new() + { + Id = Guid.CreateVersion7(), + GameId = game, + UserId = user, + Token = "muidx-" + Guid.NewGuid().ToString("N")[..20], + IssuedAt = Now.AddDays(-40), + ExpiresAt = Now.AddDays(20), + }; + + private static HttpContext Context(MuiUser? user) + { + var context = new DefaultHttpContext(); + + if (user is not null) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new System.Security.Claims.Claim(ClaimTypes.NameIdentifier, user.Id.ToString())], + "Test")); + } + + return context; + } + } + + private sealed class Frozen(DateTimeOffset at) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => at; + } + + private sealed class At : NavigationManager + { + public At(string query) => Initialize("http://localhost/", "http://localhost/account" + query); + + protected override void NavigateToCore(string uri, bool forceLoad) + { + } + } + + private sealed class NoAntiforgery : AntiforgeryStateProvider + { + public override AntiforgeryRequestToken? GetAntiforgeryToken() => null; + } + + private sealed class InMemoryClaims(List claims) : IClaimStore + { + public Task FindAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(claims.FirstOrDefault(c => c.Id == id)); + + public Task> ForGameAsync(Guid game, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.GameId == game)]); + + public Task> ForUserAsync(Guid user, CancellationToken ct = default) => + Task.FromResult>([.. claims.Where(c => c.UserId == user)]); + + public Task FindPendingByTokenAsync(Guid game, string token, CancellationToken ct = default) => + Task.FromResult(null); + + public Task InsertAsync(GameClaim claim, CancellationToken ct = default) => Task.CompletedTask; + + public Task UpdateAsync(GameClaim claim, CancellationToken ct = default) => Task.CompletedTask; + + public Task RecordEventAsync(ClaimEvent e, CancellationToken ct = default) => Task.CompletedTask; + + public Task> EventsAsync(Guid claim, CancellationToken ct = default) => + Task.FromResult>( + [new ClaimEvent(claim, Now.AddDays(-30), ClaimEventKind.Verified, "Mssp")]); + } + + private sealed class InMemoryFields : IGameFieldStore + { + private readonly Dictionary<(Guid, string, FieldSource), GameField> _rows = []; + + public Task> ForGameAsync(Guid game, CancellationToken ct = default) => + Task.FromResult>([.. _rows.Values.Where(f => f.GameId == game)]); + + public Task> ForGameAsync( + Guid game, + FieldSource only, + CancellationToken ct = default) => + Task.FromResult>( + [.. _rows.Values.Where(f => f.GameId == game && f.Source == only)]); + + public Task UpsertAsync(GameField field, CancellationToken ct = default) + { + _rows[(field.GameId, field.Field, field.Source)] = field; + return Task.CompletedTask; + } + + public Task RecordChangeAsync(FieldChange change, CancellationToken ct = default) => Task.CompletedTask; + + public Task LastChangedAtAsync(Guid game, string field, CancellationToken ct = default) => + Task.FromResult(null); + } + + private sealed class InMemoryGames : IGameStore + { + public Task ByIdAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(null); + + public Task BySlugAsync(string slug, CancellationToken ct = default) => + Task.FromResult(null); + + public Task InsertAsync(GameRecord game, CancellationToken ct = default) => Task.CompletedTask; + + public Task SetStateAsync(Guid id, LifecycleState state, DateTimeOffset at, CancellationToken ct = default) => + Task.CompletedTask; + + public Task SetClaimedAsync(Guid id, bool claimed, CancellationToken ct = default) => Task.CompletedTask; + + public Task MarkReachableAsync(Guid id, DateTimeOffset at, CancellationToken ct = default) => + Task.CompletedTask; + + public Task> UnarchivedAsync(CancellationToken ct = default) => + Task.FromResult>([]); + + public Task RenameAsync( + Guid id, + string name, + string slug, + DateTimeOffset at, + CancellationToken ct = default) => + Task.FromResult(null); + } + + /// + /// Enough of a user store to find an account and list its passkeys. + /// + /// + /// Identity's UserManager is a concrete type over a store interface, so a page that calls + /// it needs one. This is persistence rather than authorisation: which account the page is for + /// comes from the cascaded principal, and what that account may see comes from the claim store. + /// + private sealed class Accounts(List users) : IUserStore, IUserPasskeyStore + { + public Task FindByIdAsync(string id, CancellationToken ct) => + Task.FromResult(users.FirstOrDefault(u => u.Id.ToString() == id)); + + public Task GetUserIdAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.Id.ToString()); + + public Task GetUserNameAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.DisplayName); + + public Task GetNormalizedUserNameAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(user.NormalisedName); + + public Task FindByNameAsync(string name, CancellationToken ct) => + Task.FromResult(users.FirstOrDefault(u => u.NormalisedName == name)); + + public Task SetUserNameAsync(MuiUser user, string? name, CancellationToken ct) => Task.CompletedTask; + + public Task SetNormalizedUserNameAsync(MuiUser user, string? name, CancellationToken ct) => + Task.CompletedTask; + + public Task CreateAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task UpdateAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task DeleteAsync(MuiUser user, CancellationToken ct) => + Task.FromResult(IdentityResult.Success); + + public Task AddOrUpdatePasskeyAsync(MuiUser user, UserPasskeyInfo passkey, CancellationToken ct) => + Task.CompletedTask; + + public Task> GetPasskeysAsync(MuiUser user, CancellationToken ct) => + Task.FromResult>( + [ + new UserPasskeyInfo( + [1, 2, 3], + [4, 5, 6], + Now.AddYears(-1), + 0u, + ["internal"], + true, + true, + true, + [], + []) + { + Name = "the yubikey in the drawer", + }, + ]); + + public Task FindByPasskeyIdAsync(byte[] id, CancellationToken ct) => + Task.FromResult(null); + + public Task FindPasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => + Task.FromResult(null); + + public Task RemovePasskeyAsync(MuiUser user, byte[] id, CancellationToken ct) => Task.CompletedTask; + + public void Dispose() + { + } + } +}