diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bb3c83 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/Dockerfile b/Dockerfile index 4f607e6..45072e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ -RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj +COPY Directory.Build.props . +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false + /p:UseAppHost=false /p:RestoreLockedMode=true FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime WORKDIR /app diff --git a/docs/bridge.md b/docs/bridge.md index 683d7d2..d971d0c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona replies cross back through two explicit CultMesh surfaces. Epiphany is -the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost -reads that surface, authenticates the dedicated Persona-mouth signer, journals -external execution before touching Discord, and is the sole writer of -`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but -never receives the Discord token or write access to Bifrost private state. -`unknown` is terminal for automatic transport retry: an external post may have -happened, so restart recovery must not manufacture a duplicate. Failed and -unknown outcomes remain Persona pressure for explicit resolution. +Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/docs/context.md b/docs/context.md index fdcef97..542ff8d 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol - source of deployment and infrastructure conventions - documents Yggdrasil, nginx, systemd, and local-Postgres patterns - `E:\Projects\Heimdall` - - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation - - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault + - authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult + - Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault + - remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not - `E:\Projects\VoidBot` - observes Discord, packages consensus, and runs repo Persona heartbeats - should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority @@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- GitHub as the only auth provider in v1 +- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it - GitHub issues and internal tasks both supported - app-native governance and voting - priority and reward pressure should be transparent and auditable diff --git a/docs/jurisdiction-map.md b/docs/jurisdiction-map.md index c0a2754..a251b00 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut - signed identity and capability claims - grants, consent, revocation, and capability evaluation +Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity. + Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed. ### Patron Collection Crossing @@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. | +| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | | Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. | | What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. | | How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. | @@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an - Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost. - Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost. - Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims. +- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once. - Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning. - Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact. - Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next. diff --git a/docs/oidc-issuer.md b/docs/oidc-issuer.md new file mode 100644 index 0000000..a7f59a4 --- /dev/null +++ b/docs/oidc-issuer.md @@ -0,0 +1,113 @@ +# Bifrost OIDC issuer + +Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix +Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into +Bifrost; it does not issue the identity chat sees. + +The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two +decisions at the authorize endpoint: who the chat user is, and whether they may get a session. + +## Endpoints + +The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing +slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with +that exact string. + +| Endpoint | Path | +| --- | --- | +| Discovery | `/.well-known/openid-configuration` | +| JWKS | `/.well-known/jwks` | +| Authorization | `/connect/authorize` | +| Token | `/connect/token` | +| Userinfo | `/connect/userinfo` | + +Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password, +client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes +`client_secret_basic` only. Scopes are `openid` and `profile`. + +## The one client + +Exactly one client is registered, from configuration, and seeded or updated at startup; every other application +row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the +provider before any sign-in redirect. + +## Configuration + +The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present, +it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never +echoes its value. + +| Setting | Meaning | +| --- | --- | +| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. | +| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. | +| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. | +| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. | +| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. | +| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https:///upstream/backchannel-logout/`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. | + +There are no development or ephemeral keys in the host. Key files are provisioned like the other +`[workload.secret_files]` paths. + +## Claims + +| Claim | Value | +| --- | --- | +| `sub` | `UserAccount.Id`, formatted `D` | +| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) | +| `name` | `DisplayName`, else `BifrostIdentity` | + +No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost. + +## Who may sign in + +The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads. + +- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the + full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and + the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256. +- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and + `error_description=The account is not an active GameCult member.` +- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with + `error_description=The account has no Bifrost identity.` +- Signed in and `Active`: a code is issued. +- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens. + +## Back-channel logout + +When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the +subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same +reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not +`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations, +which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin +action, sync, a database edit, a future suspend) is covered because the work is derived from membership state. + +The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it, +`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the +`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions +by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the +response body is never logged. + +Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds +to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only, +and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing +else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions. + +Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions. + +MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an +id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be +absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and +`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there. + +## MAS configuration + +Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method +`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import +from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an +account. + +## Remaining scar + +Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle +and chat does not need it to move. diff --git a/docs/research/2026-10-29-estate-tally-game-theory/README.md b/docs/research/2026-10-29-estate-tally-game-theory/README.md new file mode 100644 index 0000000..f67a9a5 --- /dev/null +++ b/docs/research/2026-10-29-estate-tally-game-theory/README.md @@ -0,0 +1,330 @@ +# Eyes: estate internal tally, game-theory probes (2026-10-29) + +Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation. +Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted"). + +## Code, outputs, model +Dir `sims/` (numpy 2.5.4, Python 3.13, `python -I + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..8e82ed4 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims. +/// +public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess); + +/// +/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer +/// at ; the audience is this app's slug. Failures name a rule and +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. +/// +public sealed class HeimdallTokenVerifier( + IHttpClientFactory httpClientFactory, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + public const string ClientName = "heimdall-jwks"; + + internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1); + + private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; + + private readonly SemaphoreSlim _fetchGate = new(1, 1); + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + var headerBytes = Decode(parts[0]); + var payloadBytes = Decode(parts[1]); + var signature = Decode(parts[2]); + + using var header = ParseJson(headerBytes, "token header is not JSON"); + var kid = ReadKeyId(header.RootElement); + var publicKey = await FindKeyAsync(kid, cancellationToken); + if (publicKey is null) + { + throw Refuse("token key is not published"); + } + + var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + if (signature.Length != Ed25519.SignatureSize || + !Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length)) + { + throw Refuse("token signature is invalid"); + } + + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + + private static byte[] Decode(string segment) + { + try + { + return WebEncoders.Base64UrlDecode(segment); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + } + + private static JsonDocument ParseJson(byte[] bytes, string rule) + { + try + { + return JsonDocument.Parse(bytes); + } + catch (JsonException) + { + throw Refuse(rule); + } + } + + private static string ReadKeyId(JsonElement header) + { + if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + var kid = StringClaim(header, "kid"); + if (string.IsNullOrEmpty(kid)) + { + throw Refuse("token has no key id"); + } + + return kid; + } + + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) + { + if (claims.ValueKind != JsonValueKind.Object) + { + throw Refuse("token payload is not an object"); + } + + if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/')) + { + throw Refuse("token issuer is not Heimdall"); + } + + if (StringClaim(claims, "aud") != options.AppSlug) + { + throw Refuse("token audience is not this app"); + } + + if (StringClaim(claims, "typ") != TokenType) + { + throw Refuse("token type is not an access token"); + } + + if (!claims.TryGetProperty("app", out var app) || + app.ValueKind != JsonValueKind.Object || + StringClaim(app, "slug") != options.AppSlug) + { + throw Refuse("token app is not this app"); + } + + var now = timeProvider.GetUtcNow(); + if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew) + { + throw Refuse("token is not yet valid"); + } + + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) + { + throw Refuse("token has expired"); + } + + var subject = StringClaim(claims, "sub"); + if (string.IsNullOrEmpty(subject) || + subject != StringClaim(claims, "account_id") || + subject != payloadAccountId) + { + throw Refuse("token subject does not match the account"); + } + + var capabilities = new List(); + if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var capability in list.EnumerateArray()) + { + if (capability.ValueKind == JsonValueKind.String) + { + capabilities.Add(capability.GetString()!); + } + } + } + + return new HeimdallVerifiedIdentity( + subject, + StringClaim(claims, "display_name"), + capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); + } + + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private bool TryFreshKey(string kid, out byte[]? key) + { + var snapshot = _keys; + key = null; + return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key); + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + return await ReadKeysAsync(cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + private async Task> ReadKeysAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient(ClientName); + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = StringClaim(jwk, "kid"); + var x = StringClaim(jwk, "x"); + if (StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + string.IsNullOrEmpty(id) || + x is null) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + + return keys; + } + + private static string? StringClaim(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTimeOffset? NumericDate(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt64(out var seconds) || + seconds is < 0 or > 253402300799) + { + return null; + } + + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}."); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs new file mode 100644 index 0000000..edf1dd3 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs @@ -0,0 +1,72 @@ +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Makes the provider's one client match configuration at startup, creating it or updating it in place, and removes +/// every other application (with its authorizations and tokens), so a client left by an older configuration cannot get codes. +/// +public sealed class OidcClientSeeder( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + // Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request. + _ = serverOptions.Value; + + await using var scope = scopeFactory.CreateAsyncScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var mas = options.Clients.Mas; + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = mas.ClientId, + ClientSecret = material.ClientSecret, + ClientType = ClientTypes.Confidential, + DisplayName = "Matrix Authentication Service", + RedirectUris = { new Uri(mas.RedirectUri, UriKind.Absolute) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Profile, + Permissions.Prefixes.Scope + Scopes.OpenId + } + }; + + var existing = await manager.FindByClientIdAsync(mas.ClientId, cancellationToken); + if (existing is null) + { + await manager.CreateAsync(descriptor, cancellationToken); + } + else + { + await manager.UpdateAsync(existing, descriptor, cancellationToken); + } + + var applications = new List(); + await foreach (var application in manager.ListAsync(cancellationToken: cancellationToken)) + { + applications.Add(application); + } + + foreach (var application in applications) + { + if (!string.Equals(await manager.GetClientIdAsync(application, cancellationToken), mas.ClientId, StringComparison.Ordinal)) + { + await manager.DeleteAsync(application, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs new file mode 100644 index 0000000..b435347 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -0,0 +1,142 @@ +using System.Security.Claims; +using Microsoft.AspNetCore; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The decisions Bifrost owns at the OIDC provider: who the chat user is (sub = UserAccount.Id, +/// preferred_username = NormalizedBifrostIdentity) and whether they may get a session (membership Active). +/// OpenIddict does the protocol; these endpoints read the cookie session and the same actor facts the /App policy reads. +/// +public static class OidcEndpoints +{ + public const string NotActiveDescription = "The account is not an active GameCult member."; + public const string NoIdentityDescription = "The account has no Bifrost identity."; + + private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; + private const string IdentityAuthenticationType = "Bifrost.Oidc"; + + public static void MapBifrostOidc(this IEndpointRouteBuilder app) + { + app.MapMethods(OidcServiceCollectionExtensions.AuthorizationPath, ["GET", "POST"], (Delegate)AuthorizeAsync).AllowAnonymous(); + app.MapPost(OidcServiceCollectionExtensions.TokenPath, (Delegate)ExchangeAsync).AllowAnonymous(); + app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); + } + + private static async Task AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors) + { + var request = http.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("The OpenIddict request could not be retrieved."); + + if (http.User.Identity?.IsAuthenticated != true) + { + // OpenIddict has already replaced the request with a short request_uri, so this URL fits a sign-in + // return path (LocalReturnPath.MaxLength); a test pins that, because Parse would otherwise fall back to /App. + return Results.Challenge( + new AuthenticationProperties { RedirectUri = http.Request.PathBase + http.Request.Path + http.Request.QueryString }, + [CookieAuthenticationDefaults.AuthenticationScheme]); + } + + var actor = await actors.GetAsync(http.RequestAborted); + if (actor.UserAccount is null || !actor.IsActiveMember) + { + return Deny(Errors.AccessDenied, NotActiveDescription); + } + + var account = actor.UserAccount; + if (string.IsNullOrWhiteSpace(account.NormalizedBifrostIdentity)) + { + return Deny(Errors.AccessDenied, NoIdentityDescription); + } + + var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); + identity.SetClaim(Claims.Subject, account.Id.ToString("D")); + identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); + identity.SetClaim( + Claims.Name, + string.IsNullOrWhiteSpace(account.DisplayName) ? account.BifrostIdentity : account.DisplayName); + + var principal = new ClaimsPrincipal(identity); + principal.SetScopes(request.GetScopes()); + principal.SetDestinations(claim => claim.Type switch + { + Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], + Claims.PreferredUsername or Claims.Name when principal.HasScope(Scopes.Profile) => + [Destinations.AccessToken, Destinations.IdentityToken], + _ => [Destinations.AccessToken] + }); + + return Results.SignIn(principal, properties: null, OpenIddictScheme); + } + + private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) + { + // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. + // The code's own principal carries the claims chosen at authorize time; membership is read again here, + // because the code outlives the authorize decision (a member suspended since gets no tokens). + var result = await http.AuthenticateAsync(OpenIddictScheme); + var subject = result.Principal?.GetClaim(Claims.Subject); + if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted)) + { + return Deny(Errors.InvalidGrant, NotActiveDescription); + } + + return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); + } + + private static async Task UserInfoAsync(HttpContext http, BifrostDbContext db) + { + var result = await http.AuthenticateAsync(OpenIddictScheme); + if (!result.Succeeded || result.Principal is null) + { + return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); + } + + // An access token outlives the authorize decision too: a member suspended since is no longer answered. + if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted)) + { + return Deny(Errors.InsufficientAccess, NotActiveDescription); + } + + var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; + if (result.Principal.HasScope(Scopes.Profile)) + { + claims[Claims.PreferredUsername] = result.Principal.GetClaim(Claims.PreferredUsername); + claims[Claims.Name] = result.Principal.GetClaim(Claims.Name); + } + + return Results.Json(claims); + } + + /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. + internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subject, out var accountId)) + { + return false; + } + + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + return new CurrentBifrostActor(true, account).IsActiveMember; + } + + private static IResult Deny(string error, string description) => + Results.Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictScheme]); +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs new file mode 100644 index 0000000..8f0be75 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Features.Oidc; + +/// A configuration fault in the OIDC provider. The message names the setting, never its value. +public sealed class OidcConfigurationException(string message) : InvalidOperationException(message); + +/// +/// The secrets the OIDC provider runs on, read once from the files names. Any +/// unreadable or wrong-shaped file stops startup; there is no fallback key. +/// +public sealed class OidcKeyMaterial : IDisposable +{ + private const string NistP256Oid = "1.2.840.10045.3.1.7"; + private const int EncryptionKeyBytes = 32; + + private readonly ECDsa _signingEcdsa; + + private OidcKeyMaterial(ECDsa signingEcdsa, SecurityKey signingKey, SecurityKey encryptionKey, string clientSecret) + { + _signingEcdsa = signingEcdsa; + SigningKey = signingKey; + EncryptionKey = encryptionKey; + ClientSecret = clientSecret; + } + + public SecurityKey SigningKey { get; } + + public SecurityKey EncryptionKey { get; } + + public string ClientSecret { get; } + + public static OidcKeyMaterial Load(OidcIssuerOptions options) + { + var mas = options.Clients.Mas; + Require(options.SigningKeyPath, "Bifrost:Oidc:SigningKeyPath"); + Require(options.EncryptionKeyPath, "Bifrost:Oidc:EncryptionKeyPath"); + Require(mas.ClientId, "Bifrost:Oidc:Clients:Mas:ClientId"); + Require(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath"); + Require(mas.RedirectUri, "Bifrost:Oidc:Clients:Mas:RedirectUri"); + // A rooted path such as "/callback" parses as an absolute file URI on Linux, so the scheme is checked too. + if (!Uri.TryCreate(mas.RedirectUri, UriKind.Absolute, out var redirect) || + redirect.Scheme is not ("https" or "http")) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); + } + + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + + var ecdsa = LoadSigningKey(options.SigningKeyPath); + try + { + var signingKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Base64UrlEncoder.Encode(SHA256.HashData(ecdsa.ExportSubjectPublicKeyInfo())) + }; + return new OidcKeyMaterial( + ecdsa, + signingKey, + new SymmetricSecurityKey(LoadEncryptionKey(options.EncryptionKeyPath)), + ReadFile(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath").Trim()); + } + catch + { + ecdsa.Dispose(); + throw; + } + } + + public void Dispose() => _signingEcdsa.Dispose(); + + private static ECDsa LoadSigningKey(string path) + { + const string field = "Bifrost:Oidc:SigningKeyPath"; + var pem = ReadFile(path, field); + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + if (ecdsa.ExportParameters(false).Curve.Oid.Value != NistP256Oid) + { + throw new CryptographicException(); + } + + // ImportFromPem accepts a public key; a signing key needs the private half. + ecdsa.ExportParameters(true); + return ecdsa; + } + catch (Exception error) when (error is CryptographicException or ArgumentException) + { + ecdsa.Dispose(); + throw new OidcConfigurationException($"{field} must name a PEM file holding an EC P-256 private key."); + } + } + + private static byte[] LoadEncryptionKey(string path) + { + const string field = "Bifrost:Oidc:EncryptionKeyPath"; + try + { + var key = Convert.FromBase64String(ReadFile(path, field).Trim()); + if (key.Length == EncryptionKeyBytes) + { + return key; + } + } + catch (FormatException) + { + } + + throw new OidcConfigurationException($"{field} must name a file holding {EncryptionKeyBytes} bytes as base64."); + } + + private static string ReadFile(string path, string field) + { + try + { + return File.ReadAllText(path); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new OidcConfigurationException($"{field} must name a readable file."); + } + } + + private static void Require(string value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new OidcConfigurationException($"{field} is required when Bifrost:Oidc is configured."); + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..ac6fa72 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -0,0 +1,220 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid +/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The +/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it. +/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the +/// pass. It never writes membership. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await ReconcileSubjectAsync(subject, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) + { + // One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it. + // The subject id and the exception type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name); + } + } + } + + private async Task> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + { + _backoff.Remove(subject); + return; + } + + // A subject that recovered is forgotten at once; one still owed a logout waits out its backoff. + _backoff.TryGetValue(subject, out var previous); + if (previous is not null && time.GetUtcNow() < previous.NotBefore) + { + return; + } + + var failures = (previous?.Failures ?? 0) + 1; + var status = await SendLogoutAsync(subject, cancellationToken); + if (status is { } code && (int)code is >= 200 and < 300) + { + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs new file mode 100644 index 0000000..5ad30b8 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +public static class OidcServiceCollectionExtensions +{ + public const string AuthorizationPath = "/connect/authorize"; + public const string TokenPath = "/connect/token"; + public const string UserInfoPath = "/connect/userinfo"; + + /// How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in. + private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10); + + /// + /// Registers Bifrost as an OIDC provider whose issuer is : authorization-code flow with + /// PKCE, ES256 id_tokens, scopes openid and profile, one configured client. Keys come from files, never from + /// generated development keys. The authorize request is parked by OpenIddict's own request caching, so the + /// sign-in return path carries only a short request_uri. + /// + public static IServiceCollection AddBifrostOidc(this IServiceCollection services, OidcIssuerOptions options) + { + var material = OidcKeyMaterial.Load(options); + services.AddSingleton(material); + services.AddSingleton(options); + + services.AddOpenIddict() + .AddCore(core => core.UseEntityFrameworkCore().UseDbContext()) + .AddServer(server => + { + server.SetAuthorizationEndpointUris(AuthorizationPath) + .SetTokenEndpointUris(TokenPath) + .SetUserInfoEndpointUris(UserInfoPath); + + server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + server.RegisterScopes(Scopes.OpenId, Scopes.Profile); + server.RegisterClaims(Claims.Subject, Claims.PreferredUsername, Claims.Name); + server.EnableAuthorizationRequestCaching(); + server.Configure(opts => + { + opts.ClientAuthenticationMethods.Clear(); + opts.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + opts.RequestTokenLifetime = ParkedRequestLifetime; + }); + + server.AddSigningKey(material.SigningKey); + server.AddEncryptionKey(material.EncryptionKey); + + server.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }); + + // The issuer is the public host, read from the bound options so it has one source. + services.AddOptions().Configure>((server, host) => + { + if (!Uri.TryCreate(host.Value.PublicBaseUrl, UriKind.Absolute, out var issuer)) + { + throw new OidcConfigurationException("Host:PublicBaseUrl is required when Bifrost:Oidc is configured."); + } + + server.Issuer = issuer; + }); + + services.AddHostedService(); + if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + // MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx. + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); + services.AddHostedService(); + } + + return services; + } +} diff --git a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs new file mode 100644 index 0000000..b6c9083 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -0,0 +1,131 @@ +namespace Bifrost.Web.Features.Shared; + +// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the +// table holds only spent windows and has no room for a key it does not know. +public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull); + +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected +// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a +// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires +// them from the front and the table never holds a key past its window. +// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still +// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a +// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is +// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait. +// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left. +// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a +// test cannot move. +public sealed class FixedWindowBudget +{ + private readonly int permits; + private readonly TimeSpan window; + private readonly int maxKeys; + private readonly TimeProvider clock; + private readonly long origin; + private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly LinkedList byStart = new(); + private readonly LinkedList byUse = new(); + private readonly object gate = new(); + + public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) + { + ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1); + this.permits = permits; + this.window = window; + this.maxKeys = maxKeys; + this.clock = clock; + origin = clock.GetTimestamp(); + } + + // How many keys are held now: the keys whose window is still open, never more than maxKeys. + public int TrackedKeys + { + get + { + lock (gate) + { + return entries.Count; + } + } + } + + // Takes one permit for the key and returns null, or returns why it could not. + public BudgetDenial? Spend(string key) + { + lock (gate) + { + var now = clock.GetElapsedTime(origin); + while (byStart.First is { Value: var oldest } && oldest.Start + window <= now) + { + Remove(oldest); + } + + if (entries.TryGetValue(key, out var entry)) + { + if (entry.Count >= permits) + { + return new BudgetDenial(entry.Start + window - now, false); + } + + entry.Count++; + if (entry.Count >= permits) + { + byUse.Remove(entry.UseNode!); + entry.UseNode = null; + } + else + { + byUse.Remove(entry.UseNode!); + byUse.AddLast(entry.UseNode!); + } + + return null; + } + + if (entries.Count >= maxKeys) + { + if (byUse.First is not { Value: var forgotten }) + { + return new BudgetDenial(byStart.First!.Value.Start + window - now, true); + } + + Remove(forgotten); + } + + var added = new Entry(key, now); + entries[key] = added; + added.StartNode = byStart.AddLast(added); + if (added.Count < permits) + { + added.UseNode = byUse.AddLast(added); + } + + return null; + } + } + + private void Remove(Entry entry) + { + entries.Remove(entry.Key); + byStart.Remove(entry.StartNode!); + if (entry.UseNode is not null) + { + byUse.Remove(entry.UseNode); + } + } + + private sealed class Entry(string key, TimeSpan start) + { + public string Key { get; } = key; + + public TimeSpan Start { get; } = start; + + public int Count { get; set; } = 1; + + public LinkedListNode? StartNode { get; set; } + + public LinkedListNode? UseNode { get; set; } + } +} diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..1de135b --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,34 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path of at most characters that starts with a single slash and holds no +/// control character, and otherwise yields . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + /// Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit. + public const int MaxLength = 256; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate.Length > MaxLength || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 0ba0e10..98b8dc7 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,4 +1,5 @@ using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -10,16 +11,45 @@ public sealed class StartupConfigurationValidator( IOptions gitHubAppOptions, IOptions heimdallOptions, IOptions stripeOptions, - IOptions hostOptions) + IOptions hostOptions, + IOptions conceptVoteOptions) { public void Validate() { - if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + var failures = new List(); + + // Checked in every environment: a bad value here would otherwise pass startup and throw on every cast. + var conceptVotes = conceptVoteOptions.Value; + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) { - return; + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); } - var failures = new List(); + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + + if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive."); + } + + if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive."); + } + + if (conceptVotes.MaxTrackedKeys < 1) + { + failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1."); + } + + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + { + Refuse(failures); + return; + } if (!gitHubOAuthOptions.Value.IsConfigured) { @@ -71,10 +101,35 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } + if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || + conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) + { + failures.Add( + $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); + } + + if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin)) + { + failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash."); + } + + Refuse(failures); + } + + private static void Refuse(List failures) + { if (failures.Count > 0) { throw new InvalidOperationException( "Bifrost startup configuration is incomplete: " + string.Join(" ", failures)); } } + + // The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port + // differs from it and is refused; user info is part of the authority, so it is refused on its own. + private static bool IsExactHttpsOrigin(string? origin) => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + uri.Scheme == Uri.UriSchemeHttps && + uri.UserInfo.Length == 0 && + string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal); } diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml new file mode 100644 index 0000000..fe75338 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -0,0 +1,56 @@ +@page "/mill/{collection}/{key}" +@model Bifrost.Web.Pages.Mill.VoteModel +@{ + ViewData["Title"] = Model.Subject.Title; + var stance = Model.Subject.YourStance; +} + +
+ Back to the Concept Mill +

