diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 1bb3c83..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - true - - - diff --git a/Dockerfile b/Dockerfile index 45072e5..4f607e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -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 src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false /p:RestoreLockedMode=true + /p:UseAppHost=false 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 d971d0c..683d7d2 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,7 +196,15 @@ 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 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. +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. 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 542ff8d..fdcef97 100644 --- a/docs/context.md +++ b/docs/context.md @@ -18,10 +18,8 @@ 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` - - 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 + - owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation + - Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault - `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 @@ -57,7 +55,7 @@ After bootstrap, the next session should start implementing the first vertical s - member alpha first - invite-only onboarding -- 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 as the only auth provider in v1 - 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 a251b00..c0a2754 100644 --- a/docs/jurisdiction-map.md +++ b/docs/jurisdiction-map.md @@ -103,8 +103,6 @@ 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 @@ -155,7 +153,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself. | Concern | Owner | Reason | | --- | --- | --- | -| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. | +| Who is this actor? | Heimdall | Identity and account custody must 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. | @@ -207,7 +205,6 @@ 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 deleted file mode 100644 index a7f59a4..0000000 --- a/docs/oidc-issuer.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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 deleted file mode 100644 index f67a9a5..0000000 --- a/docs/research/2026-10-29-estate-tally-game-theory/README.md +++ /dev/null @@ -1,330 +0,0 @@ -# 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 deleted file mode 100644 index 8e82ed4..0000000 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs +++ /dev/null @@ -1,284 +0,0 @@ -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 deleted file mode 100644 index edf1dd3..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index b435347..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index 8f0be75..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs +++ /dev/null @@ -1,140 +0,0 @@ -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 deleted file mode 100644 index ac6fa72..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ /dev/null @@ -1,220 +0,0 @@ -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 deleted file mode 100644 index 5ad30b8..0000000 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index b6c9083..0000000 --- a/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs +++ /dev/null @@ -1,131 +0,0 @@ -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 deleted file mode 100644 index 1de135b..0000000 --- a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs +++ /dev/null @@ -1,34 +0,0 @@ -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 98b8dc7..c511413 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -16,41 +16,13 @@ public sealed class StartupConfigurationValidator( { public void Validate() { - 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)) - { - failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); - } - - 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; } + var failures = new List(); + if (!gitHubOAuthOptions.Value.IsConfigured) { failures.Add("GitHub OAuth client configuration is required outside development."); @@ -101,6 +73,7 @@ public sealed class StartupConfigurationValidator( failures.Add("DataProtection:KeysPath must be configured for production."); } + var conceptVotes = conceptVoteOptions.Value; if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) { @@ -108,16 +81,21 @@ public sealed class StartupConfigurationValidator( $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); } + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits)) + { + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); + } + + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + 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( diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml deleted file mode 100644 index fe75338..0000000 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml +++ /dev/null @@ -1,56 +0,0 @@ -@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 deleted file mode 100644 index dd87f6d..0000000 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ /dev/null @@ -1,179 +0,0 @@ -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 d0656d3..bea0beb 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -11,7 +11,6 @@ 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; @@ -25,23 +24,6 @@ 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 @@ -80,7 +62,6 @@ builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); - options.Conventions.AllowAnonymousToFolder("/Mill"); }); builder.Services.ConfigureHttpJsonOptions(options => { @@ -89,7 +70,10 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(options => { - options.UseNpgsql(BifrostConnectionString(builder.Configuration)); + var connectionString = builder.Configuration.GetConnectionString("Bifrost") + ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; + + options.UseNpgsql(connectionString); }); builder.Services.AddHttpLogging(options => @@ -119,19 +103,16 @@ 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) => - 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)); +{ + var options = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); +}); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); @@ -226,13 +207,6 @@ 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(); @@ -275,11 +249,6 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke app.MapConceptTallyExport(); -if (oidcOptions is not null) -{ - app.MapBifrostOidc(); -} - app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, @@ -313,18 +282,22 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnPath.Value); + await WriteSignInChooserAsync(httpContext, returnUrl); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); return; } @@ -336,7 +309,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions hostOptions, - ILogger logger, CancellationToken cancellationToken) => { try { - var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); + if (string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = "/App"; + } + var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -390,18 +367,30 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnPath, + returnUrl, requireMemberAccess, - httpContext.Request.Cookies, cancellationToken); - httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - logger.LogWarning("Heimdall start refused: {Reason}", error.Message); - return Results.Redirect(HeimdallAuthService.ErrorRedirect); + 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); } }).AllowAnonymous(); @@ -527,44 +516,31 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -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 ( +app.MapGet("/auth/heimdall/wait", async ( HttpContext httpContext, HeimdallAuthService heimdallAuthService, - IOptions hostOptions, - ILogger logger, CancellationToken cancellationToken) => { - var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); - var attempt = form?["attempt"].ToString(); - var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); + var attemptId = httpContext.Request.Query["attemptId"].ToString(); + if (string.IsNullOrWhiteSpace(attemptId)) + { + return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); + } + try { - var signIn = await heimdallAuthService.CompleteAsync( - hostOptions.Value.PublicBaseUrl, - attempt, - held?.Value, - form?["code"].ToString(), - cancellationToken); + var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); - return Results.LocalRedirect(signIn.ReturnPath.Value); + 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"); } catch (HeimdallAuthException error) { - 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); + return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); } }).AllowAnonymous(); @@ -584,23 +560,6 @@ 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 47bbb7b..b24dd83 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,7 +27,6 @@ "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 deleted file mode 100644 index bd15130..0000000 --- a/src/Bifrost.Web/packages.lock.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "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 1e94198..355d11c 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,11 +20,6 @@ - - - - - diff --git a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs deleted file mode 100644 index c942457..0000000 --- a/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs +++ /dev/null @@ -1,673 +0,0 @@ -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/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs deleted file mode 100644 index 5434e8d..0000000 --- a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs +++ /dev/null @@ -1,673 +0,0 @@ -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 deleted file mode 100644 index 04cdff4..0000000 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ /dev/null @@ -1,621 +0,0 @@ -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 deleted file mode 100644 index e2fedb2..0000000 --- a/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs +++ /dev/null @@ -1,299 +0,0 @@ -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 deleted file mode 100644 index aeaf092..0000000 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ /dev/null @@ -1,64 +0,0 @@ -// 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(/