@Model.Subject.Title

+ +
+
Everyone
+
@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down
+
Members
+
@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down
+
+ +

+ @if (stance is null) + { + You have not voted on this concept. + } + else + { + Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down"). + } +

+ +
+
+ +
+
+ +
+ @if (stance is not null) + { +
+ +
+ } +
+ + @if (Model.SignedIn) + { +

Your vote is kept against your Bifrost account. No cookie is set.

+ } + else + { +

+ Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can + change or retract it; it lasts a year and carries nothing else. +

+ } +
diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs new file mode 100644 index 0000000..dd87f6d --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -0,0 +1,179 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Mill; + +// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the +// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +[IgnoreAntiforgeryToken] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class VoteModel( + ICurrentBifrostActorAccessor actorAccessor, + ConceptAppraisalService appraisals, + VoterKeyResolver deviceCookie, + ConceptVoteRateLimits limits, + IOptions options, + IOptions host) : PageModel +{ + public ConceptSubjectView Subject { get; private set; } = null!; + + public bool SignedIn { get; private set; } + + public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + + // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, + // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own + // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + var method = context.HttpContext.Request.Method; + if (context.HandlerMethod is null) + { + context.Result = new StatusCodeResult( + HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method) + ? StatusCodes.Status400BadRequest + : StatusCodes.Status405MethodNotAllowed); + return; + } + + // The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run. + var named = context.HttpContext.Request.Query["handler"].ToString(); + if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name)) + { + context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest); + return; + } + + if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) + { + context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + return; + } + + await next(); + } + + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + SignedIn = voter?.UserAccountId is not null; + return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); + } + + public Task OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Up, cancellationToken); + + public Task OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Down, cancellationToken); + + public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + + await appraisals.RetractAsync(collection, key, voter, cancellationToken); + return RedirectToPage(new { collection, key }); + } + + private async Task CastAsync( + string collection, + string key, + ConceptStance stance, + CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var client = HttpContext.Connection.RemoteIpAddress; + var voter = ResolveVoter(actor); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + + if (voter is null) + { + if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled) + { + return mintThrottled; + } + + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + + var result = await appraisals.CastAsync( + collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); + return result is CastResult.UnknownSubject or CastResult.RetiredSubject + ? NotFound() + : RedirectToPage(new { collection, key }); + } + + // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that + // unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => + VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + + // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and + // repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that + // does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which + // has a budget of its own. A table full of spent windows is not the visitor's doing and says so. + private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; + + private const string MintRefusal = + "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; + + private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; + + // Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full. + private const string BusyRefusalSignIn = + "Voting is very busy right now. You can sign in to vote now, or try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter) + { + if (denial is not { } refused) + { + return null; + } + + var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal; + var refusal = refused.TableFull ? busy : spentRefusal; + Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + return new ContentResult + { + StatusCode = StatusCodes.Status429TooManyRequests, + ContentType = "text/plain; charset=utf-8", + Content = refusal + }; + } + + private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) + { + var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken); + if (view is null) + { + return false; + } + + Subject = view; + return true; + } +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 218a7ff..d0656d3 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -6,10 +6,12 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Bridge; +using Bifrost.Web.Features.ConceptVotes; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Oidc; using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; @@ -23,6 +25,23 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System.Net; +// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations. +if (args.Length > 0 && args[0] == "concepts") +{ + var host = Host.CreateApplicationBuilder([]); + host.Services.AddDbContext(options => options.UseNpgsql(BifrostConnectionString(host.Configuration))); + host.Services.AddSingleton(TimeProvider.System); + host.Services.AddScoped(); + host.Services.AddScoped(); + using var commandHost = host.Build(); + Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out); + return; +} + +static string BifrostConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("Bifrost") + ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; + var builder = WebApplication.CreateBuilder(args); builder.Services @@ -53,10 +72,15 @@ builder.Services .AddOptions() .BindConfiguration(BridgeOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(ConceptVoteOptions.SectionName); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); + options.Conventions.AllowAnonymousToFolder("/Mill"); }); builder.Services.ConfigureHttpJsonOptions(options => { @@ -65,10 +89,7 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(options => { - var connectionString = builder.Configuration.GetConnectionString("Bifrost") - ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; - - options.UseNpgsql(connectionString); + options.UseNpgsql(BifrostConnectionString(builder.Configuration)); }); builder.Services.AddHttpLogging(options => @@ -97,16 +118,20 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -199,6 +224,15 @@ builder.Services.AddAuthorization(options => }); }); +builder.Services.AddConceptTallyCors(); + +// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup. +var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration); +if (oidcOptions is not null) +{ + builder.Services.AddBifrostOidc(oidcOptions); +} + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -222,6 +256,7 @@ app.UseHttpLogging(); app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); +app.UseConceptTallyCors(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); @@ -238,6 +273,13 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).AllowAnonymous(); +app.MapConceptTallyExport(); + +if (oidcOptions is not null) +{ + app.MapBifrostOidc(); +} + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, @@ -271,22 +313,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnUrl); + await WriteSignInChooserAsync(httpContext, returnPath.Value); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); return; } @@ -298,7 +336,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { try { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } - + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -356,30 +390,18 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, + httpContext.Request.Cookies, cancellationToken); + httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); - } -}).AllowAnonymous(); - -app.MapPost("/auth/heimdall/callback", ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService) => -{ - try - { - heimdallAuthService.StoreBackendCallback(payload); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - return Results.BadRequest(error.Message); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -505,31 +527,44 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -app.MapGet("/auth/heimdall/wait", async ( +app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => +{ + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers["Referrer-Policy"] = "no-referrer"; + return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8); +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/complete", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { - var attemptId = httpContext.Request.Query["attemptId"].ToString(); - if (string.IsNullOrWhiteSpace(attemptId)) - { - return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); - } - + var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); + var attempt = form?["attempt"].ToString(); + var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); + var signIn = await heimdallAuthService.CompleteAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held?.Value, + form?["code"].ToString(), + cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - return Results.Redirect(signIn.RedirectUri); - } - catch (HeimdallAuthPendingException) - { - httpContext.Response.Headers.Append("Refresh", "2"); - return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain"); + httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(signIn.ReturnPath.Value); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + logger.LogWarning("Heimdall completion refused: {Reason}", error.Message); + if (held is not null) + { + httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + } + + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -549,6 +584,23 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken) +{ + if (!request.HasFormContentType) + { + return null; + } + + try + { + return await request.ReadFormAsync(cancellationToken); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; diff --git a/src/Bifrost.Web/appsettings.json b/src/Bifrost.Web/appsettings.json index b24dd83..47bbb7b 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,7 @@ "Heimdall": { "BaseUrl": "https://heimdall.gamecult.org", "AppSlug": "bifrost", + "AppSecret": "", "DiscordGuildId": "", "DiscordAllowedRoleIds": [], "PatreonTierTitle": "Inner Sanctum", diff --git a/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json new file mode 100644 index 0000000..bd15130 --- /dev/null +++ b/src/Bifrost.Web/packages.lock.json @@ -0,0 +1,430 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.4.0, )", + "resolved": "2.4.0", + "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "94reKYu63jg4O75UI3LMJHwOSi8tQ6IfubiZhdnSsWcgtmAuF8OyLfjK/MIxuvaQRJZAF6E747FIuxjOtb8/og==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Mono.TextTemplating": "2.2.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.3, )", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Npgsql": "8.0.0" + } + }, + "OpenIddict.EntityFrameworkCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.30", + "OpenIddict.Core": "7.7.1", + "OpenIddict.EntityFrameworkCore.Models": "7.7.1" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.3", + "contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.3", + "System.Collections.Immutable": "6.0.0", + "System.Reflection.Metadata": "6.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.5.0]", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "System.Composition": "6.0.0", + "System.IO.Pipelines": "6.0.3", + "System.Threading.Channels": "6.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.30", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.30", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.30", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.19.2" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.19.2" + } + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==", + "dependencies": { + "System.CodeDom": "4.4.0" + } + }, + "OpenIddict.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "OpenIddict.Core": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.Extensions.Options": "8.0.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore.Models": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ==" + }, + "OpenIddict.Server": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Convention": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0", + "System.Composition.TypedParts": "6.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==", + "dependencies": { + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==", + "dependencies": { + "System.Collections.Immutable": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==" + } + } + } +} \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 355d11c..1e94198 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,6 +20,11 @@ + + + + + diff --git a/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs new file mode 100644 index 0000000..3bd6f8d --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -0,0 +1,600 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptAppraisalServiceTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7"); + + [Fact] + public async Task Anonymous_device_voter_can_cast_and_is_counted() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + Assert.Equal(CastResult.Recorded, result); + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally); + } + + [Fact] + public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null)); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon")); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Theory] + [InlineData("abc\n")] + [InlineData("abc\r\n")] + [InlineData("abc ")] + [InlineData("abc\t")] + [InlineData("abc\0")] + public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged) + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up)); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context() + { + var dbName = $"concept-{Guid.NewGuid():N}"; + await using var first = new Fixture(dbName); + await first.AddSubject("ship-a"); + Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up)); + var other = IPAddress.Parse("198.51.100.9"); + + // Production scopes one DbContext per request: the recast gets a context that has never tracked the row. + await using (var second = new Fixture(dbName)) + { + second.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal( + CastResult.Changed, + await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other)); + } + + await using var reader = new Fixture(dbName); + var row = Assert.Single(reader.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc); + Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]); + } + + [Fact] + public async Task Voter_class_is_stamped_at_cast() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var rows = f.Db.ConceptAppraisals.ToList(); + Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass); + Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId); + var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id); + var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id); + Assert.Equal(VoterClass.Account, accountRow.VoterClass); + Assert.Equal(VoterClass.Member, memberRow.VoterClass); + + // Membership ends; the same account votes again with the opposite stance. The row keeps its stamp. + member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated; + var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, recast); + Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass); + } + + [Fact] + public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up)); + Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down)); + Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down)); + + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public async Task Member_weight_does_not_weight_counts() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var member = f.Account(MembershipStatus.Active, weight: 5m); + Assert.Equal(5m, member.EffectiveVotingWeight); + + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(1, tally.Up); + Assert.Equal(1, tally.Members.Up); + } + + [Fact] + public async Task Member_tally_is_a_filter_over_the_same_rows() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + var rows = f.Db.ConceptAppraisals.ToList(); + + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down); + Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally); + } + + [Fact] + public async Task Tally_carries_no_voter_identity() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal( + new[] { "Down", "Members", "Up" }, + typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + Assert.Equal( + new[] { "Down", "Up" }, + typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + + var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default)); + var view = JsonSerializer.Serialize( + await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default)); + foreach (var text in new[] { json, view }) + { + Assert.DoesNotContain("device:", text); + Assert.DoesNotContain("account:", text); + Assert.DoesNotContain("device-secret-id", text); + foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id }) + { + Assert.DoesNotContain(id.ToString("D"), text); + Assert.DoesNotContain(id.ToString("N"), text); + } + + foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct()) + { + Assert.DoesNotContain(tag, text); + } + } + } + + [Fact] + public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-old", retired: true); + var subjectsBefore = f.Db.ConceptSubjects.Count(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up)); + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up)); + + Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count()); + Assert.Empty(f.Db.ConceptAppraisals); + Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + } + + [Fact] + public async Task Retract_removes_the_row_and_the_count() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up); + + Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + + Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Network_tag_is_an_hmac_never_a_raw_address() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + var tag = f.Db.ConceptAppraisals.Single().NetworkTag; + + Assert.Matches("^[0-9a-f]{64}$", tag); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag); + } + + [Fact] + public async Task Unique_violation_at_insert_becomes_a_change() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName); + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, result); + Assert.Equal(1, racer.Fired); + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public void Unique_indexes_are_declared_in_model_and_migration() + { + var options = new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options; + using var db = new BifrostDbContext(options); + + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"])); + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"])); + + var script = db.Database.GenerateCreateScript(); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script); + + } + + [Fact] + public async Task Purge_removes_only_rows_in_tag_and_window_and_audits() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var admin = f.Admin(); + var t0 = f.Clock.GetUtcNow(); + var other = IPAddress.Parse("198.51.100.9"); + + await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other); + f.Clock.Advance(TimeSpan.FromHours(2)); + await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default); + + Assert.Equal(1, removed); + Assert.Equal( + ["device:device-late", "device:device-other-net"], + f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray()); + var audit = Assert.Single(f.Db.AuditEvents); + Assert.Equal("ConceptAppraisal", audit.EntityType); + Assert.Equal("Purge", audit.Action); + Assert.DoesNotContain(tag, audit.Detail); + Assert.DoesNotContain("tag", audit.Detail); + Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId); + Assert.Contains("from=" + t0.ToString("O"), audit.Detail); + Assert.Contains("count=1", audit.Detail); + Assert.DoesNotContain("device", audit.Detail); + + var refused = await Assert.ThrowsAsync( + () => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default)); + Assert.DoesNotContain(tag, refused.Message); + Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message); + Assert.Equal(2, f.Db.ConceptAppraisals.Count()); + Assert.Single(f.Db.AuditEvents); + } + + [Fact] + public async Task Unique_violation_with_no_surviving_row_is_not_swallowed() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName) { InsertRival = false }; + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + await Assert.ThrowsAsync( + () => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up)); + } + + [Fact] + public async Task A_changed_cast_restamps_tag_and_time() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9")); + var first = f.Db.ConceptAppraisals.Single().CastAtUtc; + f.Clock.Advance(TimeSpan.FromMinutes(5)); + + await f.Cast("ship-a", voter, ConceptStance.Down); + + var row = f.Db.ConceptAppraisals.Single(); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(first.AddMinutes(5), row.CastAtUtc); + } + + [Fact] + public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default); + + Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag); + var t0 = f.Clock.GetUtcNow(); + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default)); + Assert.Single(f.Db.ConceptAppraisals); + Assert.Empty(f.Db.AuditEvents); + } + + [Fact] + public async Task Purge_window_excludes_its_end_and_includes_its_start() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var t0 = f.Clock.GetUtcNow(); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default)); + Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default)); + } + + [Fact] + public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default)); + Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-b"); + await f.AddSubject("ship-old", retired: true); + var mine = f.Anonymous("device-one"); + await f.Cast("ship-a", mine, ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up); + + var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default); + var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default); + var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default); + + Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally); + Assert.Equal(ConceptStance.Down, view.YourStance); + Assert.Null(stranger!.YourStance); + Assert.Null(anon!.YourStance); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default)); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default)); + } + + [Fact] + public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists() + { + await using var f = new Fixture(); + await f.AddSubject("Ship_A"); + f.Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = "Bad_Collection", + Key = "ship-a", + Title = "t", + RegisteredAtUtc = f.Clock.GetUtcNow() + }); + await f.Db.SaveChangesAsync(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal( + CastResult.UnknownSubject, + await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Voter_keys_have_their_documented_shape() + { + await using var f = new Fixture(); + var account = f.Account(MembershipStatus.Authenticated); + + Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key); + Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101))); + Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100))); + } + + private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor + { + public bool InsertRival { get; init; } = true; + + public ConceptSubject? Subject { get; set; } + + public int Fired { get; private set; } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + var adding = eventData.Context!.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added); + if (Subject is null || !adding || Fired > 0) + { + return result; + } + + Fired++; + if (!InsertRival) + { + throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; + await using (var other = new BifrostDbContext(options)) + { + other.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = Subject.Id, + VoterKey = "device:device-one", + Stance = ConceptStance.Up, + VoterClass = VoterClass.Anonymous, + CastAtUtc = DateTimeOffset.UtcNow + }); + await other.SaveChangesAsync(cancellationToken); + } + + throw new DbUpdateException( + "conflict", + new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + } + + private sealed class Fixture : IAsyncDisposable + { + public const string Secret = "test-tag-secret"; + + public Fixture(string? dbName = null, IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}"); + if (interceptor is not null) + { + builder.AddInterceptors(interceptor); + } + + Db = new BifrostDbContext(builder.Options); + Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + Service = new ConceptAppraisalService( + Db, + new AuditTrailService(Db, Clock), + Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret })); + } + + public BifrostDbContext Db { get; } + + public ManualClock Clock { get; } + + public ConceptAppraisalService Service { get; } + + public async Task AddSubject(string key, bool retired = false) + { + Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = Collection, + Key = key, + Title = key, + RegisteredAtUtc = Clock.GetUtcNow(), + RetiredAtUtc = retired ? Clock.GetUtcNow() : null + }); + await Db.SaveChangesAsync(); + } + + public Task Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) => + Service.CastAsync(Collection, key, voter, stance, client ?? Client, default); + + public VoterIdentity Anonymous(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false) + { + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = Clock.GetUtcNow(), + LastSeenAtUtc = Clock.GetUtcNow() + }; + user.Membership = new Membership + { + UserAccountId = user.Id, + Status = status, + IsPlatformAdmin = admin, + CreatedAtUtc = Clock.GetUtcNow() + }; + if (weight > 0m) + { + user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true }); + } + + Db.UserAccounts.Add(user); + Db.SaveChanges(); + return new CurrentBifrostActor(true, user); + } + + public CurrentBifrostActor Admin() + { + return Account(MembershipStatus.Active, admin: true); + } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs new file mode 100644 index 0000000..c942457 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -0,0 +1,673 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptSubjectRegistryTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly string[] None = []; + + private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + + private static IReadOnlyList Parse(string json) => + ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json)); + + private static IReadOnlyList Fixture() => + ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath)); + + [Fact] + public async Task Rerun_of_the_same_manifest_is_a_no_op() + { + using var world = new World(); + + var first = await world.Apply(Fixture()); + Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject)))); + + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + var second = await world.Apply(Fixture()); + + Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)), + after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc))); + } + + [Fact] + public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan() + { + using var world = new World(); + + var plan = await world.Plan(Fixture()); + Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + await world.Apply(Fixture()); + + var events = await world.Fresh(db => db.AuditEvents.ToListAsync()); + var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet(); + Assert.Equal(3, events.Count); + Assert.All(events, e => + { + Assert.Equal(nameof(ConceptSubject), e.EntityType); + Assert.Equal("Add", e.Action); + Assert.Null(e.ActorUserAccountId); + Assert.Contains("actor=host-command", e.Detail); + Assert.Contains(e.EntityId, subjectIds); + }); + } + + [Fact] + public async Task Rename_changes_title_and_keeps_votes() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("bison", ConceptStance.Up); + await world.Cast("kestrel", ConceptStance.Down); + + var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList(); + var plan = await world.Apply(renamed); + + Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change); + Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged)); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison")); + Assert.Equal("Bison Prime", subject.Title); + Assert.Null(subject.RetiredAtUtc); + Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default)); + Assert.Equal("Bison Prime", view!.Title); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename"))); + } + + [Fact] + public async Task Absent_key_is_retired_only_when_named() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + + var unnamed = await world.Apply(smaller); + Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + + var named = await world.Apply(smaller, ["kestrel"]); + Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change); + var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.NotNull(retired.RetiredAtUtc); + Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down)); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Theory] + [InlineData("bison")] + [InlineData("never-registered")] + public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named) + { + using var world = new World(); + await world.Apply(Fixture()); + var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList(); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(renamed, [named])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.DoesNotContain(named, refusal.Message); + Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + + var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change)); + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + var audits = await world.Fresh(db => db.AuditEvents.CountAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + + var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal( + [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], + second.Entries.Select(e => (e.Key, e.Change))); + Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc))); + } + + [Fact] + public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + await world.Apply(smaller, ["kestrel"]); + + var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change); + var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync()); + Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order()); + Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Fact] + public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(Fixture(), ["kestrel"])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Re_registering_a_retired_key_revives_it() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var plan = await world.Apply(Fixture()); + + Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.Null(subject.RetiredAtUtc); + var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default)); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive"))); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")] + [InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")] + [InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")] + [InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] + [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("[]", "ships", "Missing")] + [InlineData("""{"ships":5}""", "ships", "Missing")] + [InlineData("not json", "manifest", "NotJson")] + public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) + { + using var world = new World(); + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Equal($"{field}: {code}", refusal.Message); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused() + { + var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); + + Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + var refusal = Assert.Throws(() => + Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); + } + + private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + [Theory] + [InlineData("\u202Eevil")] + [InlineData("a\u202Ab")] + [InlineData("a\u2066b")] + [InlineData("a\u2069b")] + [InlineData("a\u061Cb")] + [InlineData("a\u200Eb")] + [InlineData("a\u200Fb")] + [InlineData("a\u2028b")] + [InlineData("a\u2029b")] + [InlineData("a\u200Bb")] + [InlineData("a\u200Cb")] + [InlineData("a\u200Db")] + [InlineData("a\u2060b")] + [InlineData("a\uFEFFb")] + [InlineData("a\u0085b")] + [InlineData("a\tb")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData("\u00A0Ibis")] + [InlineData("Ibis\u00A0")] + [InlineData("\u3000Ibis")] + [InlineData("!!!")] + public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code)); + } + + [Theory] + [InlineData("Ibis Mk II")] + [InlineData("Ünïcode Ship")] + [InlineData("Ship ★ 7")] + [InlineData("7")] + [InlineData("Kestrel-2: the \"Bold\"")] + public void An_ordinary_title_is_accepted(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + Assert.Equal(title, Parse(json).Single().Title); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")] + [InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")] + [InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")] + public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + } + + [Fact] + public void Malformed_utf8_anywhere_in_the_file_is_refused() + { + byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8]; + byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8]; + + foreach (var bytes in new[] { inName, inUnreadValue }) + { + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(bytes)); + + Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code)); + } + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")] + [InlineData("""{"ships":[],"ships":[]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")] + public void A_duplicate_json_property_is_refused_wherever_it_sits(string json) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate() + { + var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}"""); + + Assert.Equal(["a", "b"], ships.Select(s => s.Key)); + } + + [Fact] + public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused() + { + const string head = """{"ships":[{"id":"a","name":"A"}]}"""; + var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length)); + + Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); + Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray())); + Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused() + { + static string Fleet(int count) => + """{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}"; + + Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count); + var refusal = Assert.Throws(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1))); + Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code)); + } + + [Fact] + public async Task Absent_keys_are_planned_in_key_order() + { + using var world = new World(); + await world.Apply(Fixture()); + + var plan = await world.Plan([new ManifestShip("zzz-new", "New")]); + + Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key)); + } + + [Fact] + public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() + { + using var world = new World(); + var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; + + Assert.Throws(() => Parse(json)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task Refusal_never_echoes_the_offending_value() + { + const string canary = "CANARY-secret/value"; + var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; + + var refusal = Assert.Throws(() => Parse(json)); + Assert.DoesNotContain("CANARY", refusal.Message); + using var world = new World(); + var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); + Assert.DoesNotContain("CANARY", collection.Message); + Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code)); + var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); + Assert.DoesNotContain("CANARY", retire.Message); + Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code)); + } + + [Theory] + [InlineData("ship-a\n")] + [InlineData("ship-a\r\n")] + [InlineData("ship-a ")] + public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged) + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default); + + Assert.False(ConceptKey.IsValid(forged)); + Assert.False(ConceptKey.IsValid("key\n")); + Assert.True(ConceptKey.IsValid("ship-a")); + Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up)); + Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default))); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default)); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default)); + Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync())); + } + + [Fact] + public async Task Host_command_plans_and_applies_with_exit_codes() + { + using var world = new World(); + var services = world.Services(); + + var plan = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan)); + Assert.Contains("add bison", plan.ToString()); + Assert.Contains("add ibis-mk2", plan.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + var apply = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply)); + Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync())); + + var again = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again)); + Assert.Contains("unchanged bison", again.ToString()); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Host_command_retire_flag_retires_the_named_absent_key() + { + using var world = new World(); + var services = world.Services(); + await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter()); + var smaller = Path.Combine(world.Dir, "two.json"); + await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}"""); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output)); + + Assert.Contains("retire kestrel", output.ToString()); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input() + { + using var world = new World(); + var services = world.Services(); + var bad = Path.Combine(world.Dir, "bad.json"); + await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}"""); + + var refused = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused)); + Assert.Contains("ships[0].id", refused.ToString()); + Assert.DoesNotContain("CANARY", refused.ToString()); + + var missing = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); + Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Equal("refused manifest Unreadable", missing.ToString().Trim()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Theory] + [InlineData("frobnicate", "c", "m")] + [InlineData("plan", "c")] + [InlineData("apply", "c", "m", "--bogus", "x")] + [InlineData("apply", "c", "m", "--retire")] + public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output)); + Assert.StartsWith("usage:", output.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); + } + + [Theory] + [InlineData("")] + [InlineData("CANARY\0path.json")] + public async Task Host_command_refuses_an_empty_or_nul_path(string path) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest Unreadable", output.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_directory_and_an_oversized_file() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "big.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + + var dir = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir)); + Assert.Equal("refused manifest Unreadable", dir.ToString().Trim()); + var large = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large)); + Assert.Equal("refused manifest TooLarge", large.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text() + { + using var world = new World(); + var path = Path.Combine(world.Dir, "latin1.json"); + await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest NotUtf8", output.ToString().Trim()); + } + + [Fact] + public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "CANARYBIG.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}"""); + var dup = Path.Combine(world.Dir, "CANARYDUP.json"); + await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); + var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); + await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}"""); + var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json"); + await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]); + + var cases = new (string[] Args, int Code)[] + { + (["plan", Collection, ""], ConceptCommand.Refused), + (["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused), + (["plan", Collection, world.Dir], ConceptCommand.Refused), + (["plan", Collection, big], ConceptCommand.Refused), + (["plan", Collection, bidi], ConceptCommand.Refused), + (["apply", Collection, dup], ConceptCommand.Refused), + (["apply", Collection, surrogate], ConceptCommand.Refused), + (["plan", Collection, latin1], ConceptCommand.Refused), + (["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused), + (["frob", "CANARYARG", "x"], ConceptCommand.Usage), + (["plan"], ConceptCommand.Usage), + }; + foreach (var (args, code) in cases) + { + var result = await RunBuiltCommandAsync(args); + + Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}"); + Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr); + Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr); + } + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args) + { + var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll"); + Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly"); + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var part in new[] { dll, "concepts" }.Concat(args)) + { + start.ArgumentList.Add(part); + } + + // Every case refuses before the database is touched; the password is a canary for leaks. + start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW"; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await stdout, await stderr); + } + + private sealed class World : IDisposable + { + private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; + + public World() + { + Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Dir); + } + + public string Dir { get; } + + public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + + public BifrostDbContext NewDb() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(_dbName).Options); + + public ConceptSubjectRegistry Registry(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Appraisals(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" })); + + public async Task Apply(IReadOnlyList ships, string[]? retire = null) + { + await using var db = NewDb(); + return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default); + } + + public async Task Plan(IReadOnlyList ships) + { + await using var db = NewDb(); + return await Registry(db).PlanAsync(Collection, ships, None, default); + } + + public async Task Cast(string key, ConceptStance stance) + { + await using var db = NewDb(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!; + return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); + } + + public async Task Fresh(Func> read) + { + await using var db = NewDb(); + return await read(db); + } + + public IServiceProvider Services() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(_dbName)); + services.AddSingleton(Clock); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + + public void Dispose() => Directory.Delete(Dir, true); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs new file mode 100644 index 0000000..bd6a72f --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -0,0 +1,383 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptTallyExportTests : IClassFixture +{ + private const string AllowedOrigin = "https://aetheria.gamecult.org"; + private const string Client = "203.0.113.7"; + + private readonly ExportFactory _factory; + + public ConceptTallyExportTests(ExportFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_request_gets_the_public_counts() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "beta"); + await AddSubject(collection, "untouched"); + await Cast(collection, "alpha", DeviceVoter("dev-one"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-two"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-three"), ConceptStance.Down); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Active), ConceptStance.Up); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Authenticated), ConceptStance.Down); + await Cast(collection, "beta", DeviceVoter("dev-one"), ConceptStance.Down); + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha", "beta", "untouched"], body.EnumerateObject().Select(p => p.Name).Order().ToArray()); + AssertCounts(body.GetProperty("alpha"), up: 3, down: 2, memberUp: 1, memberDown: 0); + AssertCounts(body.GetProperty("beta"), up: 0, down: 1, memberUp: 0, memberDown: 0); + AssertCounts(body.GetProperty("untouched"), up: 0, down: 0, memberUp: 0, memberDown: 0); + } + + [Fact] + public async Task Payload_carries_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var account = await AccountVoter(MembershipStatus.Authenticated); + var member = await AccountVoter(MembershipStatus.Active); + await Cast(collection, "alpha", DeviceVoter("canary-device-id-9c2e"), ConceptStance.Up); + await Cast(collection, "alpha", account, ConceptStance.Down); + await Cast(collection, "alpha", member, ConceptStance.Up); + + using var response = await Get(collection); + + var text = await response.Content.ReadAsStringAsync(); + var alpha = JsonDocument.Parse(text).RootElement.GetProperty("alpha"); + Assert.Equal(["down", "members", "up"], alpha.EnumerateObject().Select(p => p.Name).Order().ToArray()); + Assert.Equal(["down", "up"], alpha.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray()); + + string[] tags; + using (var scope = _factory.Services.CreateScope()) + { + tags = await scope.ServiceProvider.GetRequiredService() + .ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.All(tags, tag => Assert.NotEmpty(tag)); + var forbidden = new List { "canary-device-id-9c2e", "device:", "account:", "voterkey", "networktag", "useraccount" }; + forbidden.AddRange(tags); + foreach (var voter in new[] { account, member }) + { + forbidden.Add(voter.UserAccountId!.Value.ToString("N")); + forbidden.Add(voter.UserAccountId!.Value.ToString("D")); + } + + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cors_allows_only_the_configured_origin_without_credentials() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var allowed = await Get(collection, AllowedOrigin); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials")); + + using var otherHost = await Get(collection, "https://evil.example"); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightGet = await Preflight(collection, AllowedOrigin, "GET"); + Assert.Equal([AllowedOrigin], preflightGet.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(preflightGet.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightPost = await Preflight(collection, AllowedOrigin, "POST"); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Methods")); + } + + [Theory] + [InlineData("https://aetheria.gamecult.org.evil.example")] + [InlineData("https://aetheria.gamecult.orgx")] + [InlineData("https://evilaetheria.gamecult.org")] + [InlineData("https://www.aetheria.gamecult.org")] + [InlineData("HTTPS://AETHERIA.GAMECULT.ORG")] + [InlineData("https://Aetheria.gamecult.org")] + [InlineData("https://aetheria.gamecult.org/")] + [InlineData("https://aetheria.gamecult.org:8443")] + [InlineData("http://aetheria.gamecult.org")] + [InlineData("null")] + [InlineData("*")] + [InlineData("")] + public async Task Cors_refuses_origins_that_only_resemble_the_configured_one(string origin) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection, origin); + using var preflight = await Preflight(collection, origin, "GET"); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflight.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Every_export_response_varies_by_origin() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var allowed = await Get(collection, AllowedOrigin); + using var lookalike = await Get(collection, "https://aetheria.gamecult.org.evil.example"); + using var noOrigin = await Get(collection); + using var unknownAllowed = await Get(NewCollection(), AllowedOrigin); + using var unknownNoOrigin = await Get(NewCollection()); + using var preflightAllowed = await Preflight(collection, AllowedOrigin, "GET"); + using var preflightLookalike = await Preflight(collection, "https://evil.example", "GET"); + using var preflightPost = await Preflight(collection, AllowedOrigin, "POST"); + + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal(HttpStatusCode.OK, lookalike.StatusCode); + Assert.Equal(HttpStatusCode.OK, noOrigin.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, unknownAllowed.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, unknownNoOrigin.StatusCode); + foreach (var response in new[] { allowed, lookalike, noOrigin, unknownAllowed, unknownNoOrigin, preflightAllowed, preflightLookalike, preflightPost }) + { + Assert.Contains("Origin", VaryTokens(response)); + } + } + + [Fact] + public async Task Origin_is_added_to_an_existing_vary() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.Add("X-Test-Vary", "Accept-Encoding"); + request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin); + + using var response = await client.SendAsync(request); + + Assert.Equal(["Accept-Encoding", "Origin"], VaryTokens(response).Order().ToArray()); + } + + private static string[] VaryTokens(HttpResponseMessage response) => + response.Headers.Vary.ToArray(); + + [Fact] + public async Task Other_routes_carry_no_cors_headers() + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz"); + request.Headers.Add("Origin", AllowedOrigin); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Retired_subject_leaves_the_export() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Cast(collection, "gone", DeviceVoter("dev-one"), ConceptStance.Up); + await Retire(collection, "gone"); + + using var response = await Get(collection); + + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha"], body.EnumerateObject().Select(p => p.Name).ToArray()); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.Where(x => x.Subject!.Key == "gone" && x.Subject.Collection == collection)); + } + + [Fact] + public async Task Cache_control_is_public_sixty_seconds() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection); + + var cache = response.Headers.CacheControl; + Assert.NotNull(cache); + Assert.True(cache!.Public); + Assert.Equal(TimeSpan.FromSeconds(60), cache.MaxAge); + } + + [Fact] + public async Task Unknown_collection_is_not_found() + { + using var response = await Get(NewCollection()); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task The_export_only_reads() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + int rowsBefore, auditBefore; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + rowsBefore = db.ConceptAppraisals.Count(); + auditBefore = db.AuditEvents.Count(); + } + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + using var after = _factory.Services.CreateScope(); + var dbAfter = after.ServiceProvider.GetRequiredService(); + Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(auditBefore, dbAfter.AuditEvents.Count()); + } + + private async Task Get(string collection, string? origin = null) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await client.SendAsync(request); + } + + private async Task Preflight(string collection, string origin, string method) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Options, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.TryAddWithoutValidation("Origin", origin); + request.Headers.Add("Access-Control-Request-Method", method); + return await client.SendAsync(request); + } + + private static void AssertCounts(JsonElement tally, int up, int down, int memberUp, int memberDown) + { + Assert.Equal(up, tally.GetProperty("up").GetInt32()); + Assert.Equal(down, tally.GetProperty("down").GetInt32()); + Assert.Equal(memberUp, tally.GetProperty("members").GetProperty("up").GetInt32()); + Assert.Equal(memberDown, tally.GetProperty("members").GetProperty("down").GetInt32()); + } + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private static VoterIdentity DeviceVoter(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + private async Task AccountVoter(MembershipStatus status) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = now, + LastSeenAtUtc = now, + Membership = new Membership { Status = status, CreatedAtUtc = now } + }; + user.Membership.UserAccountId = user.Id; + db.UserAccounts.Add(user); + await db.SaveChangesAsync(); + return VoterIdentity.For(new CurrentBifrostActor(true, user), null)!; + } + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance) + { + using var scope = _factory.Services.CreateScope(); + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default); + Assert.Equal(CastResult.Recorded, result); + } + + // Stands in for any earlier middleware that already set a Vary: a request header names the value to preset. + private sealed class PriorVaryFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + if (context.Request.Headers.TryGetValue("X-Test-Vary", out var prior)) + { + context.Response.Headers.Vary = prior; + } + + return nextMiddleware(context); + }); + next(app); + }; + } + + public sealed class ExportFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => services.AddTransient()); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin, + ["ConceptVotes:NetworkTagSecret"] = "export-test-tag-secret-0123456789-abcdef" + })); + } + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs new file mode 100644 index 0000000..5434e8d --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -0,0 +1,673 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +public sealed partial class ConceptVotePageTests : IClassFixture +{ + private const string CookieName = "bifrost_voter"; + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly TestWebApplicationFactory _factory; + + public ConceptVotePageTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_visitor_can_cast_change_and_retract() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + var setCookie = SetCookieHeader(cast, CookieName); + Assert.NotNull(setCookie); + var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/mill", attributes); + Assert.Contains("max-age=31536000", attributes); + + using (var page = await visitor.Get(collection, "alpha")) + { + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("0 up, 0 down", members); + } + + var firstRow = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Up, firstRow.Stance); + Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass); + + // The cookie from the first cast is the voter: casting again changes the row and mints nothing. + using var change = await visitor.Post(collection, "alpha", "Down"); + Assert.Equal(HttpStatusCode.Redirect, change.StatusCode); + Assert.Null(SetCookieHeader(change, CookieName)); + var changed = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Down, changed.Stance); + Assert.Equal(firstRow.VoterKey, changed.VoterKey); + + using var retract = await visitor.Post(collection, "alpha", "Retract"); + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Get_never_sets_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var first = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Null(SetCookieHeader(first, CookieName)); + } + + using (var retract = await visitor.Post(collection, "alpha", "Retract")) + { + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + } + + Assert.False(visitor.Jar.ContainsKey(CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + using var later = await visitor.Get(collection, "alpha"); + Assert.Null(SetCookieHeader(later, CookieName)); + } + + [Fact] + public async Task Signed_in_vote_is_keyed_to_the_account() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: false); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.Null(SetCookieHeader(cast, CookieName)); + var row = Assert.Single(await Rows(collection, "alpha")); + Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal); + Assert.Equal(VoterClass.Member, row.VoterClass); + Assert.NotNull(row.UserAccountId); + + using var page = await visitor.Get(collection, "alpha"); + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Null(SetCookieHeader(page, CookieName)); + } + + private const string SiteOrigin = "https://localhost"; + + [Fact] + public async Task Cast_from_the_sites_own_origin_is_accepted_and_writes_one_row() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: SiteOrigin); + Assert.Equal(HttpStatusCode.Redirect, byOrigin.StatusCode); + Assert.Single(await Rows(collection, "alpha")); + + using var byFetchSite = await visitor.Post(collection, "alpha", "Down", origin: null, fetchSite: "same-origin"); + Assert.Equal(HttpStatusCode.Redirect, byFetchSite.StatusCode); + Assert.Equal(ConceptStance.Down, Assert.Single(await Rows(collection, "alpha")).Stance); + } + + [Theory] + [InlineData("https://localhost.evil.example")] + [InlineData("https://evil-localhost")] + [InlineData("https://sub.localhost")] + [InlineData("https://LOCALHOST")] + [InlineData("HTTPS://localhost")] + [InlineData("https://localhost:8443")] + [InlineData("http://localhost")] + [InlineData("null")] + [InlineData("https://localhost/")] + [InlineData("https://evil.example")] + public async Task Cast_from_any_other_origin_is_refused_even_when_fetch_site_says_same_origin(string origin) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var response = await visitor.Post(collection, "alpha", handler, origin: origin, fetchSite: "same-origin"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(SetCookieHeader(response, CookieName)); + Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Cast_with_the_origin_header_repeated_is_refused_even_when_every_value_is_the_sites() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") + { + Content = new FormUrlEncodedContent([]) + }; + request.Headers.TryAddWithoutValidation("Origin", [SiteOrigin, SiteOrigin]); + + using var response = await visitor.Send(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData(null)] + [InlineData("same-origin")] + public async Task Cast_with_an_empty_origin_header_is_refused_whatever_fetch_site_says(string? fetchSite) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + // HttpClient drops a header whose value is empty, so the request is built on the server itself, where an + // Origin header with an empty value is present in the dictionary as a browser's would be. + var context = await _factory.Server.SendAsync(http => + { + http.Request.Method = HttpMethods.Post; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("localhost"); + http.Request.Path = $"/mill/{collection}/alpha"; + http.Request.QueryString = new QueryString("?handler=Up"); + http.Request.ContentType = "application/x-www-form-urlencoded"; + http.Request.Headers["X-Test-Anonymous"] = "true"; + http.Request.Headers["Origin"] = string.Empty; + if (fetchSite is not null) + { + http.Request.Headers["Sec-Fetch-Site"] = fetchSite; + } + }); + + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey("Set-Cookie")); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData("same-origin", true)] + [InlineData("same-site", false)] + [InlineData("cross-site", false)] + [InlineData("none", false)] + [InlineData("Same-Origin", false)] + [InlineData("same-origin, cross-site", false)] + [InlineData(null, false)] + public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite); + + Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count); + } + + [Fact] + public async Task Refusal_never_echoes_the_origin_or_fetch_site_values() + { + const string canary = "https://CANARY-origin-5e1c.example"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary); + using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c"); + + foreach (var response in new[] { byOrigin, byFetchSite }) + { + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var seen = await response.Content.ReadAsStringAsync() + + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase); + } + + Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var view = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, view.StatusCode); + Assert.False(view.Headers.Contains("Set-Cookie")); + var html = await view.Content.ReadAsStringAsync(); + Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal); + } + + using var cast = await visitor.Post(collection, "alpha", "Up"); + var cookies = cast.Headers.GetValues("Set-Cookie").ToArray(); + Assert.Single(cookies); + Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal); + + using var later = await visitor.Get(collection, "alpha"); + Assert.False(later.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noHandler = await visitor.Post(collection, "alpha", handler: null); + using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9"); + using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put); + using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete); + using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9")); + using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up")); + + Assert.Equal(HttpStatusCode.BadRequest, noHandler.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, bogus.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, put.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, getBogus.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, getUp.StatusCode); + foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp }) + { + var status = (int)response.StatusCode; + Assert.InRange(status, 400, 499); + Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.False(response.Headers.Contains("Set-Cookie")); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + public enum Forgery + { + OneCharacterChanged, + BareDeviceId, + SameKeyRingOtherPurpose, + OtherKeyRingSamePurpose + } + + [Theory] + [InlineData(Forgery.OneCharacterChanged)] + [InlineData(Forgery.BareDeviceId)] + [InlineData(Forgery.SameKeyRingOtherPurpose)] + [InlineData(Forgery.OtherKeyRingSamePurpose)] + public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var victim = NewVisitor(anonymous: true); + using (await victim.Post(collection, "alpha", "Up")) + { + } + + var genuine = victim.Jar[CookieName]; + var victimRow = Assert.Single(await Rows(collection, "alpha")); + var deviceId = victimRow.VoterKey["device:".Length..]; + var forged = Forge(kind, genuine, deviceId); + Assert.NotEqual(genuine, forged); + if (kind is not Forgery.BareDeviceId) + { + Assert.Equal(genuine.Length, forged.Length); + Assert.Matches("^[A-Za-z0-9_-]+$", forged); + } + + var attacker = NewVisitor(anonymous: true); + attacker.Jar[CookieName] = forged; + using var cast = await attacker.Post(collection, "alpha", "Down"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + Assert.NotEqual(forged, attacker.Jar[CookieName]); + Assert.NotEqual(genuine, attacker.Jar[CookieName]); + var rows = await Rows(collection, "alpha"); + Assert.Equal(2, rows.Count); + var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey); + Assert.Equal(ConceptStance.Up, victimNow.Stance); + var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey); + Assert.Equal(ConceptStance.Down, attackerRow.Stance); + Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass); + } + + [Fact] + public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line() + { + const string canary = "CANARY-forged-voter-cookie-7f3a"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + visitor.Jar[CookieName] = canary; + + using var page = await visitor.Get(collection, "alpha"); + using var cast = await visitor.Post(collection, "alpha", "Up"); + + foreach (var response in new[] { page, cast }) + { + Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.DoesNotContain( + canary, + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))), + StringComparison.Ordinal); + } + + Assert.NotEmpty(logs.Lines); + Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal)); + } + + [Fact] + public async Task Unknown_or_retired_key_is_404() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Retire(collection, "gone"); + var visitor = NewVisitor(anonymous: true); + + foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") }) + { + using var get = await visitor.Get(c, k); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var post = await visitor.Post(c, k, handler); + Assert.Equal(HttpStatusCode.NotFound, post.StatusCode); + Assert.Null(SetCookieHeader(post, CookieName)); + } + } + + Assert.Empty(await Rows(collection, "gone")); + Assert.Empty(await Rows(collection, "alpha")); + Assert.False(visitor.Jar.ContainsKey(CookieName)); + } + + [Fact] + public async Task Page_shows_counts_by_class_and_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var memberId = Guid.NewGuid(); + var accountId = Guid.NewGuid(); + await AddRows(collection, "alpha", + Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"), + Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"), + Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"), + Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd")); + var visitor = NewVisitor(anonymous: true); + using (await visitor.Post(collection, "alpha", "Up")) + { + } + + using var page = await visitor.Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + + var (everyone, members) = Counts(html); + Assert.Equal("3 up, 2 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Contains("Your vote: up.", html, StringComparison.Ordinal); + + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary")); + var forbidden = new List + { + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, + visitor.Jar[CookieName], + memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") + }; + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase); + } + + Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true }); + + var signedIn = NewVisitor(anonymous: false); + using var signedInPage = await signedIn.Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + using var signedInCast = await signedIn.Post(collection, "alpha", "Down"); + var accountRow = Assert.Single( + await Rows(collection, "alpha"), + x => x.UserAccountId is { } id && id != memberId && id != accountId); + using var afterCast = await signedIn.Get(collection, "alpha"); + var afterHtml = await afterCast.Content.ReadAsStringAsync(); + Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal); + foreach (var text in new[] { signedInHtml, afterHtml }) + { + Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await anonymousPage.Content.ReadAsStringAsync(); + using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + + Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal); + Assert.Contains("lasts a year", html, StringComparison.Ordinal); + Assert.Contains("carries nothing else", html, StringComparison.Ordinal); + Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal); + Assert.Contains( + "href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"", + html, + StringComparison.Ordinal); + } + + [Fact] + public async Task Page_loads_no_script_and_posts_with_plain_forms() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var page = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + var main = html[html.IndexOf("", StringComparison.Ordinal)]; + + Assert.DoesNotContain(" kind switch + { + Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)] + + (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A') + + genuine[(genuine.Length / 2 + 1)..], + Forgery.BareDeviceId => deviceId, + Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService() + .CreateProtector(Purpose + ".other").Protect(deviceId), + Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider() + .CreateProtector(Purpose).Protect(deviceId), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private Visitor NewVisitor(bool anonymous, WebApplicationFactory? factory = null) => + new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }), anonymous); + + private static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + private static (string Everyone, string Members) Counts(string html) + { + var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray(); + Assert.Equal(2, cells.Length); + return (cells[0], cells[1]); + } + + [GeneratedRegex("
(.*?)
", RegexOptions.Singleline)] + private static partial Regex DefinitionCell(); + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) => + new() + { + VoterKey = key, + VoterClass = voterClass, + Stance = stance, + UserAccountId = accountId, + NetworkTag = tag, + CastAtUtc = DateTimeOffset.UtcNow + }; + + private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + foreach (var row in rows) + { + row.SubjectId = subject.Id; + db.ConceptAppraisals.Add(row); + } + + await db.SaveChangesAsync(); + } + + // A fresh DbContext per read: what a later request would see, not what this test's tracker remembers. + private async Task> Rows(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection && x.Subject.Key == key) + .ToListAsync(); + } + + private sealed class Visitor(HttpClient client, bool anonymous) + { + public Dictionary Jar { get; } = new(StringComparer.Ordinal); + + public Task Get(string collection, string key) => + Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); + + // A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it. + public Task Post( + string collection, + string key, + string? handler, + string? origin = SiteOrigin, + string? fetchSite = null, + HttpMethod? method = null) + { + var request = new HttpRequestMessage( + method ?? HttpMethod.Post, + $"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler)) + { + Content = new FormUrlEncodedContent([]) + }; + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + if (fetchSite is not null) + { + request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite); + } + + return Send(request); + } + + public async Task Send(HttpRequestMessage request) + { + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + + if (Jar.Count > 0) + { + request.Headers.TryAddWithoutValidation( + "Cookie", + string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value))); + } + + var response = await client.SendAsync(request); + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + foreach (var header in values) + { + var pair = header.Split(';')[0]; + var split = pair.IndexOf('='); + Jar[pair[..split]] = pair[(split + 1)..]; + } + } + + return response; + } + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs new file mode 100644 index 0000000..04cdff4 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -0,0 +1,621 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in +// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour. +public sealed class ConceptVoteRateLimitTests : IClassFixture +{ + private const string Site = "https://localhost"; + private const string CookieName = "bifrost_voter"; + + private readonly LimitedFactory _factory; + + public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory; + + [Fact] + public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.10"); + foreach (var handler in new[] { "Up", "Down", "Up" }) + { + using var ok = await device.Post(collection, handler); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var other = new Client(_factory, "203.0.113.11"); + using var unaffected = await other.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task Retract_spends_the_cast_budget() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.12"); + await device.Post(collection, "Up"); + await device.Post(collection, "Retract"); + await device.Post(collection, "Retract"); + + using var denied = await device.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not() + { + var collection = await NewSubject(); + var first = new Client(_factory, "2001:db8:1:100::1"); + var sameBlock = new Client(_factory, "2001:db8:1:1ff::2"); + await first.Post(collection, "Up"); + await first.Post(collection, "Down"); + using var third = await sameBlock.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await sameBlock.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var elsewhere = new Client(_factory, "2001:db8:1:200::1"); + using var unaffected = await elsewhere.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.13"); + for (var i = 0; i < 3; i++) + { + await device.Post(collection, "Up"); + } + + using (var first = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode); + Assert.Equal("600", RetryAfter(first)); + } + + // 499.5 s remain: Retry-After rounds up, so a client told to wait never retries early. + _factory.Clock.Advance(TimeSpan.FromMilliseconds(100_500)); + using (var later = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode); + Assert.Equal("500", RetryAfter(later)); + } + + _factory.Clock.Advance(TimeSpan.FromMilliseconds(498_500)); + using (var last = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); + Assert.Equal("1", RetryAfter(last)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(1)); + using var reopened = await device.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket() + { + var collection = await NewSubject(); + var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" }; + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Signed_in_accounts_are_limited_per_account() + { + var collection = await NewSubject(); + var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") }; + var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") }; + var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") }; + var visitor = new Client(_factory, "203.0.113.20"); + + await alice.Post(collection, "Up"); + await alice.Post(collection, "Down"); + await aliceElsewhere.Post(collection, "Up"); + using var denied = await aliceElsewhere.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") }; + using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode); + + using var bobOk = await bob.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode); + using var visitorOk = await visitor.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode); + } + + [Fact] + public async Task Retract_by_a_signed_in_account_spends_the_account_budget_from_any_prefix() + { + var collection = await NewSubject(); + var home = new Client(_factory, "203.0.113.60") { Account = ("7003", "carol-limits") }; + var away = new Client(_factory, "203.0.113.61") { Account = ("7003", "carol-limits") }; + var elsewhere = new Client(_factory, "203.0.113.62") { Account = ("7003", "carol-limits") }; + await home.Post(collection, "Up"); + await home.Post(collection, "Down"); + using var third = await away.Post(collection, "Retract"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await elsewhere.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Mint_budget_is_spent_only_by_minting_casts() + { + var collection = await NewSubject(); + const string prefix = "203.0.113.30"; + var first = new Client(_factory, prefix); + using (var mint = await first.Post(collection, "Up")) + { + Assert.NotNull(SetCookie(mint)); + } + + // Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint. + using (var recast = await first.Post(collection, "Down")) + { + Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode); + Assert.Null(SetCookie(recast)); + } + + using (var recastAgain = await first.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode); + } + + _factory.Clock.Advance(TimeSpan.FromMinutes(10)); + var second = new Client(_factory, prefix); + using (var secondMint = await second.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode); + Assert.NotNull(SetCookie(secondMint)); + } + + var rowsBefore = (await Rows(collection)).Count; + var third = new Client(_factory, prefix); + using var denied = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Null(SetCookie(denied)); + Assert.Equal(rowsBefore, (await Rows(collection)).Count); + Assert.StartsWith("text/plain", denied.Content.Headers.ContentType?.MediaType); + var refusal = await denied.Content.ReadAsStringAsync(); + Assert.Contains("sign in", refusal, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", refusal); + + // An hour after the first mint the budget is whole again. + _factory.Clock.Advance(TimeSpan.FromMinutes(50)); + using var reopened = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Anonymous_cast_under_the_limit_succeeds() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.40"); + + using var response = await visitor.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var row = Assert.Single(await Rows(collection)); + Assert.Equal(VoterClass.Anonymous, row.VoterClass); + Assert.Equal(ConceptStance.Up, row.Stance); + } + + [Fact] + public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address() + { + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = await NewSubject(); + var visitor = new Client(factory, "203.0.113.77"); + for (var i = 0; i < 3; i++) + { + await visitor.Post(collection, i == 1 ? "Down" : "Up"); + } + + var before = Assert.Single(await Rows(collection)); + using var denied = await visitor.Post(collection, "Down"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.NotNull(RetryAfter(denied)); + Assert.Null(SetCookie(denied)); + var after = Assert.Single(await Rows(collection)); + Assert.Equal(before.Stance, after.Stance); + var body = await denied.Content.ReadAsStringAsync(); + Assert.Contains("too fast", body); + var seen = body + + string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("203.0.113", seen); + Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); + } + + [Fact] + public void Defaults_allow_thirty_new_devices_an_hour_per_address() + { + var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions(); + + Assert.Equal(30, defaults.MintPermits); + Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow); + Assert.Equal(100_000, defaults.MaxTrackedKeys); + } + + [Fact] + public void Every_table_holds_at_most_the_configured_keys_and_accounts_have_a_table_of_their_own() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create( + new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1, CastPermits = 1, MintPermits = 1 }), + new TestClock()); + var account = new VoterIdentity("account:test", VoterClass.Account, Guid.NewGuid()); + + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)!.Value.TableFull); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.2"), account)); + var otherAccount = new VoterIdentity("account:other", VoterClass.Account, Guid.NewGuid()); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), otherAccount)!.Value.TableFull); + Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1"))); + Assert.True(limits.DenyMint(IPAddress.Parse("198.51.100.2"))!.Value.TableFull); + } + + [Fact] + public async Task Filling_the_table_with_unspent_keys_locks_out_neither_a_fresh_visitor_nor_a_fresh_account() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + using var filler = await new Client(factory, $"203.0.113.{80 + i}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, filler.StatusCode); + } + + using var visitor = await new Client(factory, "203.0.113.90").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.91") { Account = ("7101", "fresh-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, visitor.StatusCode); + Assert.NotNull(SetCookie(visitor)); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + } + + [Fact] + public async Task A_spent_key_survives_eviction_pressure_and_stays_refused_while_rotating_callers_succeed() + { + var factory = Capped(4); + var collection = await NewSubject(); + var spender = new Client(factory, "203.0.113.100"); + for (var i = 0; i < 3; i++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + for (var i = 0; i < 12; i++) + { + using var rotating = await new Client(factory, $"203.0.114.{i + 1}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, rotating.StatusCode); + } + + using var denied = await spender.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Contains("too fast", await denied.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_rotating_caller_wins_back_only_a_forgotten_keys_unspent_budget() + { + var factory = Capped(2); + var collection = await NewSubject(); + var forgotten = new Client(factory, "203.0.113.110"); + await forgotten.Post(collection, "Up"); + await forgotten.Post(collection, "Up"); + var recent = new Client(factory, "203.0.113.111"); + await recent.Post(collection, "Up"); + await new Client(factory, "203.0.113.112").Post(collection, "Up"); + + // forgotten was least recently used and had one permit left: it was evicted, so it has all three again, no more. + for (var i = 0; i < 3; i++) + { + using var ok = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task A_table_full_of_spent_keys_refuses_a_new_visitor_with_its_true_reason_and_leaves_accounts_alone() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{120 + i}"); + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + var rowsBefore = (await Rows(collection)).Count; + using var visitor = await new Client(factory, "203.0.113.130").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.131") { Account = ("7102", "spared-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, visitor.StatusCode); + Assert.NotNull(RetryAfter(visitor)); + Assert.Null(SetCookie(visitor)); + var body = await visitor.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("too fast", body); + Assert.Contains("sign in", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", body + string.Join(";", visitor.Headers.Select(h => string.Join(",", h.Value)))); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); + } + + [Fact] + public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() + { + var collection = await NewSubject(); + var sender = new Client(_factory, "203.0.113.140"); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + for (var i = 0; i < 4; i++) + { + using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Null(SetCookie(missing)); + } + } + + for (var i = 0; i < 3; i++) + { + using var ok = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public void A_request_with_no_client_address_shares_one_bucket_apart_from_every_address() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { CastPermits = 1 }), + new TestClock()); + + Assert.Null(limits.DenyCast(null, null)); + Assert.NotNull(limits.DenyCast(null, null)); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + } + + [Fact] + public async Task Reads_are_not_under_the_vote_budgets() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.50"); + for (var i = 0; i < 4; i++) + { + await visitor.Post(collection, "Up"); + } + + using var page = await visitor.Get($"/mill/{collection}/alpha"); + using var export = await visitor.Get($"/public/concepts/{collection}/tallies"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + } + + private WebApplicationFactory Capped(int maxTrackedKeys) => + _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture) + }))); + + private static string? RetryAfter(HttpResponseMessage response) => + response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; + + private static string? SetCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) + : null; + + private async Task NewSubject() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = "alpha", + Title = "alpha", + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return collection; + } + + private async Task> Rows(string collection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection) + .ToListAsync(); + } + + // One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account. + private sealed class Client(WebApplicationFactory factory, string address) + { + private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri(Site) + }); + + private string? _cookie; + + public string Peer { get; init; } = "127.0.0.1"; + + public (string Id, string Login)? Account { get; init; } + + public Task Post(string collection, string handler, string? forwardedFor = null) => + Send( + new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}") + { + Content = new FormUrlEncodedContent([]) + }, + forwardedFor); + + public Task Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null); + + private async Task Send(HttpRequestMessage request, string? forwardedFor) + { + request.Headers.TryAddWithoutValidation("Origin", Site); + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address); + request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer); + if (Account is { } account) + { + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id); + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login); + } + else + { + request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true"); + } + + if (_cookie is not null) + { + request.Headers.TryAddWithoutValidation("Cookie", _cookie); + } + + var response = await _http.SendAsync(request); + if (SetCookie(response) is { } set) + { + _cookie = set.Split(';')[0]; + } + + return response; + } + } + + public sealed class LimitedFactory : TestWebApplicationFactory + { + public TestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:CastPermits"] = "3", + ["ConceptVotes:CastWindow"] = "00:10:00", + ["ConceptVotes:MintPermits"] = "2", + ["ConceptVotes:MintWindow"] = "01:00:00", + ["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddTransient(); + }); + } + } + + public sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _now; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _now += by; + _ticks += by.Ticks; + } + } + + // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. + private sealed class PeerFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + context.Connection.RemoteIpAddress = + IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1"); + return nextMiddleware(context); + }); + next(app); + }; + } +} diff --git a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs new file mode 100644 index 0000000..e2fedb2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -0,0 +1,299 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Tests; + +public sealed class FixedWindowBudgetTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + private const int Cap = 1000; + + [Fact] + public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, Cap, clock); + + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out var wait)); + + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Assert.True(Try(budget, "a", out _)); + + clock.Advance(Window - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromTicks(1), wait); + + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_denied_attempt_does_not_extend_the_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + for (var i = 0; i < 5; i++) + { + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(Try(budget, "a", out _)); + } + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + } + + [Fact] + public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + + // At 60 s a has ended and b has 30 s left. + Try(budget, "c", out _); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); + + clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(10)); + clock.StepWall(-TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + clock.Advance(TimeSpan.FromSeconds(50)); + Assert.True(Try(budget, "a", out _)); + Assert.Equal(1, budget.TrackedKeys); + } + + [Fact] + public void A_wall_clock_step_forward_does_not_reset_a_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.StepWall(TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(3, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "a", out _)); + + // b was used least recently, though a began first: c takes b's place and a keeps its count. + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "partial", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + // Rotating callers evict partial, never spent. + for (var i = 0; i < 10; i++) + { + Assert.True(Try(budget, "rot" + i, out _)); + } + + Assert.False(Try(budget, "spent", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.Equal(2, budget.TrackedKeys); + + // partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget. + Assert.True(Try(budget, "partial", out _)); + } + + [Fact] + public void A_key_that_spends_its_last_permit_leaves_the_evictable_set() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + + // a is spent and b is not: the newcomer evicts b although a was used less recently. + Assert.True(Try(budget, "c", out _)); + Assert.False(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(20)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + var denial = budget.Spend("c"); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a")); + Assert.Equal(2, budget.TrackedKeys); + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(20), wait); + } + + [Fact] + public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "c", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "a", out _); + + // The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window. + clock.Advance(TimeSpan.FromSeconds(57)); + Try(budget, "c", out _); + + Assert.Equal(2, budget.TrackedKeys); + } + + [Fact] + public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 1, clock); + Assert.True(Try(budget, "a", out _)); + + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void Eviction_work_does_not_scan_the_table() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 50_000, clock); + for (var i = 0; i < 50_000; i++) + { + budget.Spend("k" + i); + } + + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 200_000; i++) + { + budget.Spend("n" + i); + } + + Assert.Equal(50_000, budget.TrackedKeys); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}"); + } + + [Fact] + public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + for (var i = 0; i < 100; i++) + { + Try(budget, "k" + i, out _); + } + + Assert.Equal(100, budget.TrackedKeys); + clock.Advance(Window); + Try(budget, "fresh", out _); + + Assert.Equal(1, budget.TrackedKeys); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(-1, 60)] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) + { + Assert.ThrowsAny( + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock())); + } + + [Fact] + public void A_cap_below_one_is_refused() + { + Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); + } + + private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait) + { + var denial = budget.Spend(key); + wait = denial?.RetryAfter ?? TimeSpan.Zero; + return denial is null; + } + + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. + private sealed class Clock : TimeProvider + { + private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _wall; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _wall += by; + _ticks += by.Ticks; + } + + public void StepWall(TimeSpan by) => _wall += by; + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs new file mode 100644 index 0000000..aeaf092 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -0,0 +1,64 @@ +// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints +// what it did as JSON: the ordered events, and the address and form values at the moment of the submit. +// Usage: node complete-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/