Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc12f2bb12 |
104 changed files with 193 additions and 29447 deletions
|
|
@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
## Local Dev Notes
|
||||
|
||||
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
||||
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
|
||||
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing:
|
|||
|
||||
It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain.
|
||||
|
||||
The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
|
||||
The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
|
@ -198,62 +198,6 @@ Bifrost's native Discord gateway is the conversation observer. A direct mention
|
|||
|
||||
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-<persona>`) 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. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download/<server>/<mediaId>` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT/<persona>/<hex sha256 of the room id>/<hex sha256 of the bytes>[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `<room hash>/<file>` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. 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. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence/<user>/status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds/<persona>/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/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed.
|
||||
|
||||
### Addressing facts (`chat_pending.v3`)
|
||||
|
||||
The mouth wakes no one; the heartbeat and each Persona session decide who is told a delivery (Huginn `address.rs`, ruling `eureka-body:ruling:addressed-delivery-only`). Telling every Persona every message made 71.5% of 4,351 Persona turns on 2026-11-10 do nothing. The rule stays in one place, Huginn's, because two of its inputs are not Matrix facts: the Persona's stewardship records (her projects' chat names) and the sender's role (operator or Mind). The mouth owns the other half: what Matrix itself says, per Persona, for the delivery. Matrix verifies the sender's identity and nothing else: `tag` and `room` are the sender's claim in `m.mentions` (the homeserver does not check the text against it, nor that the sender may use `@room`), and `reply` is checked against the event it targets, which the mouth reads as her account.
|
||||
|
||||
A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pending_request.v2` (the v1 tuple, `[persona, after_seq]`) is answered with `gamecult.persona.chat_pending.v3`; a v1 request still gets the 12-field `chat_pending.v2` answer unchanged. A v3 delivery is the 12 fields of the v2 delivery, then:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `<a href>` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event, a failure for a minute); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. |
|
||||
| 13 `form` | `"message"`, `"notice"` or `"edit"` | `m.notice` is an automated line; `m.replace` is an edit of an earlier message. Only a `message` can address anyone. |
|
||||
|
||||
What the mouth cannot know, and so what a nil `basis` means: **unproven, not "not for her"**. Her handle and public name in the text (any casing, with or without `@`, `'s`), the names her stewardships list, a project's repo name, and whether the sender is the operator or a Mind are all matched by Huginn, not here. A reply whose target the homeserver will not give is nil and logged. The mouth also cannot see whether a message was meant for her without naming her. Every delivery is still delivered, claimed tags included: nothing is dropped in the mouth, so the Huginn rule cannot lose a message the mouth had no way to judge.
|
||||
|
||||
Proposed runtime half (follow-up `persona-wakes-on-address-only`):
|
||||
|
||||
1. Poll with `chat_pending_request.v2` and parse `chat_pending.v3`.
|
||||
2. A delivery is *addressed* to a Persona when `form == "message"` and either `basis` is `direct`, `tag` or `reply`, or `address::addresses` matches her names or her projects' names on the text. The off-beat wake keeps the sender gate of `heartbeat.rs` `wakes` (the operator or another Mind of the roster): a stranger's tag, `@room` or DM is delivered but wakes no one off the beat. `basis == "room"` is the runtime's policy to weigh; the mouth only reports it.
|
||||
3. A real-time wake, a live session's tell and the stagger apply to addressed deliveries only. A `notice` or an `edit` never wakes, whatever its basis.
|
||||
4. A delivery that is not addressed is acked in sequence with the rest, so the position moves (the mouth stops reading at 500 unacked deliveries); it is not lost: Matrix is the record, and the next addressed delivery's `context` (up to 20 lines) and a beat's own room read show it. The room tail below gives a scheduled beat those lines without a Matrix read of its own; the mouth adds no endpoint for it.
|
||||
5. `basis` and `form` are evidence, not authority: a sender's claim is weighed by the runtime's own rule (`address.rs` for names, the sender gate for who may wake her); the mouth reports what the event says and what only Matrix could look up (the author of a reply target).
|
||||
|
||||
### Room tail (`chat_pending.v3`, field 2)
|
||||
|
||||
The v3 answer is `[persona, deliveries, tail, tail_gaps]`. `tail` is a list of recent room lines, oldest first, for a reader that was not woken for them (a scheduled beat). It uses the same room read as a delivery's `context` (the same function, a separate read), as her account, with no new endpoint; the cost is one `/messages` call per speakable room per poll (15 seconds cached), including every DM. A `chat_pending_request.v1` is still answered with the two-field `chat_pending.v2`. `chat_pending.v3` has not been deployed, but a reader that decodes it as a two-field tuple fails on the third element (rmp-serde `LengthMismatch`), so Huginn must accept `tail` and `tail_gaps` (defaulted fields) before this mouth is deployed. The array is positional: a decoder of the earlier three-field shape fails on the fourth element with the same `LengthMismatch`, so `tail_gaps` has the same deploy precondition; a decoder that reads the first three elements and ignores the rest sees `persona`, `deliveries` and `tail` unchanged.
|
||||
|
||||
| Bound | Rule |
|
||||
|---|---|
|
||||
| Rooms | Only rooms the Persona may speak in: a configured room she has joined, or one of her DMs. The same rule `send` uses (`speakableRoomId`). A room she is in but cannot speak in, and any other room, is absent. |
|
||||
| Lines | A text line outside threads, not an edit, newest `20` per room, newest `60` over all rooms. Trimmed oldest first if the reply byte budget (256 KiB, deliveries first) would be exceeded. |
|
||||
| Cursor | None. The tail is each room's newest lines whether or not they were delivered or acked: a message the wake rule left unaddressed is acked and leaves `pending`, and the tail is where a beat still finds it. A reader removes the lines a delivery already shows (by event id). Her own lines are included. |
|
||||
| Read | A room's read is kept 15 seconds, dropped when a sync brings the room news. A room that cannot be read within 10 seconds is logged and absent; the other rooms and the deliveries are unaffected. |
|
||||
|
||||
A line is the 7-field tuple `[room, event_id, at_unix_ms, sender, form, basis, body]`: `room` is the configured alias (the room id for a DM), `form` and `basis` as for a delivery, `body` clipped to 1000 bytes with the same typed text line for media a delivery has (nothing is fetched). `basis` is what Matrix says on the event itself (`direct` in a DM, `tag`, `room`) and nothing the mouth would have to look up, so a reply is nil here: **nil is unproven, not "not for her"**, and a line in a DM is `direct`. Huginn's rule that an `is_direct` delivery wakes with a nil `basis` is unchanged and lives with Huginn; the tail does not feed it.
|
||||
|
||||
The tail is **data, not instruction**. A line is what a sender typed in a room, and the mouth wakes no one, acks nothing and sends nothing because of it. A reader treats the body as untrusted text and weighs `basis` as evidence, as for a delivery. The tail does not replace a delivery: a message still reaches her as a delivery, with its own `seq`, and only `chat_ack` advances a position.
|
||||
|
||||
#### Gaps in the tail (`chat_pending.v3`, field 3, `tail_gaps`)
|
||||
|
||||
`tail_gaps` is a list of room names (the configured alias, or the room id for a DM, as `room` in a tail line), one for each speakable room whose lines are not all in `tail`. A reader renders such a room as **unknown**, not quiet. Absent or empty means every speakable room was read fully: a room with no lines because it is quiet, and a room whose few lines all fit, are never listed. Order is the order the mouth reads rooms (configured rooms in join order, then DMs), not time; a room appears once however many causes apply. Causes, all of them:
|
||||
|
||||
| Cause | Where |
|
||||
|---|---|
|
||||
| The room's read failed (the homeserver refused or errored) or did not answer within 10 seconds (`sendTimeoutMs`); the room is logged and contributes no lines | the mouth |
|
||||
| The room had more than 20 lines outside threads (the per-room cap; her own lines count), so older ones were left out | the mouth |
|
||||
| The read window came back full (100 raw events, before edits and thread replies are set aside), so real lines older than the window may exist and are unseen; a shorter chunk means the room's history was exhausted. A room whose history is exactly 100 events long is gapped too, since the mouth cannot tell. The rule assumes a homeserver returns the 100 events asked for whenever older history exists; a server that caps the limit lower turns a truncated read into a false "whole", and Bifrost does not detect that | the mouth |
|
||||
| One of the room's lines was cut by the 60-line total cap (only the newest 60 over all rooms are kept) | the mouth |
|
||||
| A line was dropped as malformed (no event id) | the mouth |
|
||||
| The reply byte budget (256 KiB, deliveries first) cut the room's oldest lines, or a line would not encode and was skipped | the reply |
|
||||
| The mouth has not started: every configured room is a gap (DMs are not yet known) | the mouth |
|
||||
| The whole tail read failed: every speakable room is a gap, and the deliveries are still served | the endpoint |
|
||||
|
||||
A line a delivery already shows and a room she cannot speak in change nothing here; her own lines are lines like any other and count toward the caps. A read the mouth kept for 15 seconds counts as a read.
|
||||
|
||||
Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries two lines and no gap, `chat_pending.v3.tail-gaps` one line and two gap rooms; the earlier v3 samples gain an empty fourth element); Huginn pins the same bytes.
|
||||
|
||||
|
||||
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.
|
||||
|
||||
`tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path.
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ Derived from them, not asked separately:
|
|||
- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists.
|
||||
- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters.
|
||||
- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing.
|
||||
- **Ranked read.** Beside the map, anonymous `GET /public/concepts/{collection}/ranked?sort=net|total|newest&limit=1..100&cursor=&unvoted=network` returns `{ items: [{ key, title, up, down, members }], next_cursor }`, largest first, ties by key. The cursor is opaque, keyset and bound to its sort and filter; any other query shape or cursor is a 400. `unvoted=network` omits the concepts the caller's own network tag has voted on (tag derived from the connection, never accepted or returned) and is never cached. Same CORS and Vary policy as the map; the map is unchanged.
|
||||
- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party.
|
||||
- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key.
|
||||
- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path.
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ echoes its value.
|
|||
| `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:BrakePath` | Optional. While a file exists at this path, `/connect/authorize`, `/token` and `/userinfo` answer 503 `temporarily_unavailable` and nothing is issued; delete the file to release. Checked per request, so no restart. Unset: no marker brake. |
|
||||
| `Bifrost:Oidc:Limits:*` | `PerAddressPermits` (30), `Window` (1 min), `MaxTrackedKeys` (10000), `MaxTokenRows` (20000), `PruneInterval` (5 min). |
|
||||
| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; 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
|
||||
|
|
@ -59,7 +57,7 @@ There are no development or ephemeral keys in the host. Key files are provisione
|
|||
| `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. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (`registration-gates.md`, Email verification).
|
||||
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost.
|
||||
|
||||
## Who may sign in
|
||||
|
||||
|
|
@ -68,29 +66,21 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A
|
|||
- 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 `Suspended`: redirect to the client's `redirect_uri` with `error=access_denied` and
|
||||
`error_description=The account is suspended.`
|
||||
- Signed in with no membership, `Authenticated` or `PendingApproval` (the chat tier): treated like an active member for
|
||||
sign-in to chat and nothing else. The token carries the same `openid profile` claims; the issuer writes no membership,
|
||||
role or vote, and every member or governance surface (`/App`, `/eve/governance`, `/api/sealed-motions`, the fork desk)
|
||||
still asks for `Active` itself, so neither the session nor the token opens them.
|
||||
- Signed in, with an account that can chat, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
|
||||
- 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, with an account that can chat, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse
|
||||
accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with
|
||||
`error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before
|
||||
that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say.
|
||||
- Otherwise: a code is issued.
|
||||
- Token endpoint and userinfo: suspension is read again; an account suspended or deleted since gets `invalid_grant` (token) or 403 (userinfo). The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership.
|
||||
- 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 who may no longer chat (the same
|
||||
reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, or a subject that is not an account id; an
|
||||
account with no membership or a pending one may chat and is left alone) 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 being suspended or deleted (admin
|
||||
action, sync, a database edit) is covered because the work is derived from membership state.
|
||||
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
|
||||
|
|
@ -117,38 +107,6 @@ Configure MAS's upstream provider with the issuer above, the client id and secre
|
|||
from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
|
||||
account.
|
||||
|
||||
## Limits, prune, brake
|
||||
|
||||
`OidcGuardMiddleware` sits ahead of OpenIddict and guards the three endpoints, matched by path segment so a trailing slash
|
||||
is no way around it. In order: the marker brake (503; a marker that cannot be read is treated as engaged); one per-address
|
||||
budget over the three endpoints together (429 with `Retry-After`; the address is the connection's after forwarded-headers
|
||||
handling that trusts only the local proxy, IPv6 keyed by /64); and a cap on token rows: a new authorize request (no
|
||||
`request_uri`) is refused with 503 while the table holds `MaxTokenRows`. Any failure inside the guard is a 503, never a pass.
|
||||
MAS's flow is a handful of requests per sign-in from one address, far under the defaults (30 a minute is about 15 sign-ins).
|
||||
|
||||
There is no per-client budget. The one client's id is public and what a caller names is unverified, so a budget keyed on it
|
||||
would let anyone spend the real client's permits and lock sign-in out. The bound on the anonymous endpoint is the address
|
||||
budget plus the row cap, and the cap is itself a switch an attacker with many addresses can flip: it refuses new sign-ins
|
||||
(never resumed ones) until the next prune frees rows, which is the bounded failure chosen over unbounded parked rows.
|
||||
Not parking state before the user authenticates (as pushed authorization requests avoid by authenticating the client first)
|
||||
would remove the switch, and OpenIddict's request caching does not offer that.
|
||||
|
||||
`OidcPruner` runs every `PruneInterval` and deletes tokens past expiry (parked authorize requests, codes, access tokens) or no
|
||||
longer valid, then authorizations no longer valid or ad hoc with no token left (younger than five minutes are left alone).
|
||||
Live rows are untouched. Plain EF queries rather than OpenIddict's own prune, which uses ExecuteDelete and cannot run on the
|
||||
in-memory provider the tests use.
|
||||
|
||||
`docs/oidc-reserved-accounts.sql` is the read-only query for existing accounts that hold one of the 28 reserved names.
|
||||
|
||||
## Not built
|
||||
|
||||
Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256,
|
||||
the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at
|
||||
issuance, back-channel logout. Not present:
|
||||
|
||||
- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID,
|
||||
`on_conflict: fail`, `on_backchannel_logout: logout_all`, the redirect URI above); that lives in the ops repo.
|
||||
|
||||
## Remaining scar
|
||||
|
||||
Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
-- Read-only. Which existing UserAccounts hold one of the 28 reserved localparts
|
||||
-- (BifrostIdentityService.ReservedIdentities). Written for review; not run on live.
|
||||
-- Run it in a read-only transaction by whoever holds the database:
|
||||
-- psql -v ON_ERROR_STOP=1 -c 'BEGIN READ ONLY' -f docs/oidc-reserved-accounts.sql
|
||||
-- Keep the list in step with ReservedIdentities; the issuer refuses these at authorize (#49).
|
||||
SELECT a."Id", a."BifrostIdentity", a."NormalizedBifrostIdentity", a."CreatedAtUtc", a."LastSeenAtUtc"
|
||||
FROM "UserAccounts" a
|
||||
WHERE lower(a."NormalizedBifrostIdentity") IN (
|
||||
'admin', 'aqua', 'deru', 'eureka', 'heimdall', 'ivixey', 'kiko', 'libby', 'meta', 'nibu', 'unnie', 'void', 'weksa',
|
||||
'root', 'system', 'matrix', 'synapse', 'support', 'bot', 'mod', 'moderator', 'postmaster', 'gamecult',
|
||||
'bifrost', 'idunn', 'odin', 'huginn', 'ratatoskr')
|
||||
ORDER BY a."NormalizedBifrostIdentity";
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
# Public registration gates
|
||||
|
||||
Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification,
|
||||
a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote.
|
||||
This page scopes the three that did not exist, says where each lives, and records what is built.
|
||||
|
||||
## What exists
|
||||
|
||||
| Gate | Where | State |
|
||||
| --- | --- | --- |
|
||||
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
|
||||
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
|
||||
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
|
||||
| Suspension | `MembershipSuspensionService` | Merged. |
|
||||
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
|
||||
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
|
||||
| Email verification | nowhere | Scoped below, not built. |
|
||||
|
||||
The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an
|
||||
`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no
|
||||
registration form.
|
||||
|
||||
Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and
|
||||
expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that
|
||||
`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The
|
||||
server verifies the protection, the expiry and the hash, and spends the id once.
|
||||
|
||||
- Self-hosted: no third-party script, request or cookie; nothing to track.
|
||||
- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a
|
||||
script all pass the same way, at the cost of CPU time on the client.
|
||||
- Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser
|
||||
JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a
|
||||
proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with.
|
||||
- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge
|
||||
can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address
|
||||
cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget
|
||||
(`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker
|
||||
with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md.
|
||||
|
||||
### The sign-up form and the no-JS fallback
|
||||
|
||||
`GET /auth/bifrost/register` renders the form: identity, optional display name, the notice in full beside an unchecked
|
||||
consent box (the box is the consent act), and a challenge. With JavaScript the page solves the proof of work in the
|
||||
background and reports progress in a polite live region. Without JavaScript the form still submits and succeeds, by a
|
||||
time-lock:
|
||||
|
||||
- The challenge carries its issue time. A submission that says it waited is accepted only once `FallbackWaitSeconds`
|
||||
(default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is
|
||||
not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or
|
||||
malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every
|
||||
other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept.
|
||||
`FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24.
|
||||
- Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per
|
||||
`FallbackWindow`, default one hour; over it is 429 with Retry-After).
|
||||
- A submission names a nonce or says it waited, never both.
|
||||
- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so
|
||||
a slow device is never stuck behind the proof of work.
|
||||
- `POST /auth/bifrost/register/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin`
|
||||
equal to the site origin, else `Sec-Fetch-Site: same-origin`), 403, before it reads the body or spends anything, so a
|
||||
third-party page cannot register an account and replace a visitor's session. A request with neither header and no
|
||||
session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped
|
||||
at 16 KB (413) and the per-address budget is spent before it is read.
|
||||
- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are
|
||||
chat localparts), so that leaks nothing.
|
||||
|
||||
Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or
|
||||
solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU,
|
||||
and an attacker can start many challenges at once and wait once, so what bounds the fallback is the per-address budget
|
||||
and nothing else. An attacker with many addresses passes it at the price of the wait. We accept that because a
|
||||
registration buys little: the account is `Authenticated`, in Users, with no vote and no member surface, and the other
|
||||
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
|
||||
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
|
||||
|
||||
## Consent receipt
|
||||
|
||||
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
|
||||
decision (`admission-is-bifrost-membership`).
|
||||
|
||||
- Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's
|
||||
`answer-collector-consent.md`) and `TrainingNotice.Text`. Served at `GET /auth/bifrost/notice`. The text in the repo is
|
||||
draft wording. Eureka owns the final wording in gamecult-ops; changing the text is a version bump, never an edit in
|
||||
place. The text states that no Discord material is used.
|
||||
- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`), unique per account and version,
|
||||
written in the same save as the account. Registration without `acceptedNoticeVersion` equal to the current version
|
||||
creates nothing. A new notice version needs a new acceptance; there is no path yet that asks for one.
|
||||
- Withdrawal: `POST /auth/bifrost/consent/withdraw`, signed in, sets `WithdrawnAtUtc` once on the account's receipts.
|
||||
Withdrawal stops future use and cannot recall past use; the notice says so. The row is one-way and never deleted.
|
||||
- Consumer: a training export must include an account's data only while a receipt for the notice version in force
|
||||
exists with `WithdrawnAtUtc` null. No export exists in Bifrost; this page is the contract it will be built against.
|
||||
|
||||
Scope limits: accounts that arrive through Heimdall providers (Discord, Patreon, Twitch, GitHub) are created in
|
||||
`HeimdallAuthService.UpsertUserAccountAsync` and get no receipt. They reach chat the same way. Closing that needs a
|
||||
consent step on the completion page and is the next cut. Existing accounts are not re-asked.
|
||||
|
||||
## Email verification
|
||||
|
||||
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
|
||||
|
||||
Why: a native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). A person who
|
||||
loses the session cookie loses the account, and the chat localpart minted from it is permanent. An address that was
|
||||
verified is the one way back, and the usual way (MAS, Matrix homeservers and most accounts hold one). Hashing it instead
|
||||
cannot send mail, so it recovers nothing.
|
||||
|
||||
Privacy handling, which the build must honour:
|
||||
|
||||
- Stored only for native accounts, one verified address per account, unique across accounts (a keyed hash of the
|
||||
normalized address is the lookup; the address itself is encrypted with Data Protection, since sending needs it).
|
||||
- Never in a token or claim (the issuer doc's claim table is unchanged), never in a log line, a dashboard, a Verse
|
||||
document, a training export, or Heimdall. The account page shows it only to its owner.
|
||||
- Used for two things: the verification mail and a single-use sign-in link (15 minutes, spent on use, per-address and
|
||||
per-account send budgets). No notices, no newsletters, no sharing.
|
||||
- The owner can remove it (then there is no recovery) or change it (a new verification first). Deleting the account
|
||||
deletes it.
|
||||
- Existing accounts and provider accounts are not asked.
|
||||
|
||||
The build cannot start until ops provides a transport: there is no mail sender anywhere in Bifrost, Heimdall or the chat
|
||||
stack. Bifrost's side is an `IEmailSender` port with the relay's settings as secret files; the relay, its sending domain
|
||||
and SPF/DKIM are ops infrastructure.
|
||||
|
||||
Shape: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a single-use
|
||||
token; no account exists yet. Step two redeems the token and creates the account, the receipt and the stored address
|
||||
together. An unverified account then cannot exist, so it cannot chat.
|
||||
|
||||
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts.
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# Training notice: proposed version 2
|
||||
|
||||
Proposal from Eureka, for Heimdall to merge or refuse. It changes no code and no served text; merging it records the
|
||||
proposal, and putting it live is a separate cut that bumps `TrainingNotice.Version` (see "To put it live"). Companion to
|
||||
`docs/registration-gates.md`, whose "Consent receipt" section is the contract this reads against. Read against main
|
||||
`33be966`: `TrainingNotice.cs`, the register and withdraw handlers in `Program.cs`, and the doc. Not run, not tested.
|
||||
|
||||
## What is wrong with version 1
|
||||
|
||||
| Line in version 1 | Problem |
|
||||
| --- | --- |
|
||||
| "Chat messages and activity" | "Activity" names nothing. A reader cannot tell whether it covers display names, reactions, presence, direct messages or the email address Bifrost will soon hold. |
|
||||
| "train and evaluate AI models" | Whose models, and does the text leave GameCult? Sending chat to a vendor's API to answer a message is different from building a dataset. The notice must say which it is. |
|
||||
| "No Discord material is used" | True only if nothing bridged from Discord reaches a chat room. The sentence is a promise about the whole room, not about the registrant, and nobody has checked that no bridge exists. |
|
||||
| "you can withdraw it at any time" | No form and no page exists. A native account has no credential (`registration-gates.md`, Email verification), and withdrawal needs a signed-in session. The sentence promises a door the person cannot yet reach. |
|
||||
| "Withdrawing stops future use; it cannot recall use that already happened" | Honest, but silent on the question people actually ask: is my data deleted. It is not, and the notice must say so. |
|
||||
| Registration without acceptance creates nothing | Consent is a condition of having an account. That is not a free choice, and the contract already treats "no receipt" as "no use", so the condition buys nothing it does not already have. |
|
||||
| No controller, contact or retention | A person cannot tell who holds the receipt, for how long, or whom to ask. |
|
||||
|
||||
## Proposed text (version 2)
|
||||
|
||||
Square brackets mark words only Meta can fill; they are not in the served text until filled.
|
||||
|
||||
> **GameCult chat training notice, version 2**
|
||||
>
|
||||
> GameCult may use messages you post in GameCult chat to train and evaluate AI models that GameCult builds and runs.
|
||||
> [Meta: confirm that this is the whole purpose, and whether any dataset or model leaves GameCult.]
|
||||
>
|
||||
> **What counts.** The text of your messages, with their time and room, tied to your account name. Not your email
|
||||
> address, your sign-in details or anything you did not post. Nothing that came from Discord is used, in any form.
|
||||
>
|
||||
> **It is your choice.** [If Meta rules consent optional:] You can use GameCult chat without agreeing. If you do not
|
||||
> agree, none of your messages are used.
|
||||
>
|
||||
> **If you agree,** we record your account, this notice's version number and the time. Nothing else is stored with it.
|
||||
>
|
||||
> **If you withdraw,** we leave all your messages out of every training set made after that moment. Withdrawing deletes
|
||||
> nothing: your messages stay in chat and on our servers, anything already used to train a model stays used, and a
|
||||
> dataset already built is not recalled. We keep the record that you agreed and when you withdrew. [Meta: deletion of
|
||||
> messages is a separate request, via ___, or not offered.]
|
||||
>
|
||||
> **How to withdraw:** ___. **Questions:** ___.
|
||||
|
||||
About 170 words. Plain, no consent boilerplate, and every sentence is something the code or an owner can honour.
|
||||
|
||||
## What each change rests on
|
||||
|
||||
- "Tied to your account name" and "not your email address": the export contract in `registration-gates.md` keys on the
|
||||
account; nothing exports an address because no address is stored today. If email verification keeps the address, this
|
||||
line becomes a rule the export must enforce, so keep it only if that is ruled.
|
||||
- "Leave all your messages out of every set made after": the contract says an export includes an account's data only
|
||||
while a receipt with `WithdrawnAtUtc` null exists, so withdrawal excludes the account's whole history from later
|
||||
exports, not just later messages. The notice says the stronger thing because the contract delivers it.
|
||||
- "Withdrawing deletes nothing": the withdraw handler sets a timestamp and nothing else. The room history lives in the
|
||||
chat server, which Bifrost does not delete from. Whether backups hold it is for the chat steward to confirm.
|
||||
- "We keep the record": the receipt row is one-way and never deleted, so say it.
|
||||
|
||||
## Receipt flow: gaps found in reading
|
||||
|
||||
1. **Withdrawal needs a session a native registrant may not have.** `POST /auth/bifrost/consent/withdraw` returns 401
|
||||
without a signed-in actor, and a native account has no credential until email verification gives it one. Until the
|
||||
sign-in link exists, withdrawal by a native account is only possible through chat sign-in, if that works for them. I did
|
||||
not trace whether it does.
|
||||
2. **No re-ask path.** A new notice version leaves every old receipt unequal to the current version. The contract is
|
||||
fail-closed (no match, no export), which is safe, but it also means a version bump silently removes everyone from the
|
||||
dataset. The notice should say a new version asks again and silence means no.
|
||||
3. **Provider-created and existing accounts have no receipt**, so they are excluded from any export. That is correct
|
||||
behaviour and should be stated as the rule, not left as a "scope limit".
|
||||
4. **Withdrawal is not CSRF-checked in what I read.** It is a cookie-authenticated POST with no body. I did not test
|
||||
whether the antiforgery or cookie same-site settings cover it. Heimdall should confirm.
|
||||
5. **Receipts are not exported anywhere yet.** The consumer (a training export) does not exist, so every promise above is
|
||||
a promise about code not written. The notice should not go live to real people before the export enforces the
|
||||
contract with a test that a withdrawn account's rows are absent.
|
||||
|
||||
## Needs a decision
|
||||
|
||||
For Meta (hers, not mine to settle):
|
||||
|
||||
1. **Purpose and scope**: own-model training only, or datasets or models that leave GameCult? Third-party model APIs
|
||||
processing messages to answer them: covered by this notice or not mentioned?
|
||||
2. **Optional or required consent.** I recommend optional: registration succeeds and a receipt exists only if ticked.
|
||||
The contract already excludes anyone without a receipt, so the code change is to make `acceptedNoticeVersion`
|
||||
nullable. Required consent is also a legal question (consent given as a condition of service is weak in the EU);
|
||||
I am not counsel and did not verify this against current law.
|
||||
3. **Deletion**: offered on request, and by whom, or not offered. The notice must match.
|
||||
4. **Controller and contact** words, and how a person withdraws before the form exists.
|
||||
5. **Minimum age.** The notice and flow say nothing about children. Not researched.
|
||||
|
||||
For members (the people whose messages these are): whether they want existing members asked at all. Version 1 does not
|
||||
re-ask existing accounts, so they stay out of any dataset; asking them is the only way they can opt in.
|
||||
|
||||
For Heimdall: items 1, 4 and 5 under "Receipt flow", and whether a Discord bridge into a chat room exists or is planned
|
||||
(the ruling `gamecult-chat:ruling:discord-taints-training` forbids Discord material in training; the notice repeats it, so
|
||||
the claim must be true).
|
||||
|
||||
## To put it live
|
||||
|
||||
A code cut, after Meta fills the brackets: replace `TrainingNotice.Text`, set `Version = 2`, update the tests that pin
|
||||
version 1, and decide the optional-consent change in the same cut because it changes what a receipt means. Version 1
|
||||
should not have reached the public (the doc says no form exists; I did not check what is live), so a bump should cost no
|
||||
re-ask; any real account holding a version 1 receipt falls out of the dataset until asked again.
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
// The fork desk's configuration (CultLib fork-card.md, Transport; M1 and M5). Minds is an ARRAY, never a map keyed
|
||||
// by Mind id: configuration keys are case-insensitive, so a keyed section would let GAMECULT-CHAT reach
|
||||
// gamecult-chat. Account ids are lowercase D-format Guids. An empty Minds list means the desk is not configured.
|
||||
public sealed class DeskOptions
|
||||
{
|
||||
public const string SectionName = "Desk";
|
||||
|
||||
public List<DeskMindOptions> Minds { get; init; } = [];
|
||||
|
||||
public DeskAttributionOptions Attribution { get; init; } = new();
|
||||
|
||||
// The one origin allowed to call /api/desk from a browser (exactly this, no credentials).
|
||||
public string AllowedOrigin { get; init; } = string.Empty;
|
||||
|
||||
// The cap on an answer body, in bytes, judged before it is parsed.
|
||||
public int MaxBodyBytes { get; init; } = 16 * 1024;
|
||||
|
||||
// What an `unavailable` document tells the client to wait.
|
||||
public int RetryAfterSeconds { get; init; } = 30;
|
||||
|
||||
public bool IsConfigured => Minds.Count > 0;
|
||||
}
|
||||
|
||||
public sealed class DeskMindOptions
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
// The full path of this Mind's socket on the peer-checked door. Bifrost only connects to it.
|
||||
public string Socket { get; init; } = string.Empty;
|
||||
|
||||
// Accounts that may answer this Mind's forks. Sent to Huginn with every call; no request sets or extends it.
|
||||
public List<string> Deciders { get; init; } = [];
|
||||
|
||||
// Huginn's bound on the list a call carries (fork-card.md: one to 64 ids).
|
||||
public const int MaxDeciders = 64;
|
||||
|
||||
// One to 64 ids, none repeated. The startup validator and the request path both ask this one question.
|
||||
public static bool DecidersWithinBounds(IReadOnlyCollection<string> deciders) =>
|
||||
deciders.Count is >= 1 and <= MaxDeciders && deciders.Distinct(StringComparer.Ordinal).Count() == deciders.Count;
|
||||
}
|
||||
|
||||
// The authority a stamped answer carries (fork-card.md: Operator, Steward or Member), from configuration because
|
||||
// Bifrost has no such roles: Operator is one account, Stewards a list, everyone else a Member.
|
||||
public sealed class DeskAttributionOptions
|
||||
{
|
||||
public string? Operator { get; init; }
|
||||
|
||||
public List<string> Stewards { get; init; } = [];
|
||||
|
||||
public string AuthorityOf(string accountId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Operator) && string.Equals(accountId, Operator, StringComparison.Ordinal))
|
||||
{
|
||||
return DeskAuthority.Operator;
|
||||
}
|
||||
|
||||
return Stewards.Contains(accountId, StringComparer.Ordinal) ? DeskAuthority.Steward : DeskAuthority.Member;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeskAuthority
|
||||
{
|
||||
public const string Operator = "Operator";
|
||||
public const string Steward = "Steward";
|
||||
public const string Member = "Member";
|
||||
}
|
||||
|
|
@ -16,15 +16,6 @@ public sealed class OidcIssuerOptions
|
|||
|
||||
public OidcClientsOptions Clients { get; init; } = new();
|
||||
|
||||
public OidcLimitsOptions Limits { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional path of the issuer's brake marker. While a file exists there, /connect/* answers 503 and nothing is issued;
|
||||
/// remove it to release. Checked on every request (one stat), so engaging and releasing need no restart.
|
||||
/// Unset: the issuer has no marker brake (removing this section and restarting is the other off switch).
|
||||
/// </summary>
|
||||
public string BrakePath { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Null when the section is absent.</summary>
|
||||
public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
|
|
@ -51,22 +42,3 @@ public sealed class OidcClientOptions
|
|||
/// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary>
|
||||
public string BackchannelLogoutUri { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One budget on /connect/authorize, /connect/token and /connect/userinfo together, per source address in one fixed window (a refused caller gets 429 with Retry-After),
|
||||
/// and the bound on what the anonymous authorize endpoint can park. No per-client budget: the client id is public. The defaults suit one client (MAS) behind one proxy.
|
||||
/// </summary>
|
||||
public sealed class OidcLimitsOptions
|
||||
{
|
||||
public int PerAddressPermits { get; init; } = 30;
|
||||
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxTrackedKeys { get; init; } = 10_000;
|
||||
|
||||
/// <summary>The most OpenIddict token rows (parked authorize requests, codes, access tokens) allowed to exist; a new authorize request beyond it is refused until the prune frees room.</summary>
|
||||
public int MaxTokenRows { get; init; } = 20_000;
|
||||
|
||||
/// <summary>How often expired and orphaned authorizations and tokens are deleted.</summary>
|
||||
public TimeSpan PruneInterval { get; init; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
/// <summary>Gates on the anonymous native registration (section <c>Bifrost:Registration</c>).</summary>
|
||||
public sealed class RegistrationOptions
|
||||
{
|
||||
public const string SectionName = "Bifrost:Registration";
|
||||
|
||||
public const int MinDifficulty = 1;
|
||||
|
||||
public const int MaxDifficulty = 24;
|
||||
|
||||
public const int MinFallbackWaitSeconds = 5;
|
||||
|
||||
public const int MaxFallbackWaitSeconds = 300;
|
||||
|
||||
public RegistrationCaptchaOptions Captcha { get; init; } = new();
|
||||
|
||||
public RegistrationLimitsOptions Limits { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One budget on GET /auth/bifrost/challenge and POST /auth/bifrost/register together, per source address in one fixed window.</summary>
|
||||
public sealed class RegistrationLimitsOptions
|
||||
{
|
||||
public int PerAddressPermits { get; init; } = 10;
|
||||
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxTrackedKeys { get; init; } = 10_000;
|
||||
|
||||
/// <summary>Time-lock submissions (no script, no proof of work) per address per <see cref="FallbackWindow"/>; far tighter than <see cref="PerAddressPermits"/> because they cost the client nothing.</summary>
|
||||
public int FallbackPermits { get; init; } = 3;
|
||||
|
||||
public TimeSpan FallbackWindow { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
public sealed class RegistrationCaptchaOptions
|
||||
{
|
||||
/// <summary>Leading zero bits the SHA-256 of <c>challenge:nonce</c> must have. 18 is about 262k hashes, a second or two in a browser.</summary>
|
||||
public int Difficulty { get; init; } = 18;
|
||||
|
||||
/// <summary>Seconds after the challenge was issued before a <c>waited</c> (no proof of work) submission is accepted.</summary>
|
||||
public int FallbackWaitSeconds { get; init; } = 30;
|
||||
}
|
||||
|
|
@ -43,7 +43,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
|
||||
|
||||
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
||||
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
||||
|
||||
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
||||
|
|
@ -323,15 +322,6 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ConsentReceipt>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique();
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindKey>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddMindKeys: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111090000_AddConsentReceipts")]
|
||||
public partial class AddConsentReceipts : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsentReceipts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
NoticeVersion = table.Column<int>(type: "integer", nullable: false),
|
||||
AcceptedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
WithdrawnAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsentReceipts", x => x.Id);
|
||||
table.ForeignKey("FK_ConsentReceipts_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts", columns: new[] { "UserAccountId", "NoticeVersion" }, unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "ConsentReceipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1440,32 +1440,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("VolunteerClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("AcceptedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("NoticeVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("WithdrawnAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserAccountId", "NoticeVersion")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ConsentReceipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -2168,17 +2142,6 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("WorkItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
|
|
|
|||
|
|
@ -363,8 +363,7 @@ public sealed class Membership
|
|||
|
||||
public sealed class RoleAssignment
|
||||
{
|
||||
// Left empty until saved: a key preset on a row added to an already-saved membership makes EF treat it as existing and fail the save.
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MembershipId { get; set; }
|
||||
|
||||
|
|
@ -1155,25 +1154,6 @@ public sealed class AuditEvent
|
|||
public UserAccount? ActorUserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets
|
||||
/// <see cref="WithdrawnAtUtc"/>; the row is never deleted, so what was agreed and when stays answerable.
|
||||
/// </summary>
|
||||
public sealed class ConsentReceipt
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public int NoticeVersion { get; set; }
|
||||
|
||||
public DateTimeOffset AcceptedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? WithdrawnAtUtc { get; set; }
|
||||
|
||||
public UserAccount? UserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
||||
public sealed class MindKey
|
||||
{
|
||||
|
|
|
|||
29
src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs
Normal file
29
src/Bifrost.Web/Features/ConceptVotes/CastRequestGuard.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous
|
||||
// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
|
||||
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
|
||||
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
|
||||
// are all refused. Header values are compared, never echoed.
|
||||
public static class CastRequestGuard
|
||||
{
|
||||
public static bool Allows(HttpRequest request, BifrostHostOptions host)
|
||||
{
|
||||
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var origin = request.Headers.Origin;
|
||||
if (request.Headers.ContainsKey("Origin"))
|
||||
{
|
||||
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var fetchSite = request.Headers["Sec-Fetch-Site"];
|
||||
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,18 +23,6 @@ public sealed record StanceCounts(int Up, int Down);
|
|||
|
||||
public sealed record ConceptTally(int Up, int Down, StanceCounts Members);
|
||||
|
||||
public enum RankOrder
|
||||
{
|
||||
Net,
|
||||
Total,
|
||||
Newest
|
||||
}
|
||||
|
||||
// One row of the ranked read: counts and the subject's title only, never a voter key, account id or network tag.
|
||||
public sealed record RankedSubject(string Key, string Title, ConceptTally Tally);
|
||||
|
||||
public sealed record RankedPage(IReadOnlyList<RankedSubject> Items, string? NextCursor);
|
||||
|
||||
public sealed record ConceptSubjectView(
|
||||
string Collection,
|
||||
string Key,
|
||||
|
|
@ -201,74 +189,6 @@ public sealed class ConceptAppraisalService(
|
|||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// The ranked read: live subjects of one collection ordered by net (up minus down), total (up plus down) or
|
||||
// registration time, largest first, ties by key ascending, so the order is total and a cursor names a position.
|
||||
// The cursor is keyset: the next page is whatever sorts after the last row's (value, key), so a vote landing
|
||||
// between pages can move rows but never makes a page repeat the cursor's own row. With unvoted and a client
|
||||
// address, subjects whose stored tag equals the client's are left out (no address filters nothing) ("not yet voted by this network"); the tag is only compared
|
||||
// inside the query and is never returned. Null when the collection has no live subject.
|
||||
public async Task<RankedPage?> GetRankedAsync(
|
||||
string collection,
|
||||
RankOrder order,
|
||||
int limit,
|
||||
RankCursor? after,
|
||||
bool unvoted,
|
||||
IPAddress? client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var live = await dbContext.ConceptSubjects.AsNoTracking()
|
||||
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
|
||||
.Select(x => new { x.Id, x.Key, x.Title, x.RegisteredAtUtc })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (live.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ids = live.Select(x => x.Id).ToList();
|
||||
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.SubjectId))
|
||||
.GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member })
|
||||
.Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var voted = new HashSet<Guid>();
|
||||
if (unvoted && client is not null)
|
||||
{
|
||||
var opts = options.Value;
|
||||
var tag = NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
|
||||
voted = (await dbContext.ConceptAppraisals.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.SubjectId) && x.NetworkTag == tag)
|
||||
.Select(x => x.SubjectId)
|
||||
.ToListAsync(cancellationToken)).ToHashSet();
|
||||
}
|
||||
|
||||
var rows = live
|
||||
.Where(s => !voted.Contains(s.Id))
|
||||
.Select(s =>
|
||||
{
|
||||
var tally = Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count)));
|
||||
var value = order switch
|
||||
{
|
||||
RankOrder.Net => (long)tally.Up - tally.Down,
|
||||
RankOrder.Total => (long)tally.Up + tally.Down,
|
||||
_ => s.RegisteredAtUtc.UtcTicks
|
||||
};
|
||||
return (Value: value, s.Key, s.Title, Tally: tally);
|
||||
})
|
||||
.Where(r => after is null || r.Value < after.Value || (r.Value == after.Value && string.CompareOrdinal(r.Key, after.Key) > 0))
|
||||
.OrderByDescending(r => r.Value)
|
||||
.ThenBy(r => r.Key, StringComparer.Ordinal)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var page = rows.Take(limit).ToList();
|
||||
var next = rows.Count > limit
|
||||
? new RankCursor(order, unvoted, page[^1].Value, page[^1].Key).Encode()
|
||||
: null;
|
||||
return new RankedPage(page.Select(r => new RankedSubject(r.Key, r.Title, r.Tally)).ToList(), next);
|
||||
}
|
||||
|
||||
public async Task<ConceptSubjectView?> GetSubjectAsync(
|
||||
string collection,
|
||||
string key,
|
||||
|
|
|
|||
|
|
@ -65,89 +65,4 @@ public static class ConceptTallyExport
|
|||
response.Headers.CacheControl = "public, max-age=60";
|
||||
return Results.Json(tallies);
|
||||
}).AllowAnonymous().RequireCors(CorsPolicy);
|
||||
|
||||
public const int DefaultPageSize = 50;
|
||||
public const int MaxPageSize = 100;
|
||||
|
||||
// The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked
|
||||
// ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor=<opaque> &unvoted=network
|
||||
// returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed
|
||||
// shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued
|
||||
// for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag
|
||||
// has already voted on; the tag is derived from the connection and compared inside the query, never accepted
|
||||
// from the request and never returned. That answer depends on the caller, so it is never cached.
|
||||
public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) =>
|
||||
app.MapGet(RoutePrefix + "/{collection}/ranked", async (
|
||||
string collection,
|
||||
HttpContext http,
|
||||
ConceptAppraisalService appraisals,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ConceptKey.IsValid(collection))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted))
|
||||
{
|
||||
return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var page = await appraisals.GetRankedAsync(
|
||||
collection, order, limit, cursor, unvoted, http.Connection.RemoteIpAddress, cancellationToken);
|
||||
if (page is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60";
|
||||
return JsonText(new
|
||||
{
|
||||
items = page.Items.Select(i => new
|
||||
{
|
||||
key = i.Key,
|
||||
title = i.Title,
|
||||
up = i.Tally.Up,
|
||||
down = i.Tally.Down,
|
||||
members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down }
|
||||
}),
|
||||
next_cursor = page.NextCursor
|
||||
}, StatusCodes.Status200OK);
|
||||
}).AllowAnonymous().RequireCors(CorsPolicy);
|
||||
|
||||
private static IResult JsonText(object body, int status) =>
|
||||
Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status);
|
||||
|
||||
internal static bool TryReadRankedQuery(
|
||||
IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted)
|
||||
{
|
||||
order = RankOrder.Net;
|
||||
limit = DefaultPageSize;
|
||||
cursor = null;
|
||||
unvoted = false;
|
||||
foreach (var (key, values) in query)
|
||||
{
|
||||
if (values.Count != 1 || values[0] is not { } value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "sort" when value == "net": order = RankOrder.Net; break;
|
||||
case "sort" when value == "total": order = RankOrder.Total; break;
|
||||
case "sort" when value == "newest": order = RankOrder.Newest; break;
|
||||
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var n) &&
|
||||
n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture):
|
||||
limit = n;
|
||||
break;
|
||||
case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break;
|
||||
case "unvoted" when value == "network": unvoted = true; break;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The opaque cursor of the ranked read: the last row's sort value and key, plus the order and filter it was issued
|
||||
// for, so a cursor cannot be replayed under another order or filter. Text "1|order|u|value|key", base64url without
|
||||
// padding; the length cap only bounds decoding work. Not signed: it names a position in public data and grants nothing. Decoding is strict and canonical (a
|
||||
// cursor that does not re-encode to the same text is refused), and every field is re-validated, so no cursor text
|
||||
// reaches a query unparsed.
|
||||
public sealed record RankCursor(RankOrder Order, bool Unvoted, long Value, string Key)
|
||||
{
|
||||
public const int MaxLength = 200;
|
||||
|
||||
public string Encode() =>
|
||||
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(
|
||||
string.Create(CultureInfo.InvariantCulture, $"1|{(int)Order}|{(Unvoted ? 1 : 0)}|{Value}|{Key}")));
|
||||
|
||||
public static bool TryDecode(string? text, out RankCursor cursor)
|
||||
{
|
||||
cursor = null!;
|
||||
if (string.IsNullOrEmpty(text) || text.Length > MaxLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string plain;
|
||||
try
|
||||
{
|
||||
var bytes = WebEncoders.Base64UrlDecode(text);
|
||||
plain = new UTF8Encoding(false, true).GetString(bytes);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = plain.Split('|');
|
||||
if (parts.Length != 5 || parts[0] != "1" ||
|
||||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var order) ||
|
||||
!Enum.IsDefined((RankOrder)order) ||
|
||||
parts[2] is not ("0" or "1") ||
|
||||
!long.TryParse(parts[3], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) ||
|
||||
!ConceptKey.IsValid(parts[4]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new RankCursor((RankOrder)order, parts[2] == "1", value, parts[4]);
|
||||
if (candidate.Encode() != text)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cursor = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The edge's judgment of an answer body (fork-card.md, answer_request, steps 1 and 2). It checks the shape only:
|
||||
// whether the answer is legal (a retraction's target, the choice, the fork) is Huginn's. A body that passes is
|
||||
// forwarded as the bytes received; the parsed request is returned for the door's context and for tests.
|
||||
public sealed record DeskBodyVerdict(AnswerRequest? Request, string? RefusalCode)
|
||||
{
|
||||
public static DeskBodyVerdict Pass(AnswerRequest request) => new(request, null);
|
||||
|
||||
public static DeskBodyVerdict Refuse(string code) => new(null, code);
|
||||
}
|
||||
|
||||
public static partial class DeskBodyShape
|
||||
{
|
||||
public const int MaxNoteBytes = 4000;
|
||||
|
||||
// Two keys the receiver alone stamps. Refused whole at any depth, never stripped.
|
||||
private static readonly string[] ForbiddenKeys = ["answerer", "authority"];
|
||||
|
||||
private static readonly string[] TopKeys =
|
||||
["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
|
||||
|
||||
private static readonly string[] AnswerKeys = ["choice", "note"];
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
|
||||
private static readonly JsonDocumentOptions ParseOptions = new() { MaxDepth = 32 };
|
||||
|
||||
public static DeskBodyVerdict Judge(ReadOnlySpan<byte> body, int maxBodyBytes)
|
||||
{
|
||||
if (body.Length > maxBodyBytes)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(body.ToArray(), ParseOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JudgeParsed(document.RootElement);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// A string the parser accepted but cannot decode: invalid UTF-8 or a lone surrogate.
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DeskBodyVerdict JudgeParsed(JsonElement root)
|
||||
{
|
||||
if (HasForbiddenKey(root))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyForbiddenField);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
var top = Members(root);
|
||||
if (top is null)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
// Unknown keys, including inside the answer object, before any value is judged.
|
||||
if (top.Keys.Any(key => !TopKeys.Contains(key, StringComparer.Ordinal)))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
|
||||
}
|
||||
|
||||
Dictionary<string, JsonElement>? answerMembers = null;
|
||||
if (top.TryGetValue("answer", out var answerElement) && answerElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
answerMembers = Members(answerElement);
|
||||
if (answerMembers is not null &&
|
||||
answerMembers.Keys.Any(key => !AnswerKeys.Contains(key, StringComparer.Ordinal)))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
|
||||
}
|
||||
}
|
||||
|
||||
return Shape(top, answerMembers);
|
||||
}
|
||||
|
||||
private static DeskBodyVerdict Shape(Dictionary<string, JsonElement> top, Dictionary<string, JsonElement>? answerMembers)
|
||||
{
|
||||
var invalid = DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
|
||||
// Map-mode: every key is written.
|
||||
if (TopKeys.Any(key => !top.ContainsKey(key)))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
if (!IsString(top["schemaVersion"], out var schemaVersion) || schemaVersion != DeskSchemas.AnswerRequest ||
|
||||
!IsString(top["fork_id"], out var forkId) || forkId.Length == 0 ||
|
||||
!IsString(top["options_hash"], out var optionsHash) || !Hex64().IsMatch(optionsHash) ||
|
||||
!IsString(top["answered_at"], out var answeredAt) || !Instant().IsMatch(answeredAt) ||
|
||||
!IsString(top["request_id"], out var requestId) || !RequestId().IsMatch(requestId))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var retractedElement = top["retracted"];
|
||||
if (retractedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var retracted = retractedElement.ValueKind == JsonValueKind.True;
|
||||
|
||||
string? retracts = null;
|
||||
if (top["retracts"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(top["retracts"], out var target) || !AnswerId().IsMatch(target))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
retracts = target;
|
||||
}
|
||||
|
||||
AnswerObject? answer = null;
|
||||
if (top["answer"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (answerMembers is null || AnswerKeys.Any(key => !answerMembers.ContainsKey(key)))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
string? choice = null;
|
||||
if (answerMembers["choice"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(answerMembers["choice"], out var picked) || picked.Length == 0)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
choice = picked;
|
||||
}
|
||||
|
||||
string? note = null;
|
||||
if (answerMembers["note"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(answerMembers["note"], out var words) || !IsWords(words))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
note = words;
|
||||
}
|
||||
|
||||
if (choice is null && note is null)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
answer = new AnswerObject(choice, note);
|
||||
}
|
||||
|
||||
// No answer and no retraction is no request. The illegal retraction forms (a target-less retraction, a
|
||||
// retraction carrying an answer, a target on a plain answer) pass here: Huginn stores them as bad_retraction.
|
||||
if (answer is null && !retracted)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
return DeskBodyVerdict.Pass(new AnswerRequest(
|
||||
schemaVersion, forkId, optionsHash, answer, retracted, retracts, answeredAt, requestId));
|
||||
}
|
||||
|
||||
// A note is 1 to 4000 UTF-8 bytes and not whitespace only. Bytes, not characters or UTF-16 units.
|
||||
private static bool IsWords(string value)
|
||||
{
|
||||
int bytes;
|
||||
try
|
||||
{
|
||||
bytes = StrictUtf8.GetByteCount(value);
|
||||
}
|
||||
catch (EncoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return bytes is >= 1 and <= MaxNoteBytes && !value.All(IsSchemaSpace);
|
||||
}
|
||||
|
||||
// The whitespace set of the schema's `words` pattern.
|
||||
private static bool IsSchemaSpace(char c) =>
|
||||
c is (>= (char)0x09 and <= (char)0x0d) or (char)0x20 or (char)0x85 or (char)0xa0 or (char)0x1680
|
||||
or (>= (char)0x2000 and <= (char)0x200a) or (char)0x2028 or (char)0x2029 or (char)0x202f or (char)0x205f or (char)0x3000;
|
||||
|
||||
private static bool IsString(JsonElement element, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = element.GetString() ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Object members by unescaped name; null when a name repeats, because the forwarded bytes would then mean
|
||||
// different things to different parsers.
|
||||
private static Dictionary<string, JsonElement>? Members(JsonElement element)
|
||||
{
|
||||
var members = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!members.TryAdd(property.Name, property.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
private static bool HasForbiddenKey(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (ForbiddenKeys.Contains(property.Name, StringComparer.Ordinal) || HasForbiddenKey(property.Value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
case JsonValueKind.Array:
|
||||
return element.EnumerateArray().Any(HasForbiddenKey);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{64}\z")]
|
||||
private static partial Regex Hex64();
|
||||
|
||||
[GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?Z\z")]
|
||||
private static partial Regex Instant();
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")]
|
||||
private static partial Regex RequestId();
|
||||
|
||||
[GeneratedRegex(@"^n[1-9][0-9]*\z")]
|
||||
private static partial Regex AnswerId();
|
||||
}
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
|
||||
// (commit de8a062a) spells them: snake_case keys, every key written (nil where absent), declared in the
|
||||
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
|
||||
// ForkCard, DeskAnswerDocument and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
|
||||
|
||||
public static class DeskSchemas
|
||||
{
|
||||
public const string AnswerRequest = "gamecult.fork.answer_request.v0";
|
||||
public const string AnswerResult = "gamecult.fork.answer_result.v0";
|
||||
public const string Card = "gamecult.fork.card.v0";
|
||||
public const string DeskAnswer = "gamecult.desk_answer.v0";
|
||||
public const string Refusal = "gamecult.fork.refusal.v0";
|
||||
public const string Unavailable = "gamecult.fork.unavailable.v0";
|
||||
}
|
||||
|
||||
public static class DeskJson
|
||||
{
|
||||
// Defaults write nulls, which map-mode needs ("every key written").
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
// The documents are not HTML: keep apostrophes and non-ASCII text as written.
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record AnswerObject(
|
||||
[property: JsonPropertyName("choice")] string? Choice,
|
||||
[property: JsonPropertyName("note")] string? Note);
|
||||
|
||||
public sealed record AnswerRequest(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("answer")] AnswerObject? Answer,
|
||||
[property: JsonPropertyName("retracted")] bool Retracted,
|
||||
[property: JsonPropertyName("retracts")] string? Retracts,
|
||||
[property: JsonPropertyName("answered_at")] string AnsweredAt,
|
||||
[property: JsonPropertyName("request_id")] string RequestId);
|
||||
|
||||
public sealed record DeskRefusal(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("error")] string Error);
|
||||
|
||||
public sealed record DeskUnavailable(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("error")] string Error,
|
||||
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
|
||||
|
||||
public sealed record ForkOption(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("text")] string Text);
|
||||
|
||||
public sealed record ForkRef(
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("id")] string Id);
|
||||
|
||||
public sealed record ForkCard(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("title")] string Title,
|
||||
[property: JsonPropertyName("context")] string Context,
|
||||
[property: JsonPropertyName("options")] ForkOption[] Options,
|
||||
[property: JsonPropertyName("recommendation")] string Recommendation,
|
||||
[property: JsonPropertyName("depends")] string[] Depends,
|
||||
[property: JsonPropertyName("leans")] string? Leans,
|
||||
[property: JsonPropertyName("flag")] string? Flag,
|
||||
[property: JsonPropertyName("raised_in")] ForkRef? RaisedIn,
|
||||
[property: JsonPropertyName("deciders")] string[] Deciders,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("ruling")] string? Ruling,
|
||||
[property: JsonPropertyName("ruled_from")] string[] RuledFrom,
|
||||
[property: JsonPropertyName("order")] long Order,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash);
|
||||
|
||||
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
|
||||
public sealed record DeskAnswerDocument(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("answer_id")] string AnswerId,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("outcome")] string Outcome,
|
||||
[property: JsonPropertyName("of")] string? Of,
|
||||
[property: JsonPropertyName("retracts")] string? Retracts,
|
||||
[property: JsonPropertyName("code")] string? Code,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("choice")] string? Choice,
|
||||
[property: JsonPropertyName("note")] string? Note,
|
||||
[property: JsonPropertyName("answered_at")] string AnsweredAt,
|
||||
[property: JsonPropertyName("received_at")] string ReceivedAt,
|
||||
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
|
||||
[property: JsonPropertyName("answerer")] string Answerer,
|
||||
[property: JsonPropertyName("authority")] string Authority,
|
||||
[property: JsonPropertyName("request_id")] string RequestId);
|
||||
|
||||
public sealed record ForkRecord(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
|
||||
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
|
||||
|
||||
public sealed record ForkEntry(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("card")] ForkCard Card,
|
||||
[property: JsonPropertyName("record")] ForkRecord Record);
|
||||
|
||||
public sealed record ForkList(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("mind")] string Mind,
|
||||
[property: JsonPropertyName("stamp")] string Stamp,
|
||||
[property: JsonPropertyName("open_count")] int OpenCount,
|
||||
[property: JsonPropertyName("recorded_count")] int RecordedCount,
|
||||
[property: JsonPropertyName("forks")] ForkEntry[] Forks,
|
||||
[property: JsonPropertyName("next_cursor")] string? NextCursor);
|
||||
|
||||
public sealed record AnswerResult(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
|
||||
[property: JsonPropertyName("card")] ForkCard? Card,
|
||||
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
|
||||
|
||||
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).
|
||||
public static class DeskRefusals
|
||||
{
|
||||
public const string Unauthenticated = "unauthenticated";
|
||||
public const string BodyForbiddenField = "body_forbidden_field";
|
||||
public const string BodyUnknownField = "body_unknown_field";
|
||||
public const string BodyInvalid = "body_invalid";
|
||||
public const string ForkUnknown = "fork_unknown";
|
||||
public const string InsufficientScope = "insufficient_scope";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string RequestInvalid = "request_invalid";
|
||||
public const string RequestIdConflict = "request_id_conflict";
|
||||
|
||||
private static readonly Dictionary<string, (int Status, string Error)> Table = new(StringComparer.Ordinal)
|
||||
{
|
||||
[Unauthenticated] = (401, "unauthenticated: a verified session is required"),
|
||||
[BodyForbiddenField] = (400, "body_forbidden_field: answerer and authority are set by the receiver"),
|
||||
[BodyUnknownField] = (400, "body_unknown_field: the body is a closed shape"),
|
||||
[BodyInvalid] = (400, "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0"),
|
||||
[ForkUnknown] = (404, "fork_unknown: no such fork"),
|
||||
[InsufficientScope] = (403, "insufficient_scope: the token lacks the scope this route needs"),
|
||||
[Forbidden] = (403, "forbidden: only Active members may use the desk"),
|
||||
[RequestInvalid] = (400, "request_invalid: the request does not satisfy its document"),
|
||||
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
|
||||
};
|
||||
|
||||
// fork_too_large names its fork, so it is built here, not read from the table; a bare door code of that name is
|
||||
// never a refusal. The texts are the pinned vectors' (alone is past the response budget).
|
||||
public const string ForkTooLarge = "fork_too_large";
|
||||
|
||||
public static int ForkTooLargeStatus => 422;
|
||||
|
||||
// A list page whose next fork alone is past the budget: the client continues with cursor c<order>; at order 0
|
||||
// it has reached the end.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} (order {order}) alone is past the response budget");
|
||||
|
||||
// A read of such a fork: nothing to page, so no order.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} alone is past the response budget");
|
||||
|
||||
public static bool IsKnown(string code) => Table.ContainsKey(code);
|
||||
|
||||
public static int StatusOf(string code) => Table[code].Status;
|
||||
|
||||
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);
|
||||
}
|
||||
|
||||
public static class DeskUnavailables
|
||||
{
|
||||
public const int Status = 503;
|
||||
|
||||
public static DeskUnavailable Document(int? retryAfterSeconds) => new(
|
||||
DeskSchemas.Unavailable,
|
||||
"unavailable",
|
||||
"unavailable: the outcome is not confirmed, try again with the same request_id",
|
||||
retryAfterSeconds);
|
||||
}
|
||||
|
||||
// Notes are returned only to deciders. For everyone else Bifrost nils every note at the door: the key stays written
|
||||
// (every key written, nil where absent), so the stripped body is still the contract's shape. Cards, outcomes, codes
|
||||
// and counts are untouched; Huginn returns the full record.
|
||||
public static class DeskNotes
|
||||
{
|
||||
public static ForkEntry Strip(ForkEntry entry) =>
|
||||
entry with { Record = entry.Record with { Answers = [.. entry.Record.Answers.Select(a => a with { Note = null })] } };
|
||||
|
||||
public static ForkList Strip(ForkList list) => list with { Forks = [.. list.Forks.Select(Strip)] };
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Oidc;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork desk's HTTP edge (fork-card.md, Transport). One catch-all under /api/desk judges, in this order and no
|
||||
// other: the raw target, the bearer, its audience, its scope, Active membership (the body is unread), the body's
|
||||
// shape, the {mind} lookup, the door. Every refusal is a contract document. Bifrost holds no fork state: it passes
|
||||
// Huginn's outcome through, attaches the current card to a stale_fork answer, and says `unavailable` when the door
|
||||
// cannot confirm an outcome.
|
||||
public static class DeskEndpoints
|
||||
{
|
||||
public const string RoutePrefix = "/api/desk";
|
||||
public const string Audience = "bifrost-desk";
|
||||
public const string ReadScope = "desk.read";
|
||||
public const string AnswerScope = "desk.answer";
|
||||
public const string CorsPolicy = "DeskCors";
|
||||
|
||||
public static IServiceCollection AddBifrostDesk(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
|
||||
services.AddCors();
|
||||
// Exactly the configured origin, no credentials, and only on the routes that require this policy.
|
||||
services
|
||||
.AddOptions<CorsOptions>()
|
||||
.Configure<IOptions<DeskOptions>>((cors, desk) =>
|
||||
cors.AddPolicy(
|
||||
CorsPolicy,
|
||||
policy => policy
|
||||
.WithOrigins(desk.Value.AllowedOrigin)
|
||||
.WithMethods("GET", "POST")
|
||||
.WithHeaders(HeaderNames.Authorization, HeaderNames.ContentType)));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IEndpointConventionBuilder MapDesk(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// A configured desk with no credential or door port would serve nothing true: stop startup instead.
|
||||
app.ServiceProvider.GetRequiredService<IDeskCredential>();
|
||||
app.ServiceProvider.GetRequiredService<IDeskDoor>();
|
||||
|
||||
return app.MapMethods(RoutePrefix + "/{**rest}", ["GET", "POST"], HandleAsync)
|
||||
.RequireCors(CorsPolicy)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static async Task HandleAsync(
|
||||
HttpContext http,
|
||||
IDeskCredential credential,
|
||||
IDeskDoor door,
|
||||
DeskMindRoutes minds,
|
||||
IOptions<DeskOptions> options,
|
||||
BifrostDbContext db)
|
||||
{
|
||||
var cancellationToken = http.RequestAborted;
|
||||
var desk = options.Value;
|
||||
http.Response.Headers.Append(HeaderNames.Vary, "Origin");
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
|
||||
// 1. The raw target, never the decoded Request.Path.
|
||||
var rawTarget = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestFeature>()?.RawTarget ?? string.Empty;
|
||||
var route = DeskRoute.Parse(http.Request.Method, rawTarget);
|
||||
if (route is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2-4. Bearer, audience, scope.
|
||||
DeskToken? token;
|
||||
try
|
||||
{
|
||||
token = await credential.AuthenticateAsync(http, cancellationToken);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The bearer could not be judged: neither a pass nor a refusal of the caller.
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.Unauthenticated);
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = route.Kind == DeskRouteKind.Answer ? AnswerScope : ReadScope;
|
||||
if (!token.Scopes.Contains(scope, StringComparer.Ordinal))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.InsufficientScope);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Active membership, re-read now; the body has not been touched.
|
||||
Guid? accountId;
|
||||
try
|
||||
{
|
||||
accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountId is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.Forbidden);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. The request's shape. An answer's bytes are the bytes forwarded; list and read judge their query.
|
||||
byte[] body = [];
|
||||
DeskListRequest? listRequest = null;
|
||||
var forkId = string.Empty;
|
||||
if (route.Kind == DeskRouteKind.Answer)
|
||||
{
|
||||
var read = await ReadBodyAsync(http.Request, desk.MaxBodyBytes, cancellationToken);
|
||||
var verdict = read is null
|
||||
? DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid)
|
||||
: DeskBodyShape.Judge(read, desk.MaxBodyBytes);
|
||||
if (verdict.RefusalCode is not null)
|
||||
{
|
||||
await WriteRefusalAsync(http, verdict.RefusalCode);
|
||||
return;
|
||||
}
|
||||
|
||||
body = read!;
|
||||
}
|
||||
else if (route.Kind == DeskRouteKind.Read
|
||||
? !DeskQuery.TryRead(route.RawQuery, route.RawFork!, out forkId)
|
||||
: !DeskQuery.TryList(route.RawQuery, string.Empty, out listRequest))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.RequestInvalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. The {mind} lookup: a miss is the same document as an unknown fork.
|
||||
var mind = minds.Resolve(route.RawMind);
|
||||
if (mind is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8-10. The door. A decider list outside 1..64 unique ids is a configuration fault: nothing is sent.
|
||||
if (!DeskMindOptions.DecidersWithinBounds(mind.Deciders))
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = accountId.Value.ToString("D");
|
||||
var context = new DeskDoorContext(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray());
|
||||
try
|
||||
{
|
||||
switch (route.Kind)
|
||||
{
|
||||
case DeskRouteKind.List:
|
||||
var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken);
|
||||
if (listed is DeskListOutcome.Listed(var list))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskNotes.Strip(list));
|
||||
}
|
||||
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
|
||||
{
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeId, largeOrder));
|
||||
}
|
||||
else if (listed is DeskListOutcome.NotListed(var listCode) && DoorMayRefuse(listCode) && listCode != DeskRefusals.RequestIdConflict)
|
||||
{
|
||||
await WriteRefusalAsync(http, ReadListCode(listCode));
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
|
||||
return;
|
||||
case DeskRouteKind.Read:
|
||||
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
|
||||
if (found is DeskReadOutcome.Found(var entry))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskNotes.Strip(entry));
|
||||
}
|
||||
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
|
||||
{
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
|
||||
}
|
||||
else if (found is DeskReadOutcome.NotFound(var readCode) && DoorMayRefuse(readCode) && readCode != DeskRefusals.RequestIdConflict)
|
||||
{
|
||||
await WriteRefusalAsync(http, ReadListCode(readCode));
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
await AnswerAsync(http, door, mind, context, body, desk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnswerAsync(
|
||||
HttpContext http, IDeskDoor door, DeskMindOptions mind, DeskDoorContext context, byte[] body, DeskOptions desk)
|
||||
{
|
||||
var cancellationToken = http.RequestAborted;
|
||||
var outcome = await door.AnswerAsync(mind.Socket, context, body, cancellationToken);
|
||||
switch (outcome)
|
||||
{
|
||||
case DeskAnswerOutcome.NotStored(var code) when DoorMayRefuse(code):
|
||||
await WriteRefusalAsync(http, code);
|
||||
return;
|
||||
case DeskAnswerOutcome.Answered(var result) when DeskStatus.Of(result) is { } status:
|
||||
// A stale_fork answer, fresh or replayed, carries the CURRENT card: nothing is stored for it.
|
||||
if (string.Equals(result.Answer.Code, DeskStatus.StaleFork, StringComparison.Ordinal))
|
||||
{
|
||||
var current = await door.ReadAsync(mind.Socket, context, result.Answer.ForkId, cancellationToken);
|
||||
if (current is DeskReadOutcome.ForkTooLarge(var largeFork))
|
||||
{
|
||||
// Nothing is recorded for a stale answer, and the card it would carry cannot be returned: permanent.
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
|
||||
return;
|
||||
}
|
||||
|
||||
if (current is not DeskReadOutcome.Found(var entry))
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
result = result with { Card = entry.Card };
|
||||
}
|
||||
|
||||
await WriteJsonAsync(http, status, result);
|
||||
return;
|
||||
default:
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A note is a decider's own words: only a caller on the admin-set list reads them (visibility ruling c).
|
||||
private static bool IsDecider(DeskDoorContext context) =>
|
||||
context.Deciders.Contains(context.Subject, StringComparer.Ordinal);
|
||||
|
||||
// Codes the door may hand back for nothing stored. The three the edge alone judges are never the door's.
|
||||
private static bool DoorMayRefuse(string code) =>
|
||||
code is not (DeskRefusals.Unauthenticated or DeskRefusals.InsufficientScope or DeskRefusals.Forbidden) &&
|
||||
DeskRefusals.IsKnown(code);
|
||||
|
||||
// request_id_conflict is the answer's alone (Libby): a read or list carries no request_id, so the door saying it
|
||||
// there is a door fault and the edge says `unavailable`.
|
||||
// A read or list sends no body. A door that refuses one for its body (an `authority` field among them) is
|
||||
// refusing the request, which is request_invalid; it is not an outage.
|
||||
private static string ReadListCode(string code) =>
|
||||
code is DeskRefusals.BodyForbiddenField or DeskRefusals.BodyUnknownField or DeskRefusals.BodyInvalid
|
||||
? DeskRefusals.RequestInvalid
|
||||
: code;
|
||||
|
||||
// At most `cap` bytes. Null when the body is longer, judged by Content-Length first and then while reading.
|
||||
private static async Task<byte[]?> ReadBodyAsync(HttpRequest request, int cap, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ContentLength > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[4096];
|
||||
int count;
|
||||
while ((count = await request.Body.ReadAsync(chunk, cancellationToken)) > 0)
|
||||
{
|
||||
if (buffer.Length + count > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, count);
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static Task WriteRefusalAsync(HttpContext http, string code) =>
|
||||
WriteJsonAsync(http, DeskRefusals.StatusOf(code), DeskRefusals.Document(code));
|
||||
|
||||
private static async Task WriteUnavailableAsync(HttpContext http, DeskOptions desk)
|
||||
{
|
||||
http.Response.Headers.RetryAfter = desk.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
await WriteJsonAsync(http, DeskUnavailables.Status, DeskUnavailables.Document(desk.RetryAfterSeconds));
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync<T>(HttpContext http, int status, T document)
|
||||
{
|
||||
http.Response.StatusCode = status;
|
||||
http.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(http.Response.Body, document, DeskJson.Options, http.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
// The one place a stored outcome becomes an HTTP status. fork-card.md prose says 200 for any stored outcome; the
|
||||
// vectors (results[].httpStatus) say a Refused answer carries its refusal's status, and the vectors are the check.
|
||||
public static class DeskStatus
|
||||
{
|
||||
public const string StaleFork = "stale_fork";
|
||||
|
||||
private static readonly Dictionary<string, int> RefusedStatus = new(StringComparer.Ordinal)
|
||||
{
|
||||
[StaleFork] = 409,
|
||||
["not_a_decider"] = 403,
|
||||
["fork_closed"] = 409,
|
||||
["choice_invalid"] = 400,
|
||||
["bad_retraction"] = 400,
|
||||
["already_retracted"] = 409,
|
||||
};
|
||||
|
||||
// Null when the door sent an outcome or a refusal code this contract does not name: Bifrost then cannot vouch
|
||||
// for it and says `unavailable`.
|
||||
public static int? Of(AnswerResult result) => result.Answer.Outcome switch
|
||||
{
|
||||
"Counted" or "Revision" or "Retraction" => 200,
|
||||
"Refused" when result.Answer.Code is { } code && RefusedStatus.TryGetValue(code, out var status) => status,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public enum DeskRouteKind
|
||||
{
|
||||
List,
|
||||
Read,
|
||||
Answer,
|
||||
}
|
||||
|
||||
public sealed record DeskRoute(DeskRouteKind Kind, string RawMind, string? RawFork, string RawQuery)
|
||||
{
|
||||
// The raw request target against the three routes. Segments stay percent-encoded: {mind} is decoded once, by
|
||||
// the resolver, and nothing here normalises a dot segment. Any other shape is not a desk route.
|
||||
public static DeskRoute? Parse(string method, string rawTarget)
|
||||
{
|
||||
var path = rawTarget;
|
||||
var query = string.Empty;
|
||||
var split = path.IndexOf('?');
|
||||
if (split >= 0)
|
||||
{
|
||||
query = path[(split + 1)..];
|
||||
path = path[..split];
|
||||
}
|
||||
|
||||
if (!path.StartsWith(DeskEndpoints.RoutePrefix + "/", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path[(DeskEndpoints.RoutePrefix.Length + 1)..].Split('/');
|
||||
return (method, segments) switch
|
||||
{
|
||||
("GET", [var mind, "forks"]) => new DeskRoute(DeskRouteKind.List, mind, null, query),
|
||||
("GET", [var mind, "forks", var fork]) => new DeskRoute(DeskRouteKind.Read, mind, fork, query),
|
||||
("POST", [var mind, "answers"]) => new DeskRoute(DeskRouteKind.Answer, mind, null, query),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The list and read requests the query string and path stand for (fork-card.md, List and Read). Nothing in a
|
||||
// query sets a decider, an answerer or a Mind; an unknown key, a repeated key or a bad value is request_invalid.
|
||||
public static class DeskQuery
|
||||
{
|
||||
public const int MaxLimit = 50;
|
||||
|
||||
public static bool TryList(string rawQuery, string mind, out DeskListRequest request)
|
||||
{
|
||||
request = new DeskListRequest(mind, null, null, MaxLimit);
|
||||
if (!TryPairs(rawQuery, out var pairs))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? status = null, cursor = null;
|
||||
var limit = MaxLimit;
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "status" when value is "open" or "recorded":
|
||||
status = value;
|
||||
break;
|
||||
case "status" when value.Length == 0:
|
||||
break;
|
||||
case "cursor" when IsCursor(value):
|
||||
cursor = value;
|
||||
break;
|
||||
case "cursor" when value.Length == 0:
|
||||
break;
|
||||
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n) && n <= MaxLimit:
|
||||
limit = n;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
request = new DeskListRequest(mind, status, cursor, limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The read request has no query at all.
|
||||
public static bool TryRead(string rawQuery, string rawFork, out string forkId)
|
||||
{
|
||||
forkId = string.Empty;
|
||||
return rawQuery.Length == 0 && DeskMindRoutes.TryDecode(rawFork, out forkId) && forkId.Length > 0;
|
||||
}
|
||||
|
||||
// c<N>, N a positive integer without a leading zero.
|
||||
private static bool IsCursor(string value) =>
|
||||
value.Length > 1 && value[0] == 'c' && value[1] is >= '1' and <= '9' && value.Skip(2).All(char.IsAsciiDigit);
|
||||
|
||||
private static bool TryPairs(string rawQuery, out List<(string Key, string Value)> pairs)
|
||||
{
|
||||
pairs = [];
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (rawQuery.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var part in rawQuery.Split('&'))
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
var rawKey = eq < 0 ? part : part[..eq];
|
||||
var rawValue = eq < 0 ? string.Empty : part[(eq + 1)..];
|
||||
if (!DeskMindRoutes.TryDecode(rawKey, out var key) || !DeskMindRoutes.TryDecode(rawValue, out var value) || !seen.Add(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pairs.Add((key, value));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
using System.Text;
|
||||
using Bifrost.Web.Configuration;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The {mind} segment of a desk route (fork-card.md M1). The raw segment is percent-decoded once and compared,
|
||||
// ordinally, with the configured ids: no case folding, trimming or normalisation, and a decoding failure is a miss.
|
||||
// The segment is never joined into a path, a socket name or a query, so a value off the list opens nothing.
|
||||
public sealed class DeskMindRoutes
|
||||
{
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
|
||||
private readonly Dictionary<string, DeskMindOptions> byId;
|
||||
|
||||
public DeskMindRoutes(IEnumerable<DeskMindOptions> minds)
|
||||
{
|
||||
byId = new Dictionary<string, DeskMindOptions>(StringComparer.Ordinal);
|
||||
foreach (var mind in minds)
|
||||
{
|
||||
byId.Add(mind.Id, mind);
|
||||
}
|
||||
}
|
||||
|
||||
public DeskMindOptions? Resolve(string rawSegment) =>
|
||||
TryDecode(rawSegment, out var id) && byId.TryGetValue(id, out var mind) ? mind : null;
|
||||
|
||||
// One pass of percent-decoding. Uri.UnescapeDataString is not used: it leaves a bad escape in place and turns
|
||||
// bytes that are not UTF-8 into U+FFFD instead of failing.
|
||||
internal static bool TryDecode(string raw, out string decoded)
|
||||
{
|
||||
decoded = string.Empty;
|
||||
var bytes = new List<byte>(raw.Length);
|
||||
for (var i = 0; i < raw.Length; i++)
|
||||
{
|
||||
var c = raw[i];
|
||||
if (c == '%')
|
||||
{
|
||||
if (i + 2 >= raw.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryHex(raw[i + 1], out var high) || !TryHex(raw[i + 2], out var low))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes.Add((byte)((high << 4) | low));
|
||||
i += 2;
|
||||
}
|
||||
else if (c > 0x7f)
|
||||
{
|
||||
// A raw request target is ASCII; anything else is not a segment this desk issues.
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes.Add((byte)c);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
decoded = StrictUtf8.GetString(bytes.ToArray());
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryHex(char c, out int value)
|
||||
{
|
||||
value = c switch
|
||||
{
|
||||
>= '0' and <= '9' => c - '0',
|
||||
>= 'a' and <= 'f' => c - 'a' + 10,
|
||||
>= 'A' and <= 'F' => c - 'A' + 10,
|
||||
_ => -1,
|
||||
};
|
||||
return value >= 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The two ports the desk stands on. Bifrost owns the edge's order and judgments; these two own what is not
|
||||
// Bifrost's yet: validating a bearer token (the OIDC validation scheme) and speaking to a Mind's Huginn on the
|
||||
// peer-checked door. No production implementation is registered here: with the Desk section configured and a port
|
||||
// missing, startup fails (DeskEndpoints.MapDesk) instead of serving.
|
||||
|
||||
// What a validated bearer token claims. The credential port verifies the token; the desk judges audience, scope and
|
||||
// membership. A null Subject or Audience means the token did not carry one.
|
||||
public sealed record DeskToken(string? Subject, string? Audience, IReadOnlyCollection<string> Scopes);
|
||||
|
||||
public interface IDeskCredential
|
||||
{
|
||||
// Null when the request carries no valid bearer token.
|
||||
ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
// The envelope sent with every answer, read and list call (fork-card.md M5), under the contract's names, built by
|
||||
// Bifrost alone: the route's configured Mind id, the verified account's authority (Operator, Steward or Member),
|
||||
// the account id, and the admin-set decider list of that Mind (1 to 64 unique ids). Nothing in the request sets any
|
||||
// of it, and Huginn keeps no copy of the list.
|
||||
public sealed record DeskDoorContext(string Mind, string Authority, string Subject, IReadOnlyList<string> Deciders);
|
||||
|
||||
public abstract record DeskAnswerOutcome
|
||||
{
|
||||
// Huginn stored an outcome (Counted, Revision, Retraction or Refused) and returned its document.
|
||||
public sealed record Answered(AnswerResult Result) : DeskAnswerOutcome;
|
||||
|
||||
// Nothing stored: Huginn refused with one of the contract's refusal codes.
|
||||
public sealed record NotStored(string Code) : DeskAnswerOutcome;
|
||||
}
|
||||
|
||||
public abstract record DeskReadOutcome
|
||||
{
|
||||
public sealed record Found(ForkEntry Entry) : DeskReadOutcome;
|
||||
|
||||
public sealed record NotFound(string Code) : DeskReadOutcome;
|
||||
|
||||
// The fork is too large to return: permanent, and nothing to page, so no order or cursor.
|
||||
public sealed record ForkTooLarge(string ForkId) : DeskReadOutcome;
|
||||
}
|
||||
|
||||
// The list request the desk's query string stands for. Mind is the route's, resolved against the configured list.
|
||||
public sealed record DeskListRequest(string Mind, string? Status, string? Cursor, int Limit);
|
||||
|
||||
public abstract record DeskListOutcome
|
||||
{
|
||||
public sealed record Listed(ForkList List) : DeskListOutcome;
|
||||
|
||||
public sealed record NotListed(string Code) : DeskListOutcome;
|
||||
|
||||
// The next fork is too large for any page: the page holds nothing past the cursor. The client continues with
|
||||
// cursor c<Order>; at Order 0 it has reached the end. A read of such a fork has its own outcome (no order).
|
||||
public sealed record ForkTooLarge(string ForkId, int Order) : DeskListOutcome;
|
||||
}
|
||||
|
||||
public interface IDeskDoor
|
||||
{
|
||||
// The body is exactly the bytes the client sent. Any exception means the outcome is not confirmed.
|
||||
Task<DeskAnswerOutcome> AnswerAsync(
|
||||
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken);
|
||||
|
||||
// The entry for one fork, its card built and hashed by Huginn.
|
||||
Task<DeskReadOutcome> ReadAsync(
|
||||
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken);
|
||||
|
||||
// A page of entries, the counts and the stamp, as Huginn projects them for this viewer.
|
||||
Task<DeskListOutcome> ListAsync(
|
||||
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
|
@ -319,8 +319,7 @@ public sealed class HeimdallAuthService(
|
|||
UserAccountId = userAccount.Id,
|
||||
CreatedAtUtc = now
|
||||
};
|
||||
// A suspension is an admin's decision: the claim neither lifts it nor, absent, imposes it.
|
||||
if (grantMemberAccess && userAccount.Membership.Status != MembershipStatus.Suspended)
|
||||
if (grantMemberAccess)
|
||||
{
|
||||
userAccount.Membership.Status = MembershipStatus.Active;
|
||||
userAccount.Membership.ApprovedAtUtc ??= now;
|
||||
|
|
@ -333,7 +332,7 @@ public sealed class HeimdallAuthService(
|
|||
userAccount,
|
||||
displayName,
|
||||
cancellationToken);
|
||||
if (grantMemberAccess && userAccount.Membership!.Status == MembershipStatus.Active)
|
||||
if (grantMemberAccess)
|
||||
{
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
userAccount.Membership!,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ public sealed partial class BifrostIdentityService(
|
|||
BifrostDbContext dbContext,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
/// <summary>
|
||||
/// The one rule set for a new native identity: well formed, not reserved, not taken. Returns the normalized identity or
|
||||
/// throws <see cref="BifrostIdentityException"/>. Spends nothing; the unique index at insert stays the race backstop.
|
||||
/// </summary>
|
||||
public async Task<string> CheckAvailableAsync(string identity, CancellationToken cancellationToken)
|
||||
public async Task<UserAccount> RegisterNativeAsync(
|
||||
string identity,
|
||||
string? displayName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedIdentity = NormalizeIdentity(identity);
|
||||
if (IsReserved(normalizedIdentity))
|
||||
|
|
@ -24,22 +23,14 @@ public sealed partial class BifrostIdentityService(
|
|||
throw new BifrostIdentityException("That Bifrost identity is reserved.");
|
||||
}
|
||||
|
||||
if (await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken))
|
||||
var exists = await dbContext.UserAccounts.AnyAsync(
|
||||
x => x.NormalizedBifrostIdentity == normalizedIdentity,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
throw new BifrostIdentityException("That Bifrost identity is already registered.");
|
||||
}
|
||||
|
||||
return normalizedIdentity;
|
||||
}
|
||||
|
||||
public async Task<UserAccount> RegisterNativeAsync(
|
||||
string identity,
|
||||
string? displayName,
|
||||
int noticeVersion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken);
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var publicIdentity = ToPublicIdentity(normalizedIdentity);
|
||||
var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName)
|
||||
|
|
@ -66,39 +57,11 @@ public sealed partial class BifrostIdentityService(
|
|||
}
|
||||
};
|
||||
|
||||
// One SaveChanges: no account exists without the receipt of the notice it accepted.
|
||||
dbContext.UserAccounts.Add(userAccount);
|
||||
dbContext.ConsentReceipts.Add(new ConsentReceipt
|
||||
{
|
||||
UserAccount = userAccount,
|
||||
NoticeVersion = noticeVersion,
|
||||
AcceptedAtUtc = now
|
||||
});
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Only a lost race on the unique index is the caller's mistake; anything else is a fault and propagates.
|
||||
if (!await IdentityTakenAsync(normalizedIdentity))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new BifrostIdentityException("That Bifrost identity is already registered.");
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return userAccount;
|
||||
}
|
||||
|
||||
// A lost race on the unique index: the tracked insert is dropped and the identity is read fresh.
|
||||
private async Task<bool> IdentityTakenAsync(string normalizedIdentity)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
return await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity);
|
||||
}
|
||||
|
||||
public async Task EnsureIdentityAsync(
|
||||
UserAccount userAccount,
|
||||
string preferredIdentity,
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Membership;
|
||||
|
||||
public enum SuspensionOutcome
|
||||
{
|
||||
Done,
|
||||
NotAdmin,
|
||||
NoReason,
|
||||
NoSuchAccount,
|
||||
NotApplicable,
|
||||
ProtectedAccount
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one place a membership becomes Suspended or stops being. Only a platform admin may call it, every use leaves an audit
|
||||
/// receipt naming the admin, the account and the reason, and no sign-in path writes or lifts a suspension (Heimdall and GitHub
|
||||
/// sign-in leave a Suspended membership as it is). Unsuspending restores Active for an account that was once approved,
|
||||
/// Authenticated for one that never was; a pending request is not remembered.
|
||||
/// </summary>
|
||||
public sealed class MembershipSuspensionService(BifrostDbContext dbContext, AuditTrailService auditTrailService)
|
||||
{
|
||||
public const int MaxReasonLength = 500;
|
||||
|
||||
public Task<SuspensionOutcome> SuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
|
||||
ChangeAsync(actor, userAccountId, reason, suspend: true, cancellationToken);
|
||||
|
||||
public Task<SuspensionOutcome> UnsuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
|
||||
ChangeAsync(actor, userAccountId, reason, suspend: false, cancellationToken);
|
||||
|
||||
private async Task<SuspensionOutcome> ChangeAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!actor.CanManageMembership || actor.UserAccount is null)
|
||||
{
|
||||
return SuspensionOutcome.NotAdmin;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return SuspensionOutcome.NoReason;
|
||||
}
|
||||
|
||||
var user = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
if (user?.Membership is not { } membership)
|
||||
{
|
||||
return SuspensionOutcome.NoSuchAccount;
|
||||
}
|
||||
|
||||
// An admin cannot suspend themselves or another platform admin here (the same derivation that makes an actor an admin):
|
||||
// bootstrap admins are re-promoted to Active at sign-in by configuration, so the suspension would be undone without a receipt.
|
||||
if (suspend && (user.Id == actor.UserAccount.Id || new CurrentBifrostActor(true, user).IsPlatformAdmin))
|
||||
{
|
||||
return SuspensionOutcome.ProtectedAccount;
|
||||
}
|
||||
|
||||
if (suspend == (membership.Status == MembershipStatus.Suspended))
|
||||
{
|
||||
return SuspensionOutcome.NotApplicable;
|
||||
}
|
||||
|
||||
var before = membership.Status;
|
||||
membership.Status = suspend
|
||||
? MembershipStatus.Suspended
|
||||
: membership.ApprovedAtUtc is null ? MembershipStatus.Authenticated : MembershipStatus.Active;
|
||||
var clipped = reason.Trim().Length > MaxReasonLength ? reason.Trim()[..MaxReasonLength] : reason.Trim();
|
||||
// One save: the status change and its receipt stand or fall together (RecordAsync saves the shared context).
|
||||
await auditTrailService.RecordAsync(
|
||||
actor.UserAccount.Id,
|
||||
nameof(Membership),
|
||||
membership.Id,
|
||||
suspend ? "membership.suspended" : "membership.unsuspended",
|
||||
$"{(suspend ? "Suspended" : "Unsuspended")} {user.DisplayName} ({before} -> {membership.Status}): {clipped}",
|
||||
cancellationToken);
|
||||
return SuspensionOutcome.Done;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ using System.Security.Claims;
|
|||
using Microsoft.AspNetCore;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
|
@ -20,9 +19,8 @@ namespace Bifrost.Web.Features.Oidc;
|
|||
/// </summary>
|
||||
public static class OidcEndpoints
|
||||
{
|
||||
public const string SuspendedDescription = "The account is suspended.";
|
||||
public const string NotActiveDescription = "The account is not an active GameCult member.";
|
||||
public const string NoIdentityDescription = "The account has no Bifrost identity.";
|
||||
public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved.";
|
||||
|
||||
private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme;
|
||||
private const string IdentityAuthenticationType = "Bifrost.Oidc";
|
||||
|
|
@ -49,16 +47,9 @@ public static class OidcEndpoints
|
|||
}
|
||||
|
||||
var actor = await actors.GetAsync(http.RequestAborted);
|
||||
// Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written;
|
||||
// every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync).
|
||||
if (actor.UserAccount is null)
|
||||
if (actor.UserAccount is null || !actor.IsActiveMember)
|
||||
{
|
||||
return Deny(Errors.AccessDenied, NoIdentityDescription);
|
||||
}
|
||||
|
||||
if (actor.Membership?.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return Deny(Errors.AccessDenied, SuspendedDescription);
|
||||
return Deny(Errors.AccessDenied, NotActiveDescription);
|
||||
}
|
||||
|
||||
var account = actor.UserAccount;
|
||||
|
|
@ -67,13 +58,6 @@ public static class OidcEndpoints
|
|||
return Deny(Errors.AccessDenied, NoIdentityDescription);
|
||||
}
|
||||
|
||||
// The identity becomes the Matrix localpart. Creation refuses reserved names (BifrostIdentityService), but a row minted
|
||||
// before that refusal may hold one, and there is no rename path: the issuer never vouches for a Mind's or a system's localpart.
|
||||
if (BifrostIdentityService.IsReserved(account.NormalizedBifrostIdentity))
|
||||
{
|
||||
return Deny(Errors.AccessDenied, ReservedIdentityDescription);
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role);
|
||||
identity.SetClaim(Claims.Subject, account.Id.ToString("D"));
|
||||
identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity);
|
||||
|
|
@ -97,13 +81,13 @@ public static class OidcEndpoints
|
|||
private static async Task<IResult> 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; suspension is read again here,
|
||||
// because the code outlives the authorize decision (an account suspended since gets no tokens).
|
||||
// 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 IsStillChatEligibleAsync(db, subject, http.RequestAborted))
|
||||
if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted))
|
||||
{
|
||||
return Deny(Errors.InvalidGrant, SuspendedDescription);
|
||||
return Deny(Errors.InvalidGrant, NotActiveDescription);
|
||||
}
|
||||
|
||||
return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme);
|
||||
|
|
@ -117,10 +101,10 @@ public static class OidcEndpoints
|
|||
return Results.Challenge(authenticationSchemes: [OpenIddictScheme]);
|
||||
}
|
||||
|
||||
// An access token outlives the authorize decision too: an account suspended since is no longer answered.
|
||||
if (!await IsStillChatEligibleAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted))
|
||||
// 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, SuspendedDescription);
|
||||
return Deny(Errors.InsufficientAccess, NotActiveDescription);
|
||||
}
|
||||
|
||||
var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
|
||||
|
|
@ -133,11 +117,8 @@ public static class OidcEndpoints
|
|||
return Results.Json(claims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
|
||||
/// the account exists and its membership is not Suspended. Membership may be absent, pending or Active. Re-read on every call.
|
||||
/// </summary>
|
||||
internal static async Task<bool> IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary>
|
||||
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
{
|
||||
|
|
@ -147,21 +128,7 @@ public static class OidcEndpoints
|
|||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
return account is not null && account.Membership?.Status != MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
/// <summary>The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call.</summary>
|
||||
internal static async Task<Guid?> ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
return new CurrentBifrostActor(true, account).IsActiveMember ? accountId : null;
|
||||
return new CurrentBifrostActor(true, account).IsActiveMember;
|
||||
}
|
||||
|
||||
private static IResult Deny(string error, string description) =>
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
using System.Net;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenIddict.EntityFrameworkCore.Models;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Abstractions;
|
||||
using static OpenIddict.Abstractions.OpenIddictConstants;
|
||||
using OpenIddict.Server.AspNetCore;
|
||||
|
||||
namespace Bifrost.Web.Features.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// The issuer's front door, ahead of OpenIddict: the marker brake, the per-address budget, and the bound
|
||||
/// on token rows. The address is the connection's, already corrected by the forwarded-headers handling that trusts only
|
||||
/// the local proxy, so a caller cannot pick its own. It fails closed: anything this middleware cannot decide is a 503, never a pass.
|
||||
/// </summary>
|
||||
public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger<OidcGuardMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext http, IOpenIddictTokenManager tokens)
|
||||
{
|
||||
var endpoint = EndpointOf(http.Request.Path);
|
||||
if (endpoint is null)
|
||||
{
|
||||
await next(http);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (BrakeEngaged(options.BrakePath))
|
||||
{
|
||||
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (limit.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is { } byAddress)
|
||||
{
|
||||
await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter);
|
||||
return;
|
||||
}
|
||||
|
||||
// A new authorize request parks a row for anyone who can name the (public) client; a resumed one carries request_uri and parks none.
|
||||
if (endpoint == Authorize && !http.Request.Query.ContainsKey("request_uri") && await tokens.CountAsync(http.RequestAborted) >= options.Limits.MaxTokenRows)
|
||||
{
|
||||
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", options.Limits.PruneInterval);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError("OIDC guard could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
|
||||
await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1));
|
||||
return;
|
||||
}
|
||||
|
||||
await next(http);
|
||||
}
|
||||
|
||||
private const string Authorize = "authorize";
|
||||
|
||||
// OpenIddict answers these paths with or without a trailing slash, so the match is by segments, never exact equality.
|
||||
private static string? EndpointOf(PathString path) =>
|
||||
path.StartsWithSegments(OidcServiceCollectionExtensions.AuthorizationPath, StringComparison.OrdinalIgnoreCase) ? Authorize
|
||||
: path.StartsWithSegments(OidcServiceCollectionExtensions.TokenPath, StringComparison.OrdinalIgnoreCase) ? "token"
|
||||
: path.StartsWithSegments(OidcServiceCollectionExtensions.UserInfoPath, StringComparison.OrdinalIgnoreCase) ? "userinfo"
|
||||
: null;
|
||||
|
||||
// A marker that cannot be told apart from an absent one (a permission or I/O error) throws, and the guard answers 503: the brake never releases by accident.
|
||||
private static bool BrakeEngaged(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.GetAttributes(path);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter)
|
||||
{
|
||||
http.Response.StatusCode = status;
|
||||
http.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
http.Response.ContentType = "application/json";
|
||||
return http.Response.WriteAsync($"{{\"error\":\"{error}\"}}", http.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The issuer's per-address permits on the shared fixed-window budget. There is no per-client budget: the one client's id is
|
||||
/// public and what a caller names unverified, so a budget on it would let anyone spend the real client's permits.
|
||||
/// </summary>
|
||||
public sealed class OidcRateLimit(OidcIssuerOptions options, TimeProvider clock)
|
||||
{
|
||||
private readonly FixedWindowBudget addresses = new(options.Limits.PerAddressPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock);
|
||||
|
||||
public BudgetDenial? Spend(string address) => addresses.Spend(address);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes expired and orphaned authorizations and tokens on a schedule: tokens past their expiry (parked authorize
|
||||
/// requests, codes, access tokens) or no longer valid, then authorizations that are no longer valid or are ad hoc with no
|
||||
/// token left. Live rows are never touched. Plain queries, not OpenIddict's ExecuteDelete prune, which the in-memory test provider
|
||||
/// cannot run; an authorization takes its remaining tokens with it, because the foreign key has no cascade on Postgres.
|
||||
/// </summary>
|
||||
public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger<OidcPruner> logger) : BackgroundService
|
||||
{
|
||||
private const int Batch = 500;
|
||||
|
||||
// A fresh authorization briefly has no token yet; leave anything younger than this alone.
|
||||
private static readonly TimeSpan AuthorizationGrace = TimeSpan.FromMinutes(5);
|
||||
|
||||
public async Task PruneAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var now = clock.GetUtcNow();
|
||||
var young = now - AuthorizationGrace;
|
||||
|
||||
int removed;
|
||||
do
|
||||
{
|
||||
var tokens = await db.Set<OpenIddictEntityFrameworkCoreToken>()
|
||||
.Where(token => token.ExpirationDate < now || (token.Status != Statuses.Valid && token.CreationDate < young))
|
||||
.Take(Batch).ToListAsync(cancellationToken);
|
||||
db.RemoveRange(tokens);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
removed = tokens.Count;
|
||||
}
|
||||
while (removed == Batch);
|
||||
|
||||
do
|
||||
{
|
||||
// A token row references its authorization (no cascade in the database), so the tokens go with it.
|
||||
var authorizations = await db.Set<OpenIddictEntityFrameworkCoreAuthorization>()
|
||||
.Include(authorization => authorization.Tokens)
|
||||
.Where(authorization => authorization.CreationDate < young
|
||||
&& (authorization.Status != Statuses.Valid || (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any())))
|
||||
.Take(Batch).ToListAsync(cancellationToken);
|
||||
db.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens));
|
||||
db.RemoveRange(authorizations);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
removed = authorizations.Count;
|
||||
}
|
||||
while (removed == Batch);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(options.Limits.PruneInterval, clock);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await PruneAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError("OIDC prune failed ({ExceptionType}); the next tick tries again.", exception.GetType().Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,8 +11,8 @@ using static OpenIddict.Abstractions.OpenIddictConstants;
|
|||
namespace Bifrost.Web.Features.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid
|
||||
/// authorizations of the configured client; for each one the shared chat-eligibility reading refuses, it POSTs one signed
|
||||
/// 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
|
||||
|
|
@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler(
|
|||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken))
|
||||
if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken))
|
||||
{
|
||||
_backoff.Remove(subject);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@ public static class OidcServiceCollectionExtensions
|
|||
server.Issuer = issuer;
|
||||
});
|
||||
|
||||
services.AddSingleton<OidcRateLimit>();
|
||||
services.AddSingleton<OidcPruner>();
|
||||
services.AddHostedService(provider => provider.GetRequiredService<OidcPruner>());
|
||||
services.AddHostedService<OidcClientSeeder>();
|
||||
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>What a client sent to register. <see cref="Waited"/> claims the time-lock path instead of a proof of work.</summary>
|
||||
public sealed record RegistrationAttempt(
|
||||
string? Identity,
|
||||
string? DisplayName,
|
||||
string? Challenge,
|
||||
string? Nonce,
|
||||
bool Waited,
|
||||
int? AcceptedNoticeVersion);
|
||||
|
||||
/// <summary>Which input a refusal is about, so the form can mark it invalid.</summary>
|
||||
public enum RegistrationField { None, Identity, Consent }
|
||||
|
||||
/// <summary>
|
||||
/// Either a signed-in <see cref="Account"/>, or a <see cref="Refusal"/> to return as is (the rate limits), or a rejection
|
||||
/// with a status and message. <see cref="KeepChallenge"/> is set when the challenge was not spent and a retry needs only a correction, so the form should carry it on (the time-lock clock must not restart).
|
||||
/// </summary>
|
||||
public sealed record RegistrationOutcome(
|
||||
UserAccount? Account,
|
||||
IResult? Refusal,
|
||||
int Status,
|
||||
string? Message,
|
||||
RegistrationField Field,
|
||||
bool KeepChallenge)
|
||||
{
|
||||
public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, bool keepChallenge = false) =>
|
||||
new(null, null, status, message, field, keepChallenge);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget.
|
||||
/// Order: consent, one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the
|
||||
/// time-lock plus its tighter budget), then the account and receipt in one save, then the cookie. A refusal at any step
|
||||
/// creates nothing, and a refusal before the challenge step spends neither the challenge nor a fallback permit.
|
||||
/// </summary>
|
||||
public sealed class NativeRegistration(
|
||||
BifrostIdentityService identities,
|
||||
RegistrationChallengeService challenges,
|
||||
RegistrationRateLimit rateLimit)
|
||||
{
|
||||
public async Task<RegistrationOutcome> RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken)
|
||||
{
|
||||
// Consent first, then the proof; a refusal here creates nothing.
|
||||
if (attempt.AcceptedNoticeVersion != TrainingNotice.Version)
|
||||
{
|
||||
return RegistrationOutcome.Reject(
|
||||
StatusCodes.Status400BadRequest,
|
||||
$"Registration requires accepting the training notice, current version {TrainingNotice.Version}.",
|
||||
RegistrationField.Consent);
|
||||
}
|
||||
|
||||
var hasNonce = !string.IsNullOrEmpty(attempt.Nonce);
|
||||
if (hasNonce && attempt.Waited)
|
||||
{
|
||||
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await identities.CheckAvailableAsync(attempt.Identity ?? "", cancellationToken);
|
||||
}
|
||||
catch (BifrostIdentityException error)
|
||||
{
|
||||
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity, keepChallenge: true);
|
||||
}
|
||||
|
||||
var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed.");
|
||||
if (hasNonce)
|
||||
{
|
||||
if (!challenges.TryConsume(attempt.Challenge, attempt.Nonce))
|
||||
{
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
else if (attempt.Waited)
|
||||
{
|
||||
if (challenges.WaitRemaining(attempt.Challenge) is not { } remaining)
|
||||
{
|
||||
return failed;
|
||||
}
|
||||
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(remaining.TotalSeconds);
|
||||
return RegistrationOutcome.Reject(
|
||||
StatusCodes.Status400BadRequest,
|
||||
$"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Your challenge is still good; submit again then.",
|
||||
keepChallenge: true);
|
||||
}
|
||||
|
||||
if (rateLimit.RefuseFallback(http) is { } fallbackRefused)
|
||||
{
|
||||
return new(null, fallbackRefused, 0, null, RegistrationField.None, false);
|
||||
}
|
||||
|
||||
if (!challenges.TryConsumeWaited(attempt.Challenge))
|
||||
{
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return failed;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, TrainingNotice.Version, cancellationToken);
|
||||
await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account));
|
||||
return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false);
|
||||
}
|
||||
catch (BifrostIdentityException error)
|
||||
{
|
||||
return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
public sealed record RegistrationChallenge(string Challenge, int Difficulty, DateTimeOffset ExpiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted proof-of-work challenge for the anonymous registration. A challenge is a data-protected
|
||||
/// (random id, expiry) payload, so the server keeps nothing per issue; only consumed ids are remembered, in a bounded
|
||||
/// set whose entries are dropped at expiry. A full set refuses both issuing and consuming rather than grow.
|
||||
/// </summary>
|
||||
public sealed class RegistrationChallengeService(
|
||||
IDataProtectionProvider dataProtection,
|
||||
IOptions<RegistrationOptions> options,
|
||||
TimeProvider clock)
|
||||
{
|
||||
public const string Purpose = "Bifrost.Registration.Challenge.v1";
|
||||
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
|
||||
|
||||
public const int MaxConsumed = 10_000;
|
||||
|
||||
private const int MaxNonceLength = 20;
|
||||
|
||||
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
|
||||
private readonly HashSet<string> consumed = new(StringComparer.Ordinal);
|
||||
// Ordered by expiry: a challenge consumed late has an earlier expiry than one consumed before it, so a plain FIFO would strand it.
|
||||
private readonly PriorityQueue<string, DateTimeOffset> expiries = new();
|
||||
private readonly object gate = new();
|
||||
|
||||
private int Difficulty => options.Value.Captcha.Difficulty;
|
||||
|
||||
/// <summary>Null when the consumed set is full: refuse rather than grow.</summary>
|
||||
public RegistrationChallenge? Issue()
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
lock (gate)
|
||||
{
|
||||
Purge(now);
|
||||
if (consumed.Count >= MaxConsumed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt = now + Lifetime;
|
||||
var difficulty = Difficulty;
|
||||
var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
|
||||
var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}.{now.ToUnixTimeMilliseconds()}");
|
||||
return new RegistrationChallenge(challenge, difficulty, expiresAt);
|
||||
}
|
||||
|
||||
/// <summary>Proof-of-work path: the nonce must meet the difficulty the challenge was issued with. Spends the challenge.</summary>
|
||||
public bool TryConsume(string? challenge, string? nonce)
|
||||
{
|
||||
if (!IsNonce(nonce) || Open(challenge) is not { } open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce));
|
||||
return LeadingZeroBits(hash) >= open.Difficulty && Spend(open);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time-lock path, step one: how long until a <c>waited</c> submission of this challenge is accepted. Null when the
|
||||
/// token is not a live challenge of ours; zero when the wait is over. Spends nothing.
|
||||
/// </summary>
|
||||
public TimeSpan? WaitRemaining(string? challenge)
|
||||
{
|
||||
if (Open(challenge) is not { } open)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var remaining = open.IssuedAt + TimeSpan.FromSeconds(options.Value.Captcha.FallbackWaitSeconds) - clock.GetUtcNow();
|
||||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>Time-lock path, step two: spends the challenge if its wait is over. No proof of work is checked.</summary>
|
||||
public bool TryConsumeWaited(string? challenge) =>
|
||||
WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open);
|
||||
|
||||
/// <summary>True when the token is ours, unexpired and not yet spent, so a form may carry it into its next attempt.</summary>
|
||||
public bool IsLive(string? challenge)
|
||||
{
|
||||
if (Open(challenge) is not { } open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
return !consumed.Contains(open.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt);
|
||||
|
||||
/// <summary>The payload of a challenge that we protected and that has not expired; null otherwise.</summary>
|
||||
private OpenChallenge? Open(string? challenge)
|
||||
{
|
||||
if (string.IsNullOrEmpty(challenge))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
OpenChallenge open;
|
||||
try
|
||||
{
|
||||
var payload = protector.Unprotect(challenge).Split('.');
|
||||
if (payload.Length != 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
open = new OpenChallenge(
|
||||
payload[0],
|
||||
int.Parse(payload[2], culture),
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], culture)),
|
||||
DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[3], culture)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return clock.GetUtcNow() >= open.ExpiresAt ? null : open;
|
||||
}
|
||||
|
||||
private bool Spend(OpenChallenge open)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
lock (gate)
|
||||
{
|
||||
Purge(now);
|
||||
if (consumed.Count >= MaxConsumed || !consumed.Add(open.Id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
expiries.Enqueue(open.Id, open.ExpiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void Purge(DateTimeOffset now)
|
||||
{
|
||||
while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now)
|
||||
{
|
||||
expiries.Dequeue();
|
||||
consumed.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNonce(string? nonce) =>
|
||||
nonce is { Length: >= 1 and <= MaxNonceLength } && nonce.All(c => c is >= '0' and <= '9');
|
||||
|
||||
public static int LeadingZeroBits(ReadOnlySpan<byte> hash)
|
||||
{
|
||||
var bits = 0;
|
||||
foreach (var b in hash)
|
||||
{
|
||||
if (b == 0)
|
||||
{
|
||||
bits += 8;
|
||||
continue;
|
||||
}
|
||||
|
||||
return bits + System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
|
||||
}
|
||||
|
||||
return bits;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>What the sign-up page shows. Everything user-supplied is HTML-encoded on the way out.</summary>
|
||||
public sealed record RegistrationPageModel(
|
||||
string ReturnPath,
|
||||
string Challenge,
|
||||
int Difficulty,
|
||||
int WaitSeconds,
|
||||
string Identity = "",
|
||||
string DisplayName = "",
|
||||
string? Error = null,
|
||||
RegistrationField ErrorField = RegistrationField.None);
|
||||
|
||||
/// <summary>
|
||||
/// The server-rendered sign-up form. It works with no script (time-lock path); the inline script, when it can, replaces the
|
||||
/// wait by solving the proof of work. No external asset, no cookie, no third party.
|
||||
/// </summary>
|
||||
public static class RegistrationPage
|
||||
{
|
||||
// Hash rule is RegistrationChallengeService's: SHA-256 of challenge + ":" + nonce, leading zero bits >= difficulty.
|
||||
// Hashes run in batches that yield to the UI between them. If the browser has no crypto.subtle, nothing here runs
|
||||
// and the form submits on the time-lock path.
|
||||
public const string Script = """
|
||||
(function () {
|
||||
var form = document.getElementById("register");
|
||||
var status = document.getElementById("status");
|
||||
if (!form || !status || !window.crypto || !window.crypto.subtle || !window.TextEncoder) { return; }
|
||||
var BATCH = 256;
|
||||
var busy = false;
|
||||
var solved = false;
|
||||
var skipped = false;
|
||||
|
||||
function zeroBits(bytes) {
|
||||
var bits = 0;
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
if (bytes[i] === 0) { bits += 8; continue; }
|
||||
return bits + Math.clz32(bytes[i]) - 24;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
async function solve(challenge, difficulty) {
|
||||
var encoder = new TextEncoder();
|
||||
for (var start = 0; !skipped; start += BATCH) {
|
||||
var tries = [];
|
||||
for (var n = start; n < start + BATCH; n++) {
|
||||
tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n)));
|
||||
}
|
||||
var hashes = await Promise.all(tries);
|
||||
for (var i = 0; i < hashes.length; i++) {
|
||||
if (zeroBits(new Uint8Array(hashes[i])) >= difficulty) { return String(start + i); }
|
||||
}
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 0); });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
if (event.submitter && event.submitter.id === "skip") {
|
||||
// The way out of a slow solve: stop it and let this submit go on the time-lock path.
|
||||
skipped = true;
|
||||
solved = true;
|
||||
form.elements.nonce.value = "";
|
||||
form.elements.waited.disabled = false;
|
||||
status.textContent = "Skipping the automatic check.";
|
||||
return;
|
||||
}
|
||||
if (solved) { return; }
|
||||
event.preventDefault();
|
||||
if (busy) { return; }
|
||||
busy = true;
|
||||
status.textContent = "Checking. This can take longer on a slow device; press Skip the automatic check to wait " + form.getAttribute("data-wait") + " seconds instead.";
|
||||
solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) {
|
||||
if (skipped || nonce === null) { return; }
|
||||
form.elements.nonce.value = nonce;
|
||||
form.elements.waited.disabled = true;
|
||||
solved = true;
|
||||
status.textContent = "Creating your account";
|
||||
form.submit();
|
||||
}, function () {
|
||||
solved = true;
|
||||
busy = false;
|
||||
status.textContent = "The automatic check did not run in this browser. Press Skip the automatic check and wait as described below.";
|
||||
});
|
||||
});
|
||||
})();
|
||||
""";
|
||||
|
||||
public static string Render(RegistrationPageModel model)
|
||||
{
|
||||
static string E(string? text) => WebUtility.HtmlEncode(text ?? "");
|
||||
var notice = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var summary = new StringBuilder();
|
||||
if (model.Error is not null)
|
||||
{
|
||||
var link = model.ErrorField switch
|
||||
{
|
||||
RegistrationField.Identity => """<ul><li><a href="#identity">Identity name</a></li></ul>""",
|
||||
RegistrationField.Consent => """<ul><li><a href="#consent">I have read this notice and consent</a></li></ul>""",
|
||||
_ => ""
|
||||
};
|
||||
summary.Append($"""<div id="errors" role="alert" tabindex="-1"><strong>Registration did not go through.</strong><p>{E(model.Error)}</p>{link}</div>""");
|
||||
}
|
||||
|
||||
string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : "";
|
||||
var wait = model.WaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
return $$"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Create an account - Bifrost</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100svh; display: grid; place-items: center; background: #090908; color: #fff8ef; font-family: system-ui, sans-serif; line-height: 1.5; }
|
||||
main { width: min(92vw, 600px); margin: 24px 0; padding: 28px; border: 1px solid rgba(255,248,239,.16); border-radius: 8px; background: #141110; }
|
||||
h1 { margin: 0 0 10px; font-size: 2rem; line-height: 1.1; }
|
||||
p { color: #c7b8ad; }
|
||||
fieldset { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #8a7d73; border-radius: 6px; }
|
||||
legend { padding: 0 6px; color: #d9ad66; font-weight: 750; }
|
||||
label { display: block; margin-top: 12px; font-weight: 650; }
|
||||
.hint { margin: 2px 0 0; font-size: .9rem; }
|
||||
input[type=text] { box-sizing: border-box; width: 100%; min-height: 44px; margin-top: 4px; padding: 0 10px; border: 1px solid #8a7d73; border-radius: 6px; background: #0d0b0a; color: #fff8ef; font: inherit; }
|
||||
input[type=checkbox] { width: 22px; height: 22px; margin: 0 8px 0 0; vertical-align: middle; accent-color: #c43e4f; }
|
||||
label.check { display: flex; align-items: center; margin-top: 14px; }
|
||||
button.secondary { margin-left: 10px; background: #2a2522; border: 1px solid #8a7d73; }
|
||||
button { margin-top: 20px; min-height: 46px; padding: 0 18px; border: 0; border-radius: 6px; background: #c43e4f; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
|
||||
:focus-visible { outline: 3px solid #ffd27a; outline-offset: 2px; }
|
||||
#errors { margin-top: 18px; padding: 10px 14px; border: 2px solid #ff9aa6; border-radius: 6px; color: #ffc2c9; }
|
||||
#errors p { margin: 4px 0 0; color: #ffc2c9; }
|
||||
[aria-invalid=true] { border-color: #ff9aa6; }
|
||||
#status { min-height: 1.5em; color: #fff8ef; }
|
||||
small { color: #d9ad66; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<small>Public registration</small>
|
||||
<h1>Create a Bifrost account</h1>
|
||||
<p>A new account can chat. It has no vote and opens no member or governance surface.</p>
|
||||
{{summary}}
|
||||
<form id="register" method="post" action="/auth/bifrost/register/form" data-difficulty="{{model.Difficulty}}" data-wait="{{wait}}">
|
||||
<fieldset>
|
||||
<legend>Your account</legend>
|
||||
<label for="identity">Identity name</label>
|
||||
<input type="text" id="identity" name="identity" value="{{E(model.Identity)}}" required autocomplete="username" autocapitalize="none" spellcheck="false"{{Invalid(RegistrationField.Identity)}} />
|
||||
<label for="displayName">Display name (optional)</label>
|
||||
<input type="text" id="displayName" name="displayName" value="{{E(model.DisplayName)}}" autocomplete="nickname" />
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Training notice, version {{notice}}</legend>
|
||||
<p id="notice">{{E(TrainingNotice.Text)}}</p>
|
||||
<label class="check" for="consent"><input type="checkbox" id="consent" name="consent" value="yes" required aria-describedby="notice"{{Invalid(RegistrationField.Consent)}} />I have read this notice and consent</label>
|
||||
<input type="hidden" name="acceptedNoticeVersion" value="{{notice}}" />
|
||||
</fieldset>
|
||||
<input type="hidden" name="challenge" value="{{E(model.Challenge)}}" />
|
||||
<input type="hidden" name="returnUrl" value="{{E(model.ReturnPath)}}" />
|
||||
<input type="hidden" name="nonce" value="" />
|
||||
<input type="hidden" name="waited" value="true" />
|
||||
<p id="status" aria-live="polite"></p>
|
||||
<button type="submit" id="create">Create account</button>
|
||||
<button type="submit" id="skip" class="secondary">Skip the automatic check and wait {{wait}} seconds instead</button>
|
||||
</form>
|
||||
<p class="hint" id="wait-note">Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick the box again before you submit. Without the automatic check only a few registrations per hour are accepted from one address.</p>
|
||||
<p><small>After registering, Bifrost returns you to {{E(model.ReturnPath)}}.</small></p>
|
||||
</main>
|
||||
<script>{{Script}}</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>
|
||||
/// The per-address permits on the registration endpoints (challenge and register share one budget), on the shared
|
||||
/// fixed-window budget. It exists so one address cannot fill the consumed-challenge set alone. It fails closed:
|
||||
/// anything it cannot decide is a 503, never a pass.
|
||||
/// </summary>
|
||||
public sealed class RegistrationRateLimit(IOptions<RegistrationOptions> options, TimeProvider clock, ILogger<RegistrationRateLimit> logger)
|
||||
{
|
||||
private readonly FixedWindowBudget addresses = new(
|
||||
options.Value.Limits.PerAddressPermits,
|
||||
options.Value.Limits.Window,
|
||||
options.Value.Limits.MaxTrackedKeys,
|
||||
clock);
|
||||
|
||||
// Time-lock submissions cost the client no work, so they get their own, much tighter budget per address.
|
||||
private readonly FixedWindowBudget fallbacks = new(
|
||||
options.Value.Limits.FallbackPermits,
|
||||
options.Value.Limits.FallbackWindow,
|
||||
options.Value.Limits.MaxTrackedKeys,
|
||||
clock);
|
||||
|
||||
/// <summary>Null when the request may proceed; otherwise the refusal to return.</summary>
|
||||
public IResult? Refuse(HttpContext http) => Refuse(http, addresses, "Too many registration requests from this address.");
|
||||
|
||||
/// <summary>The stricter budget for a submission that passed the time-lock instead of the proof of work.</summary>
|
||||
public IResult? RefuseFallback(HttpContext http) =>
|
||||
Refuse(http, fallbacks, "Too many registrations without the quick check from this address; try again later or let the page run its script.");
|
||||
|
||||
private IResult? Refuse(HttpContext http, FixedWindowBudget budget, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (budget.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter);
|
||||
return Results.Text(message, statusCode: StatusCodes.Status429TooManyRequests);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError("Registration rate limit could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name);
|
||||
http.Response.Headers.RetryAfter = "60";
|
||||
return Results.Text("Registration is unavailable; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RetryAfter(TimeSpan wait) =>
|
||||
Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
namespace Bifrost.Web.Features.Registration;
|
||||
|
||||
/// <summary>
|
||||
/// The training notice a registrant accepts. DRAFT wording: Eureka replaces <see cref="Text"/>. Changing the wording
|
||||
/// in substance means a new <see cref="Version"/> (a positive integer, no leading zeros), because a receipt records the
|
||||
/// version the person accepted.
|
||||
/// </summary>
|
||||
public static class TrainingNotice
|
||||
{
|
||||
public const int Version = 1;
|
||||
|
||||
public const string Text =
|
||||
"Chat messages and activity on GameCult chat may be used to train and evaluate AI models. " +
|
||||
"No Discord material is used. " +
|
||||
"Your consent is voluntary. It is recorded with this notice version and the time you give it, " +
|
||||
"and you can withdraw it at any time. Withdrawing stops future use; it cannot recall use that already happened.";
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
using System.Net;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
public static class ClientAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// The partition key for a per-address budget: the connection's address, already corrected by the forwarded-headers
|
||||
/// handling that trusts only the local proxy. An IPv6 caller owns its /64, so rotating the low bits buys nothing.
|
||||
/// </summary>
|
||||
public static string BudgetKey(IPAddress? address)
|
||||
{
|
||||
if (address is null)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
{
|
||||
address = address.MapToIPv4();
|
||||
}
|
||||
|
||||
if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.ToString();
|
||||
}
|
||||
|
||||
var bytes = address.GetAddressBytes();
|
||||
Array.Clear(bytes, 8, 8);
|
||||
return new IPAddress(bytes).ToString() + "/64";
|
||||
}
|
||||
}
|
||||
|
|
@ -92,7 +92,6 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro
|
|||
var recentActivity = await dbContext.AuditEvents
|
||||
.AsNoTracking()
|
||||
.Include(x => x.ActorUserAccount)
|
||||
.Where(x => !x.Action.StartsWith("membership.suspended") && !x.Action.StartsWith("membership.unsuspended")) // a reason is for admins, not the member dashboard
|
||||
.OrderByDescending(x => x.OccurredAtUtc)
|
||||
.Take(8)
|
||||
.Select(x => new DashboardActivity(
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
// The one place that decides whether a state-changing request to an anonymous form (the vote page, the sign-up form) came from
|
||||
// the site itself. These flows carry no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
|
||||
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
|
||||
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
|
||||
// are all refused. Header values are compared, never echoed.
|
||||
public static class RequestOriginGuard
|
||||
{
|
||||
public static bool Allows(HttpRequest request, BifrostHostOptions host)
|
||||
{
|
||||
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var origin = request.Headers.Origin;
|
||||
if (request.Headers.ContainsKey("Origin"))
|
||||
{
|
||||
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var fetchSite = request.Headers["Sec-Fetch-Site"];
|
||||
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For the no-script sign-up form only. Text browsers (Lynx, w3m, Links) send neither Origin nor Sec-Fetch-Site, so a
|
||||
/// request with neither is let through when it carries no Bifrost session cookie: there is no session to replace.
|
||||
/// Any Origin or Sec-Fetch-Site present is judged exactly as <see cref="Allows"/> judges it, and a headerless request
|
||||
/// that does carry a session cookie is refused.
|
||||
/// </summary>
|
||||
public static bool AllowsFormWithoutFetchMetadata(HttpRequest request, BifrostHostOptions host) =>
|
||||
Allows(request, host) ||
|
||||
(!request.Headers.ContainsKey("Origin") &&
|
||||
!request.Headers.ContainsKey("Sec-Fetch-Site") &&
|
||||
!request.Cookies.Keys.Any(name => name.StartsWith(BifrostCookies.Session, StringComparison.Ordinal)));
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Features.Shared;
|
||||
|
|
@ -13,9 +12,7 @@ public sealed class StartupConfigurationValidator(
|
|||
IOptions<HeimdallOptions> heimdallOptions,
|
||||
IOptions<StripeOptions> stripeOptions,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<ConceptVoteOptions> conceptVoteOptions,
|
||||
IOptions<DeskOptions> deskOptions,
|
||||
IOptions<RegistrationOptions> registrationOptions)
|
||||
IOptions<ConceptVoteOptions> conceptVoteOptions)
|
||||
{
|
||||
public void Validate()
|
||||
{
|
||||
|
|
@ -48,32 +45,6 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
|
||||
}
|
||||
|
||||
ValidateDesk(deskOptions.Value, failures);
|
||||
|
||||
var difficulty = registrationOptions.Value.Captcha.Difficulty;
|
||||
if (difficulty < RegistrationOptions.MinDifficulty || difficulty > RegistrationOptions.MaxDifficulty)
|
||||
{
|
||||
failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}.");
|
||||
}
|
||||
|
||||
var wait = registrationOptions.Value.Captcha.FallbackWaitSeconds;
|
||||
if (wait < RegistrationOptions.MinFallbackWaitSeconds || wait > RegistrationOptions.MaxFallbackWaitSeconds ||
|
||||
wait >= RegistrationChallengeService.Lifetime.TotalSeconds)
|
||||
{
|
||||
failures.Add($"Bifrost:Registration:Captcha:FallbackWaitSeconds must be between {RegistrationOptions.MinFallbackWaitSeconds} and {RegistrationOptions.MaxFallbackWaitSeconds} and shorter than the challenge lifetime ({RegistrationChallengeService.Lifetime.TotalSeconds:0} seconds).");
|
||||
}
|
||||
|
||||
var limits = registrationOptions.Value.Limits;
|
||||
if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1)
|
||||
{
|
||||
failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive.");
|
||||
}
|
||||
|
||||
if (limits.FallbackPermits < 1 || limits.FallbackWindow <= TimeSpan.Zero)
|
||||
{
|
||||
failures.Add("Bifrost:Registration:Limits:FallbackPermits must be at least 1 and FallbackWindow must be positive.");
|
||||
}
|
||||
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
Refuse(failures);
|
||||
|
|
@ -145,106 +116,6 @@ public sealed class StartupConfigurationValidator(
|
|||
Refuse(failures);
|
||||
}
|
||||
|
||||
// Checked in every environment: a bad Mind list or decider id would otherwise pass startup and misroute or
|
||||
// mis-attribute answers. Account existence is a request-time question, not a startup one.
|
||||
private void ValidateDesk(DeskOptions desk, List<string> failures)
|
||||
{
|
||||
if (!desk.IsConfigured)
|
||||
{
|
||||
if (configuration.GetSection(DeskOptions.SectionName).Exists())
|
||||
{
|
||||
failures.Add("Desk:Minds must list at least one Mind when the Desk section exists.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(desk.AllowedOrigin, UriKind.Absolute, out var origin) ||
|
||||
origin.Scheme is not ("http" or "https") ||
|
||||
!string.Equals(desk.AllowedOrigin, origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal))
|
||||
{
|
||||
failures.Add("Desk:AllowedOrigin must be one http(s) origin: scheme, host and optional port, no path.");
|
||||
}
|
||||
|
||||
if (desk.MaxBodyBytes < 1)
|
||||
{
|
||||
failures.Add("Desk:MaxBodyBytes must be at least 1.");
|
||||
}
|
||||
|
||||
if (desk.RetryAfterSeconds < 1)
|
||||
{
|
||||
failures.Add("Desk:RetryAfterSeconds must be at least 1.");
|
||||
}
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
var sockets = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < desk.Minds.Count; i++)
|
||||
{
|
||||
var mind = desk.Minds[i];
|
||||
if (string.IsNullOrWhiteSpace(mind.Id))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Id must not be blank.");
|
||||
}
|
||||
else if (!ids.Add(mind.Id))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Id repeats an earlier Mind id.");
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(mind.Socket))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Socket must be an absolute path.");
|
||||
}
|
||||
else if (!sockets.Add(mind.Socket))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Socket repeats an earlier Mind's socket.");
|
||||
}
|
||||
|
||||
if (mind.Deciders.Count is < 1 or > DeskMindOptions.MaxDeciders)
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Deciders must hold 1 to {DeskMindOptions.MaxDeciders} account ids.");
|
||||
}
|
||||
|
||||
var deciders = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var decider in mind.Deciders)
|
||||
{
|
||||
if (!IsLowercaseGuid(decider))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Deciders entries must be lowercase D-format account ids.");
|
||||
}
|
||||
else if (!deciders.Add(decider))
|
||||
{
|
||||
failures.Add($"Desk:Minds:{i}:Deciders repeats an account id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var attribution = desk.Attribution;
|
||||
if (!string.IsNullOrEmpty(attribution.Operator) && !IsLowercaseGuid(attribution.Operator))
|
||||
{
|
||||
failures.Add("Desk:Attribution:Operator must be a lowercase D-format account id.");
|
||||
}
|
||||
|
||||
var stewards = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var steward in attribution.Stewards)
|
||||
{
|
||||
if (!IsLowercaseGuid(steward))
|
||||
{
|
||||
failures.Add("Desk:Attribution:Stewards entries must be lowercase D-format account ids.");
|
||||
}
|
||||
else if (!stewards.Add(steward))
|
||||
{
|
||||
failures.Add("Desk:Attribution:Stewards repeats an account id.");
|
||||
}
|
||||
else if (string.Equals(steward, attribution.Operator, StringComparison.Ordinal))
|
||||
{
|
||||
failures.Add("Desk:Attribution:Operator must not also be a steward.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLowercaseGuid(string? value) =>
|
||||
Guid.TryParseExact(value, "D", out var guid) && string.Equals(value, guid.ToString("D"), StringComparison.Ordinal);
|
||||
|
||||
private static void Refuse(List<string> failures)
|
||||
{
|
||||
if (failures.Count > 0)
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@
|
|||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active && user.Status != MembershipStatus.Suspended)
|
||||
@if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active)
|
||||
{
|
||||
<form method="post" asp-page-handler="Approve">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
|
|
@ -184,15 +184,6 @@
|
|||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanManageMembership)
|
||||
{
|
||||
<form method="post" asp-page-handler="@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")" style="margin-top: 0.8rem;">
|
||||
<input type="hidden" name="userAccountId" value="@user.UserAccountId" />
|
||||
<input type="text" name="reason" maxlength="500" required placeholder="Reason" />
|
||||
<button class="button warm" type="submit">@(user.Status == MembershipStatus.Suspended ? "Unsuspend" : "Suspend")</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (Model.Actor.CanManageMembership)
|
||||
{
|
||||
<div class="stack-form" style="margin-top: 0.8rem;">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
|
@ -13,7 +12,6 @@ public sealed class IndexModel(
|
|||
BifrostDbContext dbContext,
|
||||
ICurrentBifrostActorAccessor actorAccessor,
|
||||
AuditTrailService auditTrailService,
|
||||
MembershipSuspensionService suspension,
|
||||
TimeProvider timeProvider) : PageModel
|
||||
{
|
||||
[BindProperty]
|
||||
|
|
@ -104,12 +102,7 @@ public sealed class IndexModel(
|
|||
.ThenInclude(x => x.RoleAssignments)
|
||||
.SingleAsync(x => x.Id == userAccountId, cancellationToken);
|
||||
|
||||
if (user.Membership!.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return RedirectToPage(); // only Unsuspend lifts a suspension
|
||||
}
|
||||
|
||||
user.Membership.Status = MembershipStatus.Active;
|
||||
user.Membership!.Status = MembershipStatus.Active;
|
||||
user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow();
|
||||
ApplicationBootstrapper.EnsureRole(
|
||||
user.Membership,
|
||||
|
|
@ -131,21 +124,6 @@ public sealed class IndexModel(
|
|||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostSuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
|
||||
await ChangeSuspensionAsync(userAccountId, reason, suspend: true, cancellationToken);
|
||||
|
||||
public async Task<IActionResult> OnPostUnsuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) =>
|
||||
await ChangeSuspensionAsync(userAccountId, reason, suspend: false, cancellationToken);
|
||||
|
||||
private async Task<IActionResult> ChangeSuspensionAsync(Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var outcome = suspend
|
||||
? await suspension.SuspendAsync(Actor, userAccountId, reason, cancellationToken)
|
||||
: await suspension.UnsuspendAsync(Actor, userAccountId, reason, cancellationToken);
|
||||
return outcome == SuspensionOutcome.NotAdmin ? Forbid() : RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken)
|
||||
{
|
||||
Actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ public sealed class VoteModel(
|
|||
|
||||
// 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 (RequestOriginGuard); no antiforgery cookie or token exists on this flow.
|
||||
// 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;
|
||||
|
|
@ -50,7 +50,7 @@ public sealed class VoteModel(
|
|||
return;
|
||||
}
|
||||
|
||||
if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value))
|
||||
if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value))
|
||||
{
|
||||
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ using Bifrost.Web.Data;
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Bridge;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
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.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
|
@ -78,10 +76,6 @@ builder.Services
|
|||
.AddOptions<ConceptVoteOptions>()
|
||||
.BindConfiguration(ConceptVoteOptions.SectionName);
|
||||
|
||||
builder.Services
|
||||
.AddOptions<DeskOptions>()
|
||||
.BindConfiguration(DeskOptions.SectionName);
|
||||
|
||||
builder.Services.AddAntiforgery(options =>
|
||||
{
|
||||
options.Cookie.Name = BifrostCookies.Antiforgery;
|
||||
|
|
@ -118,7 +112,6 @@ builder.Services.AddHttpContextAccessor();
|
|||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<ICurrentBifrostActorAccessor, CurrentBifrostActorAccessor>();
|
||||
builder.Services.AddScoped<AuditTrailService>();
|
||||
builder.Services.AddScoped<Bifrost.Web.Features.Membership.MembershipSuspensionService>();
|
||||
builder.Services.AddScoped<DashboardSnapshotService>();
|
||||
builder.Services.AddScoped<BridgeActionService>();
|
||||
builder.Services.AddScoped<AgentDispatchRunService>();
|
||||
|
|
@ -132,10 +125,6 @@ builder.Services.AddScoped<SealedBallotService>();
|
|||
builder.Services.AddScoped<MindIntakeService>();
|
||||
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
|
||||
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
|
||||
builder.Services.AddOptions<RegistrationOptions>().BindConfiguration(RegistrationOptions.SectionName);
|
||||
builder.Services.AddSingleton<RegistrationChallengeService>();
|
||||
builder.Services.AddSingleton<RegistrationRateLimit>();
|
||||
builder.Services.AddScoped<NativeRegistration>();
|
||||
builder.Services.AddHostedService<SealedBallotCloser>();
|
||||
builder.Services.AddScoped<MotionEveSurfaceService>();
|
||||
builder.Services.AddScoped<PatronageService>();
|
||||
|
|
@ -263,13 +252,6 @@ if (oidcOptions is not null)
|
|||
builder.Services.AddBifrostOidc(oidcOptions);
|
||||
}
|
||||
|
||||
// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports.
|
||||
var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists();
|
||||
if (deskConfigured)
|
||||
{
|
||||
builder.Services.AddBifrostDesk();
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
|
||||
|
||||
|
|
@ -291,11 +273,6 @@ if (!app.Environment.IsDevelopment())
|
|||
|
||||
app.UseHttpLogging();
|
||||
app.UseHttpsRedirection();
|
||||
if (oidcOptions is not null)
|
||||
{
|
||||
app.UseMiddleware<OidcGuardMiddleware>(); // before authentication: OpenIddict answers /connect/* inside it
|
||||
}
|
||||
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseConceptTallyCors();
|
||||
|
|
@ -316,18 +293,12 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke
|
|||
}).AllowAnonymous();
|
||||
|
||||
app.MapConceptTallyExport();
|
||||
app.MapConceptRankedExport();
|
||||
|
||||
if (oidcOptions is not null)
|
||||
{
|
||||
app.MapBifrostOidc();
|
||||
}
|
||||
|
||||
if (deskConfigured)
|
||||
{
|
||||
app.MapDesk();
|
||||
}
|
||||
|
||||
app.MapPost("/github/webhooks", async (
|
||||
HttpRequest request,
|
||||
GitHubWebhookService gitHubWebhookService,
|
||||
|
|
@ -390,203 +361,35 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
|
|||
app.MapPost("/auth/bifrost/register", async (
|
||||
NativeBifrostRegistrationRequest request,
|
||||
HttpContext httpContext,
|
||||
NativeRegistration registration,
|
||||
RegistrationRateLimit rateLimit,
|
||||
BifrostIdentityService bifrostIdentityService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
var outcome = await registration.RunAsync(
|
||||
httpContext,
|
||||
new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion),
|
||||
cancellationToken);
|
||||
if (outcome.Refusal is not null)
|
||||
{
|
||||
return outcome.Refusal;
|
||||
}
|
||||
|
||||
if (outcome.Account is not { } userAccount)
|
||||
{
|
||||
return Results.Text(outcome.Message!, statusCode: outcome.Status);
|
||||
}
|
||||
|
||||
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
|
||||
return JsonResult(
|
||||
new NativeBifrostRegistrationResponse(
|
||||
userAccount.Id,
|
||||
userAccount.BifrostIdentity,
|
||||
userAccount.DisplayName,
|
||||
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
|
||||
returnUrl),
|
||||
appJsonSerializerOptions);
|
||||
}).AllowAnonymous();
|
||||
|
||||
// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge.
|
||||
app.MapGet("/auth/bifrost/register", (
|
||||
HttpContext httpContext,
|
||||
RegistrationChallengeService challenges,
|
||||
RegistrationRateLimit rateLimit,
|
||||
IOptions<RegistrationOptions> registrationOptions) =>
|
||||
{
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
|
||||
return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK);
|
||||
}).AllowAnonymous();
|
||||
|
||||
// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead
|
||||
// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no
|
||||
// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with
|
||||
// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent.
|
||||
// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie.
|
||||
app.MapPost("/auth/bifrost/register/form", async (
|
||||
HttpContext httpContext,
|
||||
NativeRegistration registration,
|
||||
RegistrationChallengeService challenges,
|
||||
RegistrationRateLimit rateLimit,
|
||||
IOptions<BifrostHostOptions> hostOptions,
|
||||
IOptions<RegistrationOptions> registrationOptions,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value))
|
||||
{
|
||||
return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType);
|
||||
}
|
||||
|
||||
// The form is a handful of short fields: refuse a big body before reading any of it.
|
||||
const int MaxFormBytes = 16 * 1024;
|
||||
if (httpContext.Request.ContentLength > MaxFormBytes)
|
||||
{
|
||||
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
|
||||
}
|
||||
|
||||
if (httpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } limit)
|
||||
{
|
||||
limit.MaxRequestBodySize = MaxFormBytes;
|
||||
}
|
||||
|
||||
IFormCollection form;
|
||||
try
|
||||
{
|
||||
form = await httpContext.Request.ReadFormAsync(cancellationToken);
|
||||
var userAccount = await bifrostIdentityService.RegisterNativeAsync(
|
||||
request.Identity,
|
||||
request.DisplayName,
|
||||
cancellationToken);
|
||||
await httpContext.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
bifrostIdentityService.BuildPrincipal(userAccount));
|
||||
|
||||
var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl;
|
||||
return JsonResult(
|
||||
new NativeBifrostRegistrationResponse(
|
||||
userAccount.Id,
|
||||
userAccount.BifrostIdentity,
|
||||
userAccount.DisplayName,
|
||||
userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(),
|
||||
returnUrl),
|
||||
appJsonSerializerOptions);
|
||||
}
|
||||
catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge)
|
||||
catch (BifrostIdentityException error)
|
||||
{
|
||||
return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge);
|
||||
return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
|
||||
{
|
||||
return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
string Field(string name) => form[name].FirstOrDefault() ?? "";
|
||||
var returnPath = LocalReturnPath.Parse(Field("returnUrl"));
|
||||
// The checkbox is the consent act: without it the posted notice version is not taken as accepted.
|
||||
int? accepted = Field("consent") == "yes" && int.TryParse(Field("acceptedNoticeVersion"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var version)
|
||||
? version
|
||||
: null;
|
||||
var identity = Field("identity");
|
||||
var displayName = Field("displayName");
|
||||
var challenge = Field("challenge");
|
||||
|
||||
var outcome = await registration.RunAsync(
|
||||
httpContext,
|
||||
new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted),
|
||||
cancellationToken);
|
||||
if (outcome.Refusal is not null)
|
||||
{
|
||||
return outcome.Refusal;
|
||||
}
|
||||
|
||||
if (outcome.Account is not null)
|
||||
{
|
||||
// A Location header takes ASCII only; anything else is not a path worth keeping.
|
||||
var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value;
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
httpContext.Response.Headers.Location = target;
|
||||
return Results.StatusCode(StatusCodes.Status303SeeOther);
|
||||
}
|
||||
|
||||
// A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its
|
||||
// challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked.
|
||||
var options = registrationOptions.Value;
|
||||
return RegistrationFormResult(
|
||||
httpContext,
|
||||
challenges,
|
||||
options,
|
||||
new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field),
|
||||
outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null,
|
||||
outcome.Status);
|
||||
|
||||
static string Truncate(string text) => text.Length <= 256 ? text : text[..256];
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) =>
|
||||
{
|
||||
if (rateLimit.Refuse(httpContext) is { } refused)
|
||||
{
|
||||
return refused;
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
var challenge = challenges.Issue();
|
||||
return challenge is null
|
||||
? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable)
|
||||
: JsonResult(challenge, appJsonSerializerOptions);
|
||||
}).AllowAnonymous();
|
||||
|
||||
app.MapGet("/auth/bifrost/notice", () =>
|
||||
JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous();
|
||||
|
||||
// Withdrawal stops future use; the receipt row stays. Idempotent: an already-withdrawn receipt keeps its first timestamp.
|
||||
app.MapPost("/auth/bifrost/consent/withdraw", async (
|
||||
ICurrentBifrostActorAccessor actors,
|
||||
BifrostDbContext dbContext,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var actor = await actors.GetAsync(cancellationToken);
|
||||
if (!actor.IsAuthenticated || actor.UserAccount is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var receipts = await dbContext.ConsentReceipts
|
||||
.Where(x => x.UserAccountId == actor.UserAccount.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (receipts.Count == 0)
|
||||
{
|
||||
return Results.Text("No consent receipt is recorded for this account.", statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
foreach (var receipt in receipts.Where(x => x.WithdrawnAtUtc is null))
|
||||
{
|
||||
receipt.WithdrawnAtUtc = now;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return JsonResult(
|
||||
new { withdrawnAtUtc = receipts.Min(x => x.WithdrawnAtUtc) },
|
||||
appJsonSerializerOptions);
|
||||
});
|
||||
|
||||
app.MapGet("/auth/heimdall/{provider}", async (
|
||||
string provider,
|
||||
HttpContext httpContext,
|
||||
|
|
@ -912,36 +715,6 @@ static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, Can
|
|||
}
|
||||
}
|
||||
|
||||
// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full).
|
||||
static IResult RegistrationFormResult(
|
||||
HttpContext httpContext,
|
||||
RegistrationChallengeService challenges,
|
||||
RegistrationOptions options,
|
||||
RegistrationPageModel model,
|
||||
string? keepChallenge,
|
||||
int statusCode)
|
||||
{
|
||||
var token = keepChallenge;
|
||||
var difficulty = options.Captcha.Difficulty;
|
||||
if (token is null)
|
||||
{
|
||||
var issued = challenges.Issue();
|
||||
if (issued is null)
|
||||
{
|
||||
return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
token = issued.Challenge;
|
||||
difficulty = issued.Difficulty;
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.CacheControl = "no-store";
|
||||
return Results.Content(
|
||||
RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }),
|
||||
"text/html; charset=utf-8",
|
||||
statusCode: statusCode);
|
||||
}
|
||||
|
||||
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
|
||||
{
|
||||
httpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||
|
|
@ -995,11 +768,7 @@ public partial class Program;
|
|||
public sealed record NativeBifrostRegistrationRequest(
|
||||
string Identity,
|
||||
string? DisplayName,
|
||||
string? ReturnUrl,
|
||||
string? Challenge,
|
||||
string? Nonce,
|
||||
int? AcceptedNoticeVersion,
|
||||
bool? Waited = null);
|
||||
string? ReturnUrl);
|
||||
|
||||
public sealed record NativeBifrostRegistrationResponse(
|
||||
Guid UserAccountId,
|
||||
|
|
|
|||
|
|
@ -1,561 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class ConceptRankedExportTests : IClassFixture<ConceptRankedExportTests.RankedFactory>
|
||||
{
|
||||
private const string AllowedOrigin = "https://aetheria.gamecult.org";
|
||||
private static readonly DateTimeOffset Epoch = new(2026, 11, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly RankedFactory _factory;
|
||||
|
||||
public ConceptRankedExportTests(RankedFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Net_ranks_up_minus_down_and_ties_break_by_key()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var keys = Keys(await GetOk(c, "?sort=net"));
|
||||
|
||||
Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_order_is_net_and_the_default_page_is_everything_under_the_cap()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var body = await GetOk(c, "");
|
||||
|
||||
Assert.Equal(Keys(await GetOk(c, "?sort=net")), Keys(body));
|
||||
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Total_ranks_up_plus_down()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Keys(await GetOk(c, "?sort=total")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Newest_ranks_by_registration_time()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], Keys(await GetOk(c, "?sort=newest")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Items_carry_counts_and_title_only()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var first = (await GetOk(c, "?limit=1")).GetProperty("items")[0];
|
||||
|
||||
Assert.Equal(["down", "key", "members", "title", "up"], first.EnumerateObject().Select(p => p.Name).Order().ToArray());
|
||||
Assert.Equal(["down", "up"], first.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("net")]
|
||||
[InlineData("total")]
|
||||
[InlineData("newest")]
|
||||
public async Task Paging_walks_every_subject_once_in_the_same_order_as_one_big_page(string sort)
|
||||
{
|
||||
var c = await Seed();
|
||||
var whole = Keys(await GetOk(c, $"?sort={sort}"));
|
||||
|
||||
var walked = new List<string>();
|
||||
string? cursor = null;
|
||||
var pages = 0;
|
||||
do
|
||||
{
|
||||
var body = await GetOk(c, $"?sort={sort}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor));
|
||||
walked.AddRange(Keys(body));
|
||||
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
|
||||
pages++;
|
||||
}
|
||||
while (cursor is not null && pages < 10);
|
||||
|
||||
Assert.Equal(whole, walked);
|
||||
Assert.Equal(3, pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ties_break_by_ordinal_key_even_when_keys_hold_hyphens_and_were_registered_out_of_order()
|
||||
{
|
||||
var c = "c" + Guid.NewGuid().ToString("N");
|
||||
string[] ordinal = ["a-b", "a-c", "ab", "b", "b-a"];
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
foreach (var key in ordinal.Reverse())
|
||||
{
|
||||
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key, RegisteredAtUtc = Epoch });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var walked = new List<string>();
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var body = await GetOk(c, "?limit=1" + (cursor is null ? "" : "&cursor=" + cursor));
|
||||
walked.AddRange(Keys(body));
|
||||
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
|
||||
}
|
||||
while (cursor is not null && walked.Count < 10);
|
||||
|
||||
Assert.Equal(ordinal, walked);
|
||||
Assert.Equal(ordinal, Keys(await GetOk(c, "?sort=newest")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unvoted_without_a_client_address_still_pages()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var first = await GetOk(c, "?unvoted=network&limit=2");
|
||||
var second = await GetOk(c, "?unvoted=network&limit=2&cursor=" + first.GetProperty("next_cursor").GetString());
|
||||
|
||||
Assert.Equal(["bravo", "alpha"], Keys(first));
|
||||
Assert.Equal(["charlie", "delta"], Keys(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_vote_between_pages_never_repeats_the_cursors_own_row()
|
||||
{
|
||||
var c = await Seed();
|
||||
var first = await GetOk(c, "?limit=2");
|
||||
var seen = Keys(first);
|
||||
|
||||
await Cast(c, "echo", "mover-1", ConceptStance.Up);
|
||||
await Cast(c, "echo", "mover-2", ConceptStance.Up);
|
||||
await Cast(c, "echo", "mover-3", ConceptStance.Up);
|
||||
await Cast(c, "echo", "mover-4", ConceptStance.Up);
|
||||
var second = Keys(await GetOk(c, "?limit=5&cursor=" + first.GetProperty("next_cursor").GetString()));
|
||||
|
||||
Assert.Empty(seen.Intersect(second));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("?limit=0")]
|
||||
[InlineData("?limit=101")]
|
||||
[InlineData("?limit=-1")]
|
||||
[InlineData("?limit=+5")]
|
||||
[InlineData("?limit=05")]
|
||||
[InlineData("?limit=5.0")]
|
||||
[InlineData("?limit=%205")]
|
||||
[InlineData("?limit=")]
|
||||
[InlineData("?limit=abc")]
|
||||
[InlineData("?limit=99999999999999999999")]
|
||||
[InlineData("?sort=")]
|
||||
[InlineData("?sort=oldest")]
|
||||
[InlineData("?sort=Net")]
|
||||
[InlineData("?sort=net&sort=total")]
|
||||
[InlineData("?limit=1&limit=2")]
|
||||
[InlineData("?unvoted=")]
|
||||
[InlineData("?unvoted=all")]
|
||||
[InlineData("?unvoted=network&unvoted=network")]
|
||||
[InlineData("?Sort=net")]
|
||||
[InlineData("?tag=abc")]
|
||||
[InlineData("?networkTag=abc")]
|
||||
[InlineData("?page=2")]
|
||||
public async Task A_query_outside_the_closed_shape_is_a_400(string query)
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
using var response = await Get(c, query);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Equal("request_invalid", JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_page_cap_is_one_hundred_and_one_hundred_is_allowed()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
using var ok = await Get(c, "?limit=100");
|
||||
using var over = await Get(c, "?limit=101");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, over.StatusCode);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> HostileCursors()
|
||||
{
|
||||
static string B64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
yield return ["x"];
|
||||
yield return ["%00"];
|
||||
yield return ["not base64!"];
|
||||
yield return ["AAAA"];
|
||||
yield return [new string('A', 201)];
|
||||
yield return [new string('A', 100_000)];
|
||||
yield return [B64("")];
|
||||
yield return [B64("1|0|0|5")];
|
||||
yield return [B64("1|0|0|5|alpha|extra")];
|
||||
yield return [B64("2|0|0|5|alpha")];
|
||||
yield return [B64("1|3|0|5|alpha")];
|
||||
yield return [B64("1|-1|0|5|alpha")];
|
||||
yield return [B64("1|0|2|5|alpha")];
|
||||
yield return [B64("1|0|0|abc|alpha")];
|
||||
yield return [B64("1|0|0|+5|alpha")];
|
||||
yield return [B64("1|0|0|-0|alpha")];
|
||||
yield return [B64("1|0|0|05|alpha")];
|
||||
yield return [B64("1|0|0|99999999999999999999|alpha")];
|
||||
yield return [B64("1|0|0|5|Alpha")];
|
||||
yield return [B64("1|0|0|5|al pha")];
|
||||
yield return [B64("1|0|0|5|alpha\n")];
|
||||
yield return [B64("1|0|0|5|'; drop table")];
|
||||
yield return [B64("1|0|0|5|../x")];
|
||||
yield return [B64("1|0|0|5|" + new string('a', 81))];
|
||||
yield return [B64("1|0|0|5|")];
|
||||
yield return [Convert.ToBase64String(Encoding.UTF8.GetBytes("1|0|0|5|alpha"))];
|
||||
yield return [B64("1|0|0|5|alpha") + "="];
|
||||
yield return [Convert.ToBase64String([0xFF, 0xFE, 0xFD, 0xFC]).Replace('+', '-').Replace('/', '_').TrimEnd('=')];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(HostileCursors))]
|
||||
public async Task A_hostile_cursor_is_a_400_and_never_a_query(string cursor)
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
using var response = await Get(c, "?cursor=" + Uri.EscapeDataString(cursor));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cursor_is_bound_to_the_sort_and_the_filter_it_was_issued_for()
|
||||
{
|
||||
var c = await Seed();
|
||||
var net = (await GetOk(c, "?limit=1")).GetProperty("next_cursor").GetString()!;
|
||||
var unvoted = (await GetOk(c, "?limit=1&unvoted=network", client: "198.51.100.9")).GetProperty("next_cursor").GetString()!;
|
||||
|
||||
using var otherSort = await Get(c, "?sort=total&cursor=" + net);
|
||||
using var dropsFilter = await Get(c, "?cursor=" + unvoted);
|
||||
using var addsFilter = await Get(c, "?unvoted=network&cursor=" + net, client: "198.51.100.9");
|
||||
using var same = await Get(c, "?limit=1&cursor=" + net);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, otherSort.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, dropsFilter.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, addsFilter.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, same.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_well_formed_cursor_past_every_row_is_an_empty_last_page()
|
||||
{
|
||||
var c = await Seed();
|
||||
var past = new RankCursor(RankOrder.Net, false, long.MinValue, "a").Encode();
|
||||
|
||||
var body = await GetOk(c, "?cursor=" + past);
|
||||
|
||||
Assert.Empty(Keys(body));
|
||||
Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Alpha")]
|
||||
[InlineData("a_b")]
|
||||
[InlineData("..")]
|
||||
[InlineData("a%2Fb")]
|
||||
[InlineData("a%0A")]
|
||||
[InlineData("%27%3B--")]
|
||||
[InlineData("nope")]
|
||||
public async Task A_hostile_or_unknown_collection_is_a_404(string collection)
|
||||
{
|
||||
using var response = await Get(collection, "");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_collection_over_the_key_grammar_is_a_404()
|
||||
{
|
||||
using var response = await Get(new string('a', 81), "");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retired_subjects_are_left_out()
|
||||
{
|
||||
var c = await Seed();
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
(await db.ConceptSubjects.SingleAsync(x => x.Collection == c && x.Key == "bravo")).RetiredAtUtc = Epoch;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Assert.DoesNotContain("bravo", Keys(await GetOk(c, "")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unvoted_leaves_out_what_the_callers_network_voted_and_not_what_others_did()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var fromVoter = Keys(await GetOk(c, "?unvoted=network", client: Voter));
|
||||
var fromStranger = Keys(await GetOk(c, "?unvoted=network", client: "198.51.100.77"));
|
||||
|
||||
Assert.Equal(["charlie", "delta", "echo"], fromVoter.Order().ToArray());
|
||||
Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], fromStranger.Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unvoted_pages_and_ranks_the_same_way()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
var walked = new List<string>();
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
var body = await GetOk(c, "?unvoted=network&sort=newest&limit=1" + (cursor is null ? "" : "&cursor=" + cursor), client: "198.51.100.77");
|
||||
walked.AddRange(Keys(body));
|
||||
cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString();
|
||||
}
|
||||
while (cursor is not null && walked.Count < 10);
|
||||
|
||||
Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], walked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unvoted_without_a_client_address_filters_nothing_and_matches_no_untagged_row()
|
||||
{
|
||||
var c = await Seed();
|
||||
await CastUntagged(c, "echo", "ghost-device");
|
||||
|
||||
var keys = Keys(await GetOk(c, "?unvoted=network", client: null));
|
||||
|
||||
Assert.Equal(5, keys.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unvoted_and_plain_responses_expose_no_tag_or_voter_identity()
|
||||
{
|
||||
var c = await Seed();
|
||||
string[] tags;
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
tags = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
|
||||
.ConceptAppraisals.Where(x => x.NetworkTag != "").Select(x => x.NetworkTag).Distinct().ToArrayAsync();
|
||||
}
|
||||
|
||||
Assert.NotEmpty(tags);
|
||||
foreach (var query in new[] { "", "?unvoted=network", "?limit=2&unvoted=network" })
|
||||
{
|
||||
using var response = await Get(c, query, client: Voter);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
foreach (var forbidden in tags.Concat(["voter-", "device:", "account:", "networktag", "voterkey", Voter]))
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is position only: it carries no tag either.
|
||||
var cursor = (await GetOk(c, "?limit=1&unvoted=network", client: Voter)).GetProperty("next_cursor").GetString()!;
|
||||
var decoded = Encoding.UTF8.GetString(Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(cursor));
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
Assert.DoesNotContain(tag, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_in_the_request_can_name_a_tag_or_a_client()
|
||||
{
|
||||
var c = await Seed();
|
||||
using var client = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{c}/ranked?unvoted=network");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
request.Headers.Add("X-Test-Client", "198.51.100.77");
|
||||
request.Headers.Add("X-Network-Tag", "anything");
|
||||
request.Headers.Add("X-Forwarded-For", Voter);
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
// The test connection address wins; the forwarded header is the proxy's to set, not the request's.
|
||||
Assert.Equal(5, Keys(JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cache_control_is_public_for_the_plain_read_and_never_cached_for_the_unvoted_one()
|
||||
{
|
||||
var c = await Seed();
|
||||
|
||||
using var plain = await Get(c, "");
|
||||
using var unvoted = await Get(c, "?unvoted=network", client: Voter);
|
||||
|
||||
Assert.True(plain.Headers.CacheControl!.Public);
|
||||
Assert.Equal(TimeSpan.FromSeconds(60), plain.Headers.CacheControl.MaxAge);
|
||||
Assert.True(unvoted.Headers.CacheControl!.Private);
|
||||
Assert.True(unvoted.Headers.CacheControl.NoStore);
|
||||
Assert.False(unvoted.Headers.CacheControl.Public);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cors_and_vary_follow_the_tally_export_policy_and_the_read_sets_no_cookie_and_writes_nothing()
|
||||
{
|
||||
var c = await Seed();
|
||||
int rows, audits;
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
rows = db.ConceptAppraisals.Count();
|
||||
audits = db.AuditEvents.Count();
|
||||
}
|
||||
|
||||
using var allowed = await Get(c, "", origin: AllowedOrigin);
|
||||
using var other = await Get(c, "", origin: "https://evil.example");
|
||||
using var bad = await Get(c, "?limit=0", origin: AllowedOrigin);
|
||||
|
||||
Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
|
||||
Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
|
||||
Assert.False(other.Headers.Contains("Access-Control-Allow-Origin"));
|
||||
foreach (var response in new[] { allowed, other, bad })
|
||||
{
|
||||
Assert.Contains("Origin", response.Headers.Vary.ToArray());
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
}
|
||||
|
||||
using var after = _factory.Services.CreateScope();
|
||||
var dbAfter = after.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Equal(rows, dbAfter.ConceptAppraisals.Count());
|
||||
Assert.Equal(audits, dbAfter.AuditEvents.Count());
|
||||
}
|
||||
|
||||
// The network that cast the votes in Seed.
|
||||
private const string Voter = "203.0.113.7";
|
||||
|
||||
// alpha 3 up 2 down (net 1, total 5), bravo 2 up (net 2, total 2), charlie 1 up 1 down (net 0, total 2),
|
||||
// delta and echo unvoted (net 0, total 0). Registered alpha first, echo last. Voter's network cast alpha and
|
||||
// bravo; charlie's votes came from a second network.
|
||||
private async Task<string> Seed()
|
||||
{
|
||||
var c = "c" + Guid.NewGuid().ToString("N");
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var i = 0;
|
||||
foreach (var key in new[] { "alpha", "bravo", "charlie", "delta", "echo" })
|
||||
{
|
||||
db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key.ToUpperInvariant(), RegisteredAtUtc = Epoch.AddDays(i++) });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await Cast(c, "alpha", "voter-1", ConceptStance.Up);
|
||||
await Cast(c, "alpha", "voter-2", ConceptStance.Up);
|
||||
await Cast(c, "alpha", "voter-3", ConceptStance.Up);
|
||||
await Cast(c, "alpha", "voter-4", ConceptStance.Down);
|
||||
await Cast(c, "alpha", "voter-5", ConceptStance.Down);
|
||||
await Cast(c, "bravo", "voter-1", ConceptStance.Up);
|
||||
await Cast(c, "bravo", "voter-2", ConceptStance.Up);
|
||||
await Cast(c, "charlie", "other-1", ConceptStance.Up, "192.0.2.50");
|
||||
await Cast(c, "charlie", "other-2", ConceptStance.Down, "192.0.2.50");
|
||||
return c;
|
||||
}
|
||||
|
||||
private async Task Cast(string collection, string key, string device, ConceptStance stance, string client = Voter)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
|
||||
var result = await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
|
||||
.CastAsync(collection, key, voter, stance, IPAddress.Parse(client), default);
|
||||
Assert.Equal(CastResult.Recorded, result);
|
||||
}
|
||||
|
||||
private async Task CastUntagged(string collection, string key, string device)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!;
|
||||
await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
|
||||
.CastAsync(collection, key, voter, ConceptStance.Up, null, default);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Get(string collection, string query, string? client = null, string? origin = null)
|
||||
{
|
||||
using var http = _factory.CreateClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/ranked{query}");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
if (client is not null)
|
||||
{
|
||||
request.Headers.Add("X-Test-Client", client);
|
||||
}
|
||||
|
||||
if (origin is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Origin", origin);
|
||||
}
|
||||
|
||||
return await http.SendAsync(request);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetOk(string collection, string query, string? client = null)
|
||||
{
|
||||
using var response = await Get(collection, query, client);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string[] Keys(JsonElement body) =>
|
||||
body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("key").GetString()!).ToArray();
|
||||
|
||||
// The test host has no connection address; a header stands in for the one the proxy path would have resolved.
|
||||
private sealed class ClientAddressFilter : IStartupFilter
|
||||
{
|
||||
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
|
||||
{
|
||||
app.Use((context, nextMiddleware) =>
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-Test-Client", out var address))
|
||||
{
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(address.ToString());
|
||||
}
|
||||
|
||||
return nextMiddleware(context);
|
||||
});
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class RankedFactory : TestWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureServices(services => services.AddTransient<IStartupFilter, ClientAddressFilter>());
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin,
|
||||
["ConceptVotes:NetworkTagSecret"] = "ranked-test-tag-secret-0123456789-abcdef"
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskAnswerTests
|
||||
{
|
||||
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
|
||||
|
||||
private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync()
|
||||
{
|
||||
var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
|
||||
}
|
||||
|
||||
private static AnswerResult Stale(string forkId) => new(
|
||||
DeskSchemas.AnswerResult,
|
||||
new DeskAnswerDocument(
|
||||
DeskSchemas.DeskAnswer, "n1", forkId, "Refused", null, null, "stale_fork", "oldhash", "no", null,
|
||||
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
|
||||
"00000001-0000-4000-8000-000000000001"),
|
||||
null,
|
||||
null);
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_fork_attaches_current_card_fresh_and_replayed()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
|
||||
var target = DeskReplay.Target("answer", "gamecult-chat", null);
|
||||
|
||||
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card"));
|
||||
var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
// The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one.
|
||||
factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card"));
|
||||
var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
Assert.Equal(409, fresh.Status);
|
||||
Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]);
|
||||
Assert.Equal(409, replayed.Status);
|
||||
Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
var closed = Stale(DeskReplay.ChatFork);
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } });
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
Assert.Equal(409, reply.Status);
|
||||
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
|
||||
Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_answers_card_read_of_an_oversize_fork_is_422_fork_too_large_naming_the_fork()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
var doc = JsonNode.Parse(reply.Body)!;
|
||||
Assert.Equal(422, reply.Status);
|
||||
Assert.Equal("fork_too_large", (string?)doc["code"]);
|
||||
Assert.Contains(DeskReplay.ChatFork, (string)doc["error"]!);
|
||||
Assert.Null(doc["card"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_replayed_request_id_that_is_stale_again_takes_the_same_reread_and_gets_the_same_422()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
// The door's stored Refused(stale_fork) for this request_id, returned on every replay while the fork stays oversize.
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
var body = DeskReplay.AnswerBody(DeskReplay.ChatFork);
|
||||
var target = DeskReplay.Target("answer", "gamecult-chat", null);
|
||||
|
||||
var first = await factory.SendAsync("POST", target, body, token);
|
||||
var replay = await factory.SendAsync("POST", target, body, token);
|
||||
|
||||
Assert.Equal(422, first.Status);
|
||||
Assert.Equal(first.Status, replay.Status);
|
||||
Assert.Equal(first.Body, replay.Body);
|
||||
Assert.Equal("fork_too_large", (string?)JsonNode.Parse(replay.Body)!["code"]);
|
||||
Assert.Contains(DeskReplay.ChatFork, (string)JsonNode.Parse(replay.Body)!["error"]!);
|
||||
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "answer"));
|
||||
Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "read"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed()
|
||||
{
|
||||
var rows = DeskVectors.Rows("transport", "answerTooLarge").ToList();
|
||||
Assert.Single(rows);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
var request = row["request"]!;
|
||||
var forkId = (string)row["fork_id"]!;
|
||||
Assert.Equal(forkId, (string)request["fork_id"]!);
|
||||
var stale = new AnswerResult(
|
||||
DeskSchemas.AnswerResult,
|
||||
new DeskAnswerDocument(
|
||||
DeskSchemas.DeskAnswer, "n1", forkId, (string)row["outcome"]!, null, null, (string)row["code"]!,
|
||||
(string)request["options_hash"]!, (string?)request["answer"]!["choice"], (string?)request["answer"]!["note"],
|
||||
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
|
||||
(string)request["request_id"]!),
|
||||
null,
|
||||
null);
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
var body = DeskVectors.Bytes(request);
|
||||
var target = DeskReplay.Target("answer", "gamecult-chat", null);
|
||||
|
||||
var fresh = await factory.SendAsync("POST", target, body, token);
|
||||
var replay = await factory.SendAsync("POST", target, body, token);
|
||||
|
||||
foreach (var reply in new[] { fresh, replay })
|
||||
{
|
||||
Assert.Equal((int)row["httpStatus"]!, reply.Status);
|
||||
var doc = JsonNode.Parse(reply.Body);
|
||||
Assert.True(DeskVectors.SameRefusal(doc, row["refusal"], forkId, null), (string)row["name"]!);
|
||||
Assert.Null(doc!["card"]);
|
||||
}
|
||||
|
||||
Assert.Equal(fresh.Body, replay.Body);
|
||||
// The door got the client's bytes untouched, stored nothing counted, and was asked for the card twice.
|
||||
Assert.All(factory.Door.Calls.Where(c => c.Kind == "answer"), c => Assert.Equal(body, c.Body));
|
||||
Assert.Equal(forkId, factory.Door.Calls.First(c => c.Kind == "read").ForkId);
|
||||
Assert.False((bool)row["counted"]!);
|
||||
Assert.NotEqual("Counted", stale.Answer.Outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Card_read_failure_is_unavailable()
|
||||
{
|
||||
foreach (var failure in new[] { "throws", "refuses" })
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork));
|
||||
factory.Door.ThrowOnRead = failure == "throws";
|
||||
// "refuses": no fork held, so the read answers fork_unknown
|
||||
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
|
||||
Assert.True(503 == reply.Status, failure);
|
||||
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_body_reaches_the_door_byte_for_byte()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
// Whitespace, escapes and key spacing a re-serialiser would normalise.
|
||||
var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork));
|
||||
var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal)
|
||||
.Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n";
|
||||
var bytes = Encoding.UTF8.GetBytes(odd);
|
||||
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token);
|
||||
|
||||
Assert.Equal(200, reply.Status);
|
||||
Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request()
|
||||
{
|
||||
var (factory, _) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer");
|
||||
|
||||
// Query strings and headers that name a decider or an answerer change nothing.
|
||||
var reply = await factory.SendAsync(
|
||||
"POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x",
|
||||
DeskReplay.AnswerBody("nibu:question:glow-order"), token,
|
||||
configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member);
|
||||
|
||||
Assert.Equal(200, reply.Status);
|
||||
var call = Assert.Single(factory.Door.Calls);
|
||||
Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject);
|
||||
Assert.Equal("Steward", call.Context.Authority);
|
||||
Assert.Equal("nibu", call.Context.Mind);
|
||||
Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Body_is_judged_after_membership_and_before_the_mind()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
// A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden.
|
||||
var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}");
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
|
||||
Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
|
||||
var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token);
|
||||
Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]);
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Membership_is_re_read_on_every_request()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
var target = DeskReplay.Target("answer", "gamecult-chat", null);
|
||||
Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false);
|
||||
Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_oversized_body_is_refused_before_it_is_read()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}");
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token);
|
||||
Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
|
||||
// Chunked: no Content-Length to judge, so the cap applies while reading.
|
||||
var chunked = await factory.SendAsync(
|
||||
"POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token,
|
||||
configure: http => http.Request.ContentLength = null);
|
||||
Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]);
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable()
|
||||
{
|
||||
var (factory, token) = await ArrangeAsync();
|
||||
using (factory)
|
||||
{
|
||||
foreach (var script in new Func<DeskAnswerOutcome>[]
|
||||
{
|
||||
() => new DeskAnswerOutcome.NotStored("something_internal"),
|
||||
() => new DeskAnswerOutcome.NotStored("forbidden"),
|
||||
() => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }),
|
||||
})
|
||||
{
|
||||
factory.Door.AnswerScript = (_, _, _) => script();
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
Assert.Equal(503, reply.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskAuthCaseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Vector_auth_cases()
|
||||
{
|
||||
var cases = DeskVectors.Rows("transport", "authCases").ToList();
|
||||
Assert.Equal(22, cases.Count);
|
||||
|
||||
foreach (var row in cases)
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
factory.Door.Down = (bool)row["huginn_down"]!;
|
||||
var token = await DeskReplay.TokenAsync(factory, row["token"]);
|
||||
|
||||
var route = (string)row["route"]!;
|
||||
var reply = await factory.SendAsync(
|
||||
route == "answer" ? "POST" : "GET", DeskReplay.Target(route, "gamecult-chat", DeskReplay.ChatFork),
|
||||
route == "answer" ? DeskReplay.AnswerBody(DeskReplay.ChatFork) : null, token);
|
||||
|
||||
var name = (string)row["name"]!;
|
||||
var expect = (string)row["expect"]!;
|
||||
var body = JsonNode.Parse(reply.Body);
|
||||
switch (expect)
|
||||
{
|
||||
case "ok":
|
||||
Assert.True(200 == reply.Status, $"{name}: {reply.Status} {reply.Text}");
|
||||
Assert.Single(factory.Door.Calls);
|
||||
break;
|
||||
case "unavailable":
|
||||
Assert.True(503 == reply.Status, $"{name}: {reply.Status}");
|
||||
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskUnavailables.Document(30), DeskJson.Options))), name);
|
||||
break;
|
||||
default:
|
||||
Assert.True(DeskRefusals.StatusOf(expect) == reply.Status, $"{name}: {reply.Status}");
|
||||
Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), name);
|
||||
// Auth, scope and membership are judged before Huginn is asked.
|
||||
Assert.True(factory.Door.Calls.Count == 0, $"{name}: the door was called");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskBodyShapeTests
|
||||
{
|
||||
private const int Cap = 16 * 1024;
|
||||
|
||||
private static DeskBodyVerdict Judge(JsonNode body, int cap = Cap) => DeskBodyShape.Judge(DeskVectors.Bytes(body), cap);
|
||||
|
||||
private static DeskBodyVerdict Judge(string json, int cap = Cap) => DeskBodyShape.Judge(Encoding.UTF8.GetBytes(json), cap);
|
||||
|
||||
private static JsonObject ValidBody() =>
|
||||
DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == "choice_and_note")["message"]!.DeepClone().AsObject();
|
||||
|
||||
private static string ExpectedCode(string name) => name switch
|
||||
{
|
||||
"answerer_key" or "authority_key" => DeskRefusals.BodyForbiddenField,
|
||||
"answer_object_with_a_key" => DeskRefusals.BodyUnknownField,
|
||||
_ => DeskRefusals.BodyInvalid,
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> ValidRows() =>
|
||||
DeskVectors.Rows("request", "valid").Select(row => new object[] { (string)row["name"]! });
|
||||
|
||||
public static IEnumerable<object[]> InvalidRows() =>
|
||||
DeskVectors.Rows("request", "invalid").Select(row => new object[] { (string)row["name"]! });
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidRows))]
|
||||
public void Valid_request_vectors_pass(string name)
|
||||
{
|
||||
var message = DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == name)["message"]!;
|
||||
|
||||
var verdict = Judge(message);
|
||||
|
||||
Assert.Null(verdict.RefusalCode);
|
||||
Assert.NotNull(verdict.Request);
|
||||
// The parsed request is the document: same keys, same order, same values.
|
||||
var echoed = JsonSerializer.SerializeToNode(verdict.Request, DeskJson.Options);
|
||||
Assert.True(DeskVectors.SameShape(message, echoed), $"{name} did not round-trip: {echoed}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidRows))]
|
||||
public void Invalid_request_vectors_refuse_with_their_code(string name)
|
||||
{
|
||||
var message = DeskVectors.Rows("request", "invalid").First(row => (string)row["name"]! == name)["message"]!;
|
||||
|
||||
var verdict = Judge(message);
|
||||
|
||||
Assert.Null(verdict.Request);
|
||||
Assert.Equal(ExpectedCode(name), verdict.RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forbidden_key_at_any_depth_beats_unknown_key()
|
||||
{
|
||||
foreach (var key in new[] { "answerer", "authority" })
|
||||
{
|
||||
var nested = ValidBody();
|
||||
nested["answer"]!["detail"] = new JsonObject { ["deep"] = new JsonArray(new JsonObject { [key] = "x" }) };
|
||||
nested["extra"] = 1;
|
||||
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(nested).RefusalCode);
|
||||
|
||||
var top = ValidBody();
|
||||
top["unknown"] = 1;
|
||||
top[key] = "someone";
|
||||
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(top).RefusalCode);
|
||||
|
||||
// Written with an escape: the name is the same once decoded, and still refused whole.
|
||||
var escaped = DeskVectors.Bytes(ValidBody()).ToArray();
|
||||
var text = Encoding.UTF8.GetString(escaped).TrimEnd('}') + $",\"{key[..3]}\\u{(int)key[3]:x4}{key[4..]}\":\"x\"}}";
|
||||
Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(text).RefusalCode);
|
||||
}
|
||||
|
||||
// The key name is matched exactly: a near miss is only an unknown key.
|
||||
var near = ValidBody();
|
||||
near["Answerer"] = "x";
|
||||
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(near).RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mind_and_deciders_in_the_body_are_unknown_fields()
|
||||
{
|
||||
foreach (var key in new[] { "mind", "deciders" })
|
||||
{
|
||||
var body = ValidBody();
|
||||
body[key] = "x";
|
||||
Assert.Equal(DeskRefusals.BodyUnknownField, Judge(body).RefusalCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Illegal_retraction_forms_reach_the_door()
|
||||
{
|
||||
var forms = new (string Answer, string Retracted, string Retracts)[]
|
||||
{
|
||||
("null", "true", "null"), // a retraction with no target
|
||||
("{\"choice\":\"no\",\"note\":null}", "true", "\"n1\""), // a retraction carrying an answer
|
||||
("{\"choice\":\"no\",\"note\":null}", "false", "\"n1\""), // a plain answer with a target
|
||||
("null", "true", "\"n1\""), // the legal retraction
|
||||
};
|
||||
|
||||
foreach (var (answer, retracted, retracts) in forms)
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["answer"] = JsonNode.Parse(answer);
|
||||
body["retracted"] = JsonNode.Parse(retracted);
|
||||
body["retracts"] = JsonNode.Parse(retracts);
|
||||
|
||||
var verdict = Judge(body);
|
||||
|
||||
Assert.True(verdict.RefusalCode is null, $"answer={answer} retracted={retracted} retracts={retracts}: {verdict.RefusalCode}");
|
||||
Assert.NotNull(verdict.Request);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neither_answer_nor_retraction_is_body_invalid()
|
||||
{
|
||||
foreach (var retracts in new[] { "null", "\"n1\"" })
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["answer"] = null;
|
||||
body["retracted"] = false;
|
||||
body["retracts"] = JsonNode.Parse(retracts);
|
||||
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Note_limits_are_utf8_bytes()
|
||||
{
|
||||
string Note(string text) => Judge(WithNote(text)).RefusalCode ?? "pass";
|
||||
|
||||
string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count));
|
||||
var astral = char.ConvertFromUtf32(0x1F600); // 4 bytes
|
||||
var combining = "e" + (char)0x0301; // 3 bytes
|
||||
var twoByte = ((char)0xe9).ToString(); // 2 bytes
|
||||
var spaces = new string(new[] { (char)9, (char)0xa0, (char)0x3000, (char)0x2028 });
|
||||
|
||||
Assert.Equal("pass", Note(new string('x', 4000)));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(new string('x', 4001)));
|
||||
Assert.Equal("pass", Note(Repeat(astral, 1000)));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(astral, 1001)));
|
||||
Assert.Equal("pass", Note(Repeat(combining, 1333) + "x"));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(combining, 1334)));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(twoByte, 1999) + "xxx"));
|
||||
Assert.Equal("pass", Note("x"));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(""));
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Note(spaces));
|
||||
Assert.Equal("pass", Note(" a "));
|
||||
|
||||
static JsonNode WithNote(string text)
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["answer"] = new JsonObject { ["choice"] = null, ["note"] = text };
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lone_surrogate_in_a_note_is_body_invalid()
|
||||
{
|
||||
var json = DeskVectors.Bytes(ValidBody()).ToArray();
|
||||
var text = Encoding.UTF8.GetString(json).Replace("\"note\":\"", "\"note\":\"\\ud800");
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Over_cap_refused_before_parse()
|
||||
{
|
||||
var body = ValidBody();
|
||||
var bytes = DeskVectors.Bytes(body);
|
||||
|
||||
Assert.Null(DeskBodyShape.Judge(bytes, bytes.Length).RefusalCode);
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bytes, bytes.Length - 1).RefusalCode);
|
||||
|
||||
// Over the cap the forbidden key is never found, because the body is never parsed.
|
||||
body["answerer"] = "x";
|
||||
var forbidden = DeskVectors.Bytes(body);
|
||||
Assert.Equal(DeskRefusals.BodyForbiddenField, DeskBodyShape.Judge(forbidden, forbidden.Length).RefusalCode);
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(forbidden, forbidden.Length - 1).RefusalCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("[]")]
|
||||
[InlineData("null")]
|
||||
[InlineData("\"text\"")]
|
||||
[InlineData("{")]
|
||||
public void Non_object_or_broken_json_is_body_invalid(string json) =>
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(json).RefusalCode);
|
||||
|
||||
[Fact]
|
||||
public void Invalid_utf8_is_body_invalid()
|
||||
{
|
||||
var bytes = DeskVectors.Bytes(ValidBody());
|
||||
var bad = bytes.Take(bytes.Length - 2).Concat(new byte[] { 0xff }).Concat(bytes.Skip(bytes.Length - 2)).ToArray();
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bad, Cap).RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_key_is_body_invalid()
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).TrimEnd('}') + ",\"fork_id\":\"other\"}";
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode);
|
||||
|
||||
var answer = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).Replace("\"choice\":\"no\"", "\"choice\":\"no\",\"choice\":\"yes\"");
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(answer).RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trailing_newline_does_not_rescue_a_bad_pattern()
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["request_id"] = ((string)body["request_id"]!) + "\n";
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
|
||||
body = ValidBody();
|
||||
body["options_hash"] = ((string)body["options_hash"]!) + "\n";
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrong_schema_version_and_bad_retracts_are_body_invalid()
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["schemaVersion"] = "gamecult.fork.answer_request.v1";
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
|
||||
foreach (var retracts in new[] { "n0", "n01", "x1", "n" })
|
||||
{
|
||||
body = ValidBody();
|
||||
body["answer"] = null;
|
||||
body["retracted"] = true;
|
||||
body["retracts"] = retracts;
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_key_is_body_invalid_for_each_key()
|
||||
{
|
||||
foreach (var key in ValidBody().Select(p => p.Key).ToList())
|
||||
{
|
||||
var body = ValidBody();
|
||||
body.Remove(key);
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Answer_object_must_hold_both_keys()
|
||||
{
|
||||
var body = ValidBody();
|
||||
body["answer"] = new JsonObject { ["choice"] = "no" };
|
||||
Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskDocumentTests
|
||||
{
|
||||
private static JsonNode? Write<T>(T document) => JsonSerializer.SerializeToNode(document, DeskJson.Options);
|
||||
|
||||
[Fact]
|
||||
public void Refusal_and_unavailable_documents_match_vectors()
|
||||
{
|
||||
var rows = DeskVectors.Rows("doorRefusals").ToList();
|
||||
Assert.Equal(10, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var code = (string)row["message"]!["code"]!;
|
||||
if (code == DeskRefusals.ForkTooLarge)
|
||||
{
|
||||
// It names its fork, so it is built, not looked up; the row is the list form at order 3.
|
||||
Assert.True(DeskVectors.SameRefusal(Write(DeskRefusals.ForkTooLargeDocument("gamecult-chat:question:q3", 3)), row["message"], "gamecult-chat:question:q3", 3), $"refusal {code}");
|
||||
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.ForkTooLargeStatus);
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}");
|
||||
Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code));
|
||||
}
|
||||
|
||||
var unavailable = DeskVectors.Rows("unavailable").ToList();
|
||||
Assert.Equal(2, unavailable.Count);
|
||||
foreach (var row in unavailable)
|
||||
{
|
||||
var retry = (int?)row["message"]!["retryAfterSeconds"];
|
||||
Assert.True(DeskVectors.SameShape(row["message"], Write(DeskUnavailables.Document(retry))), (string)row["name"]!);
|
||||
Assert.Equal(DeskUnavailables.Status, (int)row["httpStatus"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_refusal_code_in_the_schema_has_a_document()
|
||||
{
|
||||
var codes = DeskVectors.Rows("doorRefusals").Select(row => (string)row["message"]!["code"]!).ToHashSet();
|
||||
var written = typeof(DeskRefusals).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToHashSet();
|
||||
Assert.Equal(codes, written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Answer_results_round_trip_with_their_spelling()
|
||||
{
|
||||
var rows = DeskVectors.Rows("results").ToList();
|
||||
Assert.Equal(6, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var message = row["message"]!;
|
||||
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
|
||||
Assert.True(DeskVectors.SameShape(message, Write(result)), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cards_round_trip_with_their_spelling()
|
||||
{
|
||||
var rows = DeskVectors.Rows("card", "valid").ToList();
|
||||
Assert.Equal(5, rows.Count);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var message = row["message"]!;
|
||||
var card = message.Deserialize<ForkCard>(DeskJson.Options)!;
|
||||
Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The rules the desk's edge keeps that a happy path never shows: whose decider list rides each call, the body cap
|
||||
// to the byte, what the Retry-After comes from, the audience's case, no-store, and what a failing port becomes.
|
||||
public sealed class DeskHardeningTests
|
||||
{
|
||||
private const string Nibu = "nibu";
|
||||
private const string Chat = "gamecult-chat";
|
||||
private const string NibuFork = "nibu:question:glow-order";
|
||||
|
||||
private static async Task<DeskWebApplicationFactory> ArrangeAsync(Dictionary<string, string?>? configuration = null)
|
||||
{
|
||||
var factory = new DeskWebApplicationFactory(configuration);
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
return factory;
|
||||
}
|
||||
|
||||
private static string Token(DeskWebApplicationFactory factory, string audience = "bifrost-desk") =>
|
||||
factory.Token(DeskWebApplicationFactory.Alice, audience, "desk.read", "desk.answer");
|
||||
|
||||
private static Task<DeskReply> SendRouteAsync(DeskWebApplicationFactory factory, string route, string mind, string token)
|
||||
{
|
||||
var fork = mind == Nibu ? NibuFork : DeskReplay.ChatFork;
|
||||
return route switch
|
||||
{
|
||||
"answer" => factory.SendAsync("POST", DeskReplay.Target("answer", mind, null), DeskReplay.AnswerBody(fork), token),
|
||||
"read" => factory.SendAsync("GET", DeskReplay.Target("read", mind, fork), null, token),
|
||||
_ => factory.SendAsync("GET", DeskReplay.Target("list", mind, null), null, token),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly string[] Routes = ["answer", "read", "list"];
|
||||
|
||||
private static string Code(DeskReply reply) => (string)JsonNode.Parse(reply.Body)!["code"]!;
|
||||
|
||||
// (a) Each Mind's list is its own: a context built from another Mind's list changes the call.
|
||||
[Fact]
|
||||
public async Task Each_call_carries_the_route_minds_own_decider_list_and_envelope()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
Assert.NotEqual(DeskWebApplicationFactory.DecidersOf(Chat), DeskWebApplicationFactory.DecidersOf(Nibu));
|
||||
var token = Token(factory);
|
||||
|
||||
foreach (var mind in new[] { Chat, Nibu })
|
||||
{
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
factory.Door.Calls.Clear();
|
||||
var reply = await SendRouteAsync(factory, route, mind, token);
|
||||
Assert.True(reply.Status == 200, $"{mind} {route}: {reply.Status}");
|
||||
var call = Assert.Single(factory.Door.Calls);
|
||||
Assert.True(DeskWebApplicationFactory.DecidersOf(mind).SequenceEqual(call.Context.Deciders), $"{mind} {route}: deciders");
|
||||
Assert.Equal(mind, call.Context.Mind);
|
||||
Assert.Equal(DeskWebApplicationFactory.SocketOf(mind), call.Socket);
|
||||
Assert.Equal(DeskWebApplicationFactory.Alice, call.Context.Subject);
|
||||
Assert.Equal("Operator", call.Context.Authority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) The cap to the byte, at the HTTP edge, however the length is announced.
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(4096)]
|
||||
[InlineData(4097)]
|
||||
[InlineData(8192)]
|
||||
public async Task A_body_of_exactly_the_cap_reaches_the_shape_judge_and_one_more_byte_does_not(int cap)
|
||||
{
|
||||
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
|
||||
cap = Math.Max(cap, bare.Length);
|
||||
var config = DeskWebApplicationFactory.DefaultConfiguration();
|
||||
config["Desk:MaxBodyBytes"] = cap.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
using var factory = await ArrangeAsync(config);
|
||||
var token = Token(factory);
|
||||
var target = DeskReplay.Target("answer", Chat, null);
|
||||
byte[] Padded(int length) => [.. bare, .. Enumerable.Repeat((byte)' ', length - bare.Length)];
|
||||
|
||||
var modes = new (string Name, Action<HttpContext> Configure)[]
|
||||
{
|
||||
("content-length", _ => { }),
|
||||
("no content-length", http => http.Request.ContentLength = null),
|
||||
("chunked", http =>
|
||||
{
|
||||
http.Request.ContentLength = null;
|
||||
http.Request.Headers.TransferEncoding = "chunked";
|
||||
}),
|
||||
};
|
||||
foreach (var (name, configure) in modes)
|
||||
{
|
||||
factory.Door.Calls.Clear();
|
||||
var exact = await factory.SendAsync("POST", target, Padded(cap), token, configure: configure);
|
||||
Assert.True(exact.Status == 200, $"{name} exact: {exact.Status} {exact.Text}");
|
||||
Assert.Equal(cap, Assert.Single(factory.Door.Calls).Body!.Length);
|
||||
|
||||
factory.Door.Calls.Clear();
|
||||
var over = await factory.SendAsync("POST", target, Padded(cap + 1), token, configure: configure);
|
||||
Assert.True(over.Status == 400 && Code(over) == "body_invalid", $"{name} over: {over.Status} {over.Text}");
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
}
|
||||
|
||||
// A declared length over the cap is refused on the header alone, before any byte is read.
|
||||
[Fact]
|
||||
public async Task A_declared_length_over_the_cap_is_refused_before_reading()
|
||||
{
|
||||
var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork);
|
||||
var config = DeskWebApplicationFactory.DefaultConfiguration();
|
||||
config["Desk:MaxBodyBytes"] = bare.Length.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
using var factory = await ArrangeAsync(config);
|
||||
|
||||
var reply = await factory.SendAsync(
|
||||
"POST", DeskReplay.Target("answer", Chat, null), bare, Token(factory),
|
||||
configure: http => http.Request.ContentLength = bare.Length + 1);
|
||||
|
||||
Assert.Equal("body_invalid", Code(reply));
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
// (c) The wait an `unavailable` names is the configured one, in the document and in the header.
|
||||
[Fact]
|
||||
public async Task Retry_after_comes_from_configuration_in_the_document_and_the_header()
|
||||
{
|
||||
var config = DeskWebApplicationFactory.DefaultConfiguration();
|
||||
config["Desk:RetryAfterSeconds"] = "7";
|
||||
using var factory = await ArrangeAsync(config);
|
||||
factory.Door.Down = true;
|
||||
var token = Token(factory);
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
var reply = await SendRouteAsync(factory, route, Chat, token);
|
||||
Assert.True(reply.Status == 503, route);
|
||||
Assert.Equal(7, (int)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]!);
|
||||
Assert.Equal("7", reply.Headers.RetryAfter.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// (d) The audience is compared ordinally.
|
||||
[Theory]
|
||||
[InlineData("BIFROST-DESK")]
|
||||
[InlineData("Bifrost-Desk")]
|
||||
[InlineData("bifrost-desk ")]
|
||||
[InlineData("bifrost-votes")]
|
||||
public async Task The_audience_is_case_sensitive(string audience)
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
var reply = await SendRouteAsync(factory, route, Chat, Token(factory, audience));
|
||||
Assert.True(reply.Status == 401 && Code(reply) == "unauthenticated", $"{route}: {reply.Status}");
|
||||
}
|
||||
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
// (e) No desk response is cacheable: answers, refusals, unavailable and an unrecognised target alike.
|
||||
[Fact]
|
||||
public async Task Every_desk_response_is_no_store()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
var token = Token(factory);
|
||||
var replies = new List<(string, DeskReply)>();
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
replies.Add((route + " 200", await SendRouteAsync(factory, route, Chat, token)));
|
||||
replies.Add((route + " 401", await SendRouteAsync(factory, route, Chat, "")));
|
||||
replies.Add((route + " 404", await SendRouteAsync(factory, route, "ghost", token)));
|
||||
}
|
||||
|
||||
factory.Door.Down = true;
|
||||
replies.Add(("503", await SendRouteAsync(factory, "list", Chat, token)));
|
||||
replies.Add(("unrecognised", await factory.SendAsync("GET", "/api/desk/x", null, token)));
|
||||
|
||||
foreach (var (label, reply) in replies)
|
||||
{
|
||||
Assert.True(reply.Headers.CacheControl.ToString() == "no-store", $"{label}: '{reply.Headers.CacheControl}'");
|
||||
}
|
||||
|
||||
Assert.Equal(200, replies[0].Item2.Status);
|
||||
Assert.Equal(503, replies[^2].Item2.Status);
|
||||
}
|
||||
|
||||
// (f) A cancelled request is not `unavailable`: the exception goes on to the host.
|
||||
[Fact]
|
||||
public async Task A_cancelled_request_is_not_called_unavailable()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
using var cts = new CancellationTokenSource();
|
||||
factory.Door.AnswerScript = (_, _, _) =>
|
||||
{
|
||||
cts.Cancel();
|
||||
throw new IOException("the client went away");
|
||||
};
|
||||
|
||||
var reply = await factory.SendAsync(
|
||||
"POST", DeskReplay.Target("answer", Chat, null), DeskReplay.AnswerBody(DeskReplay.ChatFork), Token(factory),
|
||||
configure: http => http.RequestAborted = cts.Token);
|
||||
|
||||
// The host's exception handling answers; the desk does not claim the door was merely unavailable.
|
||||
Assert.NotEqual(503, reply.Status);
|
||||
Assert.DoesNotContain("unavailable", reply.Text);
|
||||
}
|
||||
|
||||
// (f) A stale_fork needs the current card; a card read that finds nothing, for any code, is `unavailable`.
|
||||
[Theory]
|
||||
[InlineData("fork_unknown")]
|
||||
[InlineData("request_invalid")]
|
||||
[InlineData("made_up")]
|
||||
public async Task A_stale_fork_whose_card_read_finds_nothing_is_unavailable_never_a_stale_fork_without_a_card(string code)
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
var stale = new AnswerResult(
|
||||
DeskSchemas.AnswerResult,
|
||||
new DeskAnswerDocument(
|
||||
DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Refused", null, null, "stale_fork", "oldhash", "no", null,
|
||||
"2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator",
|
||||
"00000001-0000-4000-8000-000000000001"),
|
||||
null,
|
||||
null);
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale);
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
|
||||
|
||||
var reply = await SendRouteAsync(factory, "answer", Chat, Token(factory));
|
||||
|
||||
Assert.Equal(503, reply.Status);
|
||||
Assert.Equal("unavailable", Code(reply));
|
||||
Assert.Null(JsonNode.Parse(reply.Body)!["card"]);
|
||||
Assert.Contains(factory.Door.Calls, c => c.Kind == "read");
|
||||
}
|
||||
|
||||
// (h) A port that fails is `unavailable`, never a 500 and never a pass.
|
||||
[Fact]
|
||||
public async Task A_throwing_credential_port_is_unavailable_and_opens_nothing()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
factory.Credential.Throws = true;
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
|
||||
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
|
||||
}
|
||||
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_throwing_account_lookup_is_unavailable_and_opens_nothing()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
// The request's database context is gone by the time the desk looks the account up (the database failed).
|
||||
factory.Credential.OnAuthenticate = http => http.RequestServices.GetRequiredService<Bifrost.Web.Data.BifrostDbContext>().Dispose();
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
|
||||
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
|
||||
}
|
||||
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
// The runtime guard on the decider list (the startup validator is the first layer; this is the second).
|
||||
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
|
||||
|
||||
private static void SetDeciders(DeskWebApplicationFactory factory, string mind, IEnumerable<string> ids)
|
||||
{
|
||||
var list = factory.Services.GetRequiredService<IOptions<DeskOptions>>().Value.Minds.Single(m => m.Id == mind).Deciders;
|
||||
list.Clear();
|
||||
list.AddRange(ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_door_is_called_with_64_deciders()
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
var ids = Enumerable.Range(1, 64).Select(Id).ToList();
|
||||
SetDeciders(factory, Chat, ids);
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
factory.Door.Calls.Clear();
|
||||
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
|
||||
Assert.True(reply.Status == 200, route);
|
||||
Assert.Equal(ids, Assert.Single(factory.Door.Calls).Context.Deciders);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("65")]
|
||||
[InlineData("0")]
|
||||
[InlineData("duplicate")]
|
||||
public async Task A_decider_list_out_of_bounds_is_unavailable_without_a_door_call(string fault)
|
||||
{
|
||||
using var factory = await ArrangeAsync();
|
||||
var ids = fault switch
|
||||
{
|
||||
"65" => Enumerable.Range(1, 65).Select(Id).ToList(),
|
||||
"0" => [],
|
||||
_ => [Id(1), Id(2), Id(1)],
|
||||
};
|
||||
SetDeciders(factory, Chat, ids);
|
||||
|
||||
foreach (var route in Routes)
|
||||
{
|
||||
var reply = await SendRouteAsync(factory, route, Chat, Token(factory));
|
||||
Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}");
|
||||
}
|
||||
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
|
||||
// Only that Mind's list is at fault: the other Mind still answers.
|
||||
var other = await SendRouteAsync(factory, "list", Nibu, Token(factory));
|
||||
Assert.Equal(200, other.Status);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskMindRouteTests
|
||||
{
|
||||
private static async Task<(DeskReply Reply, DeskWebApplicationFactory Factory)> RunAsync(JsonObject row)
|
||||
{
|
||||
var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
var token = await DeskReplay.TokenAsync(factory, row["token"]);
|
||||
var route = (string)row["route"]!;
|
||||
var forkId = (string?)row["fork_id"];
|
||||
var reply = await factory.SendAsync(
|
||||
route == "answer" ? "POST" : "GET",
|
||||
DeskReplay.Target(route, (string)row["mind"]!, forkId),
|
||||
route == "answer" ? DeskReplay.AnswerBody(forkId!) : null,
|
||||
token);
|
||||
return (reply, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Vector_route_cases()
|
||||
{
|
||||
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
|
||||
Assert.Equal(57, cases.Count);
|
||||
|
||||
foreach (var row in cases)
|
||||
{
|
||||
var name = (string)row["name"]!;
|
||||
var (reply, factory) = await RunAsync(row);
|
||||
using (factory)
|
||||
{
|
||||
Assert.True((int)row["httpStatus"]! == reply.Status, $"{name}: {reply.Status} {reply.Text}");
|
||||
var expect = (string)row["expect"]!;
|
||||
if (expect != "ok")
|
||||
{
|
||||
Assert.True(
|
||||
DeskVectors.SameShape(JsonNode.Parse(reply.Body), JsonNode.Parse(JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))),
|
||||
name);
|
||||
}
|
||||
|
||||
var opened = (string?)row["opened"];
|
||||
if (opened is null)
|
||||
{
|
||||
Assert.True(factory.Door.Calls.Count == 0, $"{name}: opened {factory.Door.Calls.FirstOrDefault()?.Socket}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var call = Assert.Single(factory.Door.Calls);
|
||||
Assert.True(call.Socket.EndsWith("/" + opened, StringComparison.Ordinal), $"{name}: {call.Socket}");
|
||||
}
|
||||
|
||||
var storedIn = (string?)row["stored_in"];
|
||||
if (storedIn is null)
|
||||
{
|
||||
Assert.Empty(factory.Door.StoredIn);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal([DeskWebApplicationFactory.SocketOf(storedIn)], factory.Door.StoredIn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_response_pairs_are_byte_equal()
|
||||
{
|
||||
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToDictionary(r => (string)r["name"]!);
|
||||
var pairs = DeskVectors.Array("transport", "mindRoutes", "sameResponse");
|
||||
Assert.Equal(4, pairs.Count);
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var first = cases[(string)pair![0]!];
|
||||
var second = cases[(string)pair[1]!];
|
||||
var (a, fa) = await RunAsync(first);
|
||||
var (b, fb) = await RunAsync(second);
|
||||
using (fa)
|
||||
using (fb)
|
||||
{
|
||||
Assert.Equal(a.Status, b.Status);
|
||||
Assert.Equal(a.Body, b.Body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskMindRoutesTests
|
||||
{
|
||||
private static DeskMindRoutes Routes()
|
||||
{
|
||||
var minds = DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject();
|
||||
return new DeskMindRoutes(minds.Select(m => new DeskMindOptions { Id = m.Key, Socket = "/run/huginn/" + (string)m.Value! }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_vector_segment_resolves_or_misses()
|
||||
{
|
||||
var routes = Routes();
|
||||
var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList();
|
||||
Assert.Equal(57, cases.Count);
|
||||
|
||||
foreach (var row in cases)
|
||||
{
|
||||
var mind = (string)row["mind"]!;
|
||||
var opened = (string?)row["opened"];
|
||||
var resolved = routes.Resolve(mind);
|
||||
|
||||
// A route that opens a socket resolves to the Mind whose socket it is; every other route is a miss.
|
||||
// Auth-refused cases never reach the lookup, so only the cases that reach it are judged here.
|
||||
var expect = (string)row["expect"]!;
|
||||
if (expect is not ("ok" or "fork_unknown" or "unavailable"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opened is null)
|
||||
{
|
||||
Assert.True(resolved is null, $"{row["name"]}: '{Short(mind)}' resolved to '{resolved?.Id}'");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(resolved is not null, $"{row["name"]}: '{Short(mind)}' missed");
|
||||
Assert.EndsWith(opened, resolved!.Socket, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Segments_that_do_not_name_a_configured_mind_miss()
|
||||
{
|
||||
var routes = Routes();
|
||||
foreach (var mind in new[]
|
||||
{
|
||||
"ghost", ".", "..", "../x", "a/b", "a%2fb", "%2e%2e%2fx", "eureka%00", "nibu%20", "nibu ", " nibu",
|
||||
"%ff", "%c3%28", "%", "%6", "%zz", "nibu%", "%256eibu", "", new string('a', 4096), "nïbu",
|
||||
})
|
||||
{
|
||||
Assert.Null(routes.Resolve(mind));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Percent_encoded_configured_ids_are_decoded_once()
|
||||
{
|
||||
var routes = Routes();
|
||||
|
||||
Assert.Equal("nibu", routes.Resolve("%6eibu")!.Id);
|
||||
Assert.Equal("nibu", routes.Resolve("%6Eibu")!.Id);
|
||||
Assert.Equal("gamecult-chat", routes.Resolve("gamecult%2Dchat")!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Other_case_misses_despite_config_case_insensitivity()
|
||||
{
|
||||
var routes = Routes();
|
||||
|
||||
Assert.Null(routes.Resolve("GAMECULT-CHAT"));
|
||||
Assert.Null(routes.Resolve("Nibu"));
|
||||
Assert.Null(routes.Resolve("gamecult-chat%20"));
|
||||
Assert.NotNull(routes.Resolve("gamecult-chat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_encoded_misses()
|
||||
{
|
||||
var routes = Routes();
|
||||
|
||||
Assert.Null(routes.Resolve("%256eibu"));
|
||||
Assert.Null(routes.Resolve("gamecult%252Dchat"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_ids_that_differ_only_by_case_stay_distinct()
|
||||
{
|
||||
var routes = new DeskMindRoutes([
|
||||
new DeskMindOptions { Id = "a", Socket = "/s/a" },
|
||||
new DeskMindOptions { Id = "A", Socket = "/s/A" },
|
||||
]);
|
||||
|
||||
Assert.Equal("/s/a", routes.Resolve("a")!.Socket);
|
||||
Assert.Equal("/s/A", routes.Resolve("A")!.Socket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_decoding_failure_is_a_miss_even_when_the_failure_spells_a_configured_id()
|
||||
{
|
||||
var replacement = char.ConvertFromUtf32(0xFFFD);
|
||||
var e = char.ConvertFromUtf32(0xE9);
|
||||
var routes = new DeskMindRoutes([
|
||||
new DeskMindOptions { Id = replacement, Socket = "/s/replacement" },
|
||||
new DeskMindOptions { Id = e, Socket = "/s/e-acute" },
|
||||
new DeskMindOptions { Id = "a%zz", Socket = "/s/bad-escape" },
|
||||
new DeskMindOptions { Id = "100%", Socket = "/s/trailing" },
|
||||
]);
|
||||
|
||||
Assert.Null(routes.Resolve("%ff")); // not UTF-8, not U+FFFD
|
||||
Assert.Null(routes.Resolve(char.ConvertFromUtf32(0xC3) + char.ConvertFromUtf32(0xA9))); // UTF-8 bytes as raw chars
|
||||
Assert.Null(routes.Resolve("a%zz"));
|
||||
Assert.Null(routes.Resolve("100%"));
|
||||
Assert.Equal("/s/replacement", routes.Resolve("%ef%bf%bd")!.Socket); // the real encoding still resolves
|
||||
Assert.Equal("/s/e-acute", routes.Resolve("%c3%a9")!.Socket);
|
||||
}
|
||||
|
||||
private static string Short(string value) => value.Length > 40 ? value[..40] + "..." : value;
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskOptionsTests
|
||||
{
|
||||
private const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
|
||||
private const string Bob = "00000000-0000-4000-8000-000000000002";
|
||||
|
||||
private static Dictionary<string, string?> Good() => new()
|
||||
{
|
||||
["Desk:AllowedOrigin"] = "https://chat.gamecult.org",
|
||||
["Desk:Minds:0:Id"] = "gamecult-chat",
|
||||
["Desk:Minds:0:Socket"] = "/run/huginn/huginn-gamecult-chat.sock",
|
||||
["Desk:Minds:0:Deciders:0"] = Alice,
|
||||
["Desk:Minds:1:Id"] = "nibu",
|
||||
["Desk:Minds:1:Socket"] = "/run/huginn/huginn-nibu.sock",
|
||||
["Desk:Minds:1:Deciders:0"] = Bob,
|
||||
["Desk:Attribution:Operator"] = Alice,
|
||||
["Desk:Attribution:Stewards:0"] = Bob,
|
||||
};
|
||||
|
||||
private static DeskOptions Bind(Dictionary<string, string?> values)
|
||||
{
|
||||
var options = new DeskOptions();
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build().GetSection(DeskOptions.SectionName).Bind(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static void Validate(Dictionary<string, string?> values, string environment = "Testing")
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
new StartupConfigurationValidator(
|
||||
configuration,
|
||||
new StubEnvironment(environment),
|
||||
Options.Create(new GitHubOAuthOptions()),
|
||||
Options.Create(new GitHubAppOptions()),
|
||||
Options.Create(new HeimdallOptions()),
|
||||
Options.Create(new StripeOptions()),
|
||||
Options.Create(new BifrostHostOptions()),
|
||||
Options.Create(new ConceptVoteOptions()),
|
||||
Options.Create(Bind(values)),
|
||||
Options.Create(new RegistrationOptions())).Validate();
|
||||
}
|
||||
|
||||
private static void Refused(Dictionary<string, string?> values, string expectedFragment)
|
||||
{
|
||||
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values));
|
||||
Assert.Contains(expectedFragment, failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_good_desk_configuration_binds_by_position_and_passes()
|
||||
{
|
||||
var options = Bind(Good());
|
||||
|
||||
Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id));
|
||||
Assert.Equal([Alice], options.Minds[0].Deciders);
|
||||
Assert.Equal([Bob], options.Minds[1].Deciders);
|
||||
Validate(Good());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_desk_section_is_not_configured_and_passes()
|
||||
{
|
||||
Assert.False(Bind([]).IsConfigured);
|
||||
Validate([]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mind_ids_keep_the_case_the_configuration_gave_them()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:0:Id"] = "GameCult-Chat";
|
||||
|
||||
Assert.Equal("GameCult-Chat", Bind(values).Minds[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_duplicate_ids() => Refused(Dup(), "Desk:Minds:1:Id repeats");
|
||||
|
||||
private static Dictionary<string, string?> Dup()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:1:Id"] = "gamecult-chat";
|
||||
return values;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_a_blank_id()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:1:Id"] = " ";
|
||||
|
||||
Refused(values, "Desk:Minds:1:Id must not be blank");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("huginn.sock")]
|
||||
[InlineData("run/huginn.sock")]
|
||||
[InlineData("")]
|
||||
public void Startup_refuses_relative_socket(string socket)
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:1:Socket"] = socket;
|
||||
|
||||
Refused(values, "Desk:Minds:1:Socket must be an absolute path");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F")]
|
||||
[InlineData("e0d1c2b3a495468788796a5b4c3d2e1f")]
|
||||
[InlineData("{e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f}")]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("")]
|
||||
public void Startup_refuses_bad_decider_id(string decider)
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:0:Deciders:1"] = decider;
|
||||
|
||||
Refused(values, "Desk:Minds:0:Deciders entries must be lowercase D-format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_a_repeated_decider()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:0:Deciders:1"] = Alice;
|
||||
|
||||
Refused(values, "Desk:Minds:0:Deciders repeats");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_operator_in_stewards()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Attribution:Stewards:1"] = Alice;
|
||||
|
||||
Refused(values, "must not also be a steward");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_bad_or_repeated_attribution_ids()
|
||||
{
|
||||
var bad = Good();
|
||||
bad["Desk:Attribution:Operator"] = "ALICE";
|
||||
Refused(bad, "Desk:Attribution:Operator must be a lowercase");
|
||||
|
||||
var badSteward = Good();
|
||||
badSteward["Desk:Attribution:Stewards:1"] = "E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F";
|
||||
Refused(badSteward, "Desk:Attribution:Stewards entries must be lowercase");
|
||||
|
||||
var repeated = Good();
|
||||
repeated["Desk:Attribution:Stewards:1"] = Bob;
|
||||
Refused(repeated, "Desk:Attribution:Stewards repeats");
|
||||
}
|
||||
|
||||
private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D");
|
||||
|
||||
private static Dictionary<string, string?> WithDeciders(int count)
|
||||
{
|
||||
var values = Good();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
values[$"Desk:Minds:0:Deciders:{i}"] = Id(i + 1);
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
values.Remove("Desk:Minds:0:Deciders:0");
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_takes_one_to_64_deciders_per_mind()
|
||||
{
|
||||
Validate(WithDeciders(1));
|
||||
Validate(WithDeciders(64));
|
||||
Refused(WithDeciders(65), "Desk:Minds:0:Deciders must hold 1 to 64");
|
||||
Refused(WithDeciders(0), "Desk:Minds:0:Deciders must hold 1 to 64");
|
||||
|
||||
// The bound is per Mind: the second Mind is judged on its own list.
|
||||
var second = Good();
|
||||
second.Remove("Desk:Minds:1:Deciders:0");
|
||||
Refused(second, "Desk:Minds:1:Deciders must hold 1 to 64");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Startup_refuses_two_minds_sharing_one_socket()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:1:Socket"] = values["Desk:Minds:0:Socket"];
|
||||
|
||||
Refused(values, "Desk:Minds:1:Socket repeats an earlier Mind's socket");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Desk_failures_are_reported_in_production_too_and_alongside_each_other()
|
||||
{
|
||||
var values = Good();
|
||||
values["Desk:Minds:1:Socket"] = "relative";
|
||||
values["Desk:Attribution:Stewards:1"] = Alice;
|
||||
|
||||
var failure = Assert.Throws<InvalidOperationException>(() => Validate(values, "Production"));
|
||||
Assert.Contains("Desk:Minds:1:Socket", failure.Message);
|
||||
Assert.Contains("must not also be a steward", failure.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attribution_is_operator_steward_member()
|
||||
{
|
||||
var attribution = Bind(Good()).Attribution;
|
||||
|
||||
Assert.Equal("Operator", attribution.AuthorityOf(Alice));
|
||||
Assert.Equal("Steward", attribution.AuthorityOf(Bob));
|
||||
Assert.Equal("Member", attribution.AuthorityOf("00000000-0000-4000-8000-000000000003"));
|
||||
Assert.Equal("Member", attribution.AuthorityOf(Alice.ToUpperInvariant()));
|
||||
Assert.Equal("Member", new DeskAttributionOptions().AuthorityOf(string.Empty));
|
||||
}
|
||||
|
||||
private sealed class StubEnvironment(string name) : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = name;
|
||||
public string ApplicationName { get; set; } = "Bifrost.Web";
|
||||
public string ContentRootPath { get; set; } = string.Empty;
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,468 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskReadTests
|
||||
{
|
||||
private static string ReadToken(DeskWebApplicationFactory f) =>
|
||||
f.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read");
|
||||
|
||||
private static Uri Unused => new("http://unused");
|
||||
|
||||
// A list_request invalid row names the query that cannot express it; the others are the shapes a query cannot hold.
|
||||
private static readonly Dictionary<string, string> InvalidListQueries = new()
|
||||
{
|
||||
["limit_51"] = "?limit=51",
|
||||
["limit_negative"] = "?limit=-1",
|
||||
["status_all"] = "?status=all",
|
||||
["cursor_malformed"] = "?cursor=next",
|
||||
["cursor_zero"] = "?cursor=c0",
|
||||
["unknown_key"] = "?sort=oldest",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task List_and_read_request_invalid_vectors()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = ReadToken(factory);
|
||||
|
||||
var invalid = DeskVectors.Rows("transport", "listRequest", "invalid").Select(r => (string)r["name"]!).ToList();
|
||||
foreach (var name in invalid.Where(n => n != "mind_empty"))
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + InvalidListQueries[name], null, token);
|
||||
Assert.True(reply.Status == 400, $"{name}: {reply.Status}");
|
||||
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
}
|
||||
|
||||
// Also: repeated key, bad escape, query on a read, empty fork id.
|
||||
foreach (var target in new[]
|
||||
{
|
||||
"/api/desk/gamecult-chat/forks?limit=1&limit=2", "/api/desk/gamecult-chat/forks?limit=%zz", "/api/desk/gamecult-chat/forks?limit=",
|
||||
"/api/desk/gamecult-chat/forks?limit=+1", "/api/desk/gamecult-chat/forks?limit=1.0",
|
||||
"/api/desk/gamecult-chat/forks/gamecult-chat%3Aquestion%3Aq1?x=1", "/api/desk/gamecult-chat/forks/", "/api/desk/gamecult-chat/forks/%ff",
|
||||
})
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", target, null, token);
|
||||
Assert.True(reply.Status == 400, $"{target}: {reply.Status}");
|
||||
}
|
||||
|
||||
// The invalid request is judged before the Mind: an unknown Mind with a bad query is the query's refusal.
|
||||
var order = await factory.SendAsync("GET", "/api/desk/ghost/forks?limit=99", null, token);
|
||||
Assert.Equal("request_invalid", (string?)JsonNode.Parse(order.Body)!["code"]);
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_list_queries_reach_the_door_as_typed_requests()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
foreach (var row in DeskVectors.Rows("transport", "listRequest", "valid"))
|
||||
{
|
||||
var m = row["message"]!;
|
||||
var query = $"?status={(string?)m["status"]}&cursor={(string?)m["cursor"]}&limit={(int)m["limit"]!}";
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + query, null, ReadToken(factory));
|
||||
Assert.Equal(200, reply.Status);
|
||||
var call = factory.Door.Calls[^1];
|
||||
Assert.Equal(new DeskListRequest("gamecult-chat", (string?)m["status"], (string?)m["cursor"], (int)m["limit"]!), call.List);
|
||||
}
|
||||
|
||||
var bare = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
|
||||
Assert.Equal(new DeskListRequest("nibu", null, null, 50), factory.Door.Calls[^1].List);
|
||||
Assert.Equal(200, bare.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_scenarios_pass_through()
|
||||
{
|
||||
var steps = 0;
|
||||
foreach (var scenario in DeskVectors.Rows("transport", "listScenarios"))
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
|
||||
{
|
||||
var request = step["request"]!;
|
||||
var response = step["response"]!.AsObject();
|
||||
var list = response.Deserialize<ForkList>(DeskJson.Options)!;
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
|
||||
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
|
||||
|
||||
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
|
||||
|
||||
Assert.Equal(200, reply.Status);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!);
|
||||
steps++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(9, steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var ok = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, ReadToken(factory));
|
||||
var entry = JsonSerializer.SerializeToNode(DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)), DeskJson.Options);
|
||||
var missing = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", "gamecult-chat:question:ghost"), null, ReadToken(factory));
|
||||
|
||||
Assert.Equal(200, ok.Status);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(ok.Body), entry));
|
||||
Assert.Equal(DeskReplay.ChatFork, Assert.IsType<string>(factory.Door.Calls[0].ForkId));
|
||||
Assert.Equal(404, missing.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_its_order()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:huge", 7);
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
|
||||
var doc = JsonNode.Parse(reply.Body)!;
|
||||
Assert.Equal(422, reply.Status);
|
||||
Assert.Equal("fork_too_large", (string?)doc["code"]);
|
||||
var error = (string)doc["error"]!;
|
||||
Assert.Contains("nibu:question:huge", error);
|
||||
Assert.Contains("order 7", error);
|
||||
|
||||
// At order 0 the text still names the fork and its order: the client reads "below 0" as the end.
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0);
|
||||
var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory));
|
||||
var endError = (string)JsonNode.Parse(end.Body)!["error"]!;
|
||||
Assert.Equal(422, end.Status);
|
||||
Assert.Contains("nibu:question:first", endError);
|
||||
Assert.Contains("order 0", endError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task fork_too_large_as_a_bare_door_code_is_unavailable_only_the_typed_outcomes_are_the_refusal()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound("fork_too_large");
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed("fork_too_large");
|
||||
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
|
||||
Assert.Equal(503, reply.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_of_an_oversize_fork_is_the_same_422_naming_the_fork_with_no_order_or_cursor()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
|
||||
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:huge"), null, ReadToken(factory));
|
||||
|
||||
var doc = JsonNode.Parse(reply.Body)!;
|
||||
Assert.Equal(422, reply.Status);
|
||||
Assert.Equal("fork_too_large", (string?)doc["code"]);
|
||||
var error = (string)doc["error"]!;
|
||||
Assert.Contains("nibu:question:huge", error);
|
||||
Assert.DoesNotContain("order", error);
|
||||
Assert.DoesNotContain("cursor", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_short_pages_and_oversize_forks_follow_the_vectors()
|
||||
{
|
||||
var steps = 0;
|
||||
foreach (var scenario in DeskVectors.Rows("transport", "listShortPages"))
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
|
||||
{
|
||||
var request = step["request"]!;
|
||||
var name = (string)scenario["name"]!;
|
||||
if (step["refusal"] is { } refusal)
|
||||
{
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge((string)step["fork_id"]!, (int)step["order"]!);
|
||||
}
|
||||
else
|
||||
{
|
||||
var list = step["response"]!.Deserialize<ForkList>(DeskJson.Options)!;
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list);
|
||||
}
|
||||
|
||||
var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}";
|
||||
var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory));
|
||||
|
||||
Assert.Equal(step["refusal"] is null ? 200 : (int)step["httpStatus"]!, reply.Status);
|
||||
Assert.True(
|
||||
step["refusal"] is null
|
||||
? DeskVectors.SameShape(JsonNode.Parse(reply.Body), step["response"])
|
||||
: DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), step["refusal"], (string)step["fork_id"]!, (int)step["order"]!),
|
||||
name);
|
||||
steps++;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.Equal(6, steps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_past_the_cap_follows_the_vectors()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var rows = DeskVectors.Rows("transport", "readTooLarge").ToList();
|
||||
Assert.Single(rows);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var forkId = (string)row["fork_id"]!;
|
||||
factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id);
|
||||
var reply = await factory.SendAsync("GET", DeskReplay.Target("read", (string)row["request"]!["mind"]!, forkId), null, ReadToken(factory));
|
||||
|
||||
Assert.Equal((int)row["httpStatus"]!, reply.Status);
|
||||
Assert.True(DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), row["refusal"], forkId, null), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Door_faults_follow_the_vectors_per_route()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var faults = DeskVectors.Rows("transport", "doorFaults").ToList();
|
||||
Assert.Equal(3, faults.Count);
|
||||
foreach (var row in faults)
|
||||
{
|
||||
var code = (string)row["doorCode"]!;
|
||||
string method = "GET", target;
|
||||
string? bearer = null;
|
||||
byte[]? body = null;
|
||||
switch ((string)row["route"]!)
|
||||
{
|
||||
case "read":
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
|
||||
target = DeskReplay.Target("read", "nibu", "nibu:question:glow-order");
|
||||
bearer = ReadToken(factory);
|
||||
break;
|
||||
case "list":
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
|
||||
target = "/api/desk/nibu/forks";
|
||||
bearer = ReadToken(factory);
|
||||
break;
|
||||
default:
|
||||
method = "POST";
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
|
||||
target = DeskReplay.Target("answer", "nibu", null);
|
||||
body = DeskReplay.AnswerBody("nibu:question:glow-order");
|
||||
bearer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
|
||||
break;
|
||||
}
|
||||
|
||||
var reply = await factory.SendAsync(method, target, body, bearer);
|
||||
|
||||
Assert.Equal((int)row["httpStatus"]!, reply.Status);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), row["response"]), (string)row["name"]!);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 3, 0, [], "c2"));
|
||||
|
||||
var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks?limit=10", null, ReadToken(factory));
|
||||
|
||||
Assert.Equal(200, reply.Status);
|
||||
Assert.Equal("c2", (string?)JsonNode.Parse(reply.Body)!["next_cursor"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_door_that_refuses_a_read_or_list_for_a_body_field_such_as_authority_is_request_invalid_not_unavailable()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
foreach (var code in new[] { "body_forbidden_field", "body_unknown_field", "body_invalid" })
|
||||
{
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code);
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code);
|
||||
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
|
||||
Assert.Equal(400, reply.Status);
|
||||
Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task request_id_conflict_is_the_answers_alone_on_read_and_list_it_is_a_door_fault()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(DeskRefusals.RequestIdConflict);
|
||||
factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(DeskRefusals.RequestIdConflict);
|
||||
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
|
||||
Assert.Equal(503, reply.Status);
|
||||
Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Door_failure_on_list_and_read_is_unavailable()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
factory.Door.Down = true;
|
||||
|
||||
foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") })
|
||||
{
|
||||
var reply = await factory.SendAsync("GET", target, null, ReadToken(factory));
|
||||
Assert.Equal(503, reply.Status);
|
||||
Assert.Equal(30, (int?)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_door_call_carries_the_configured_deciders_and_nothing_in_the_request_can_change_them()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var hostile = "deciders=" + DeskWebApplicationFactory.Member;
|
||||
Action<Microsoft.AspNetCore.Http.HttpContext> headers = http =>
|
||||
{
|
||||
http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member;
|
||||
http.Request.Headers["Deciders"] = DeskWebApplicationFactory.Member;
|
||||
};
|
||||
var read = ReadToken(factory);
|
||||
var answer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
|
||||
|
||||
await factory.SendAsync("GET", "/api/desk/nibu/forks", null, read, configure: headers);
|
||||
var hostileList = await factory.SendAsync("GET", "/api/desk/nibu/forks?" + hostile, null, read, configure: headers);
|
||||
Assert.Equal(400, hostileList.Status);
|
||||
await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:glow-order"), null, read, configure: headers);
|
||||
await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null) + "?" + hostile, DeskReplay.AnswerBody("nibu:question:glow-order"), answer, configure: headers);
|
||||
// A body naming deciders never reaches the door.
|
||||
var body = JsonNode.Parse(DeskReplay.AnswerBody("nibu:question:glow-order"))!.AsObject();
|
||||
body["deciders"] = new JsonArray(DeskWebApplicationFactory.Member);
|
||||
var named = await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null), DeskVectors.Bytes(body), answer);
|
||||
|
||||
Assert.Equal("body_unknown_field", (string?)JsonNode.Parse(named.Body)!["code"]);
|
||||
Assert.Equal(["list", "read", "answer"], factory.Door.Calls.Select(c => c.Kind).ToArray());
|
||||
Assert.All(factory.Door.Calls, c => Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), c.Context.Deciders));
|
||||
}
|
||||
private const string Secret = "the decider's own words";
|
||||
|
||||
private static DeskAnswerDocument Doc(string id, string outcome, string? of, string? retracts, string? note) => new(
|
||||
DeskSchemas.DeskAnswer, id, DeskReplay.ChatFork, outcome, of, retracts, null, "h", note == null ? null : "a", note,
|
||||
"2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, DeskWebApplicationFactory.Alice, "Steward", "r-" + id);
|
||||
|
||||
// Counted, Revision, Retraction and Refused documents, every one carrying the decider's note.
|
||||
private static ForkEntry NotedEntry()
|
||||
{
|
||||
var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork));
|
||||
DeskAnswerDocument[] answers =
|
||||
[
|
||||
Doc("n1", "Counted", null, null, Secret),
|
||||
Doc("n2", "Revision", "n1", null, Secret + " 2"),
|
||||
Doc("n3", "Retraction", null, "n2", Secret + " 3"),
|
||||
Doc("n4", "Refused", null, null, Secret + " 4"),
|
||||
];
|
||||
return entry with { Record = entry.Record with { CountedAnswerId = "n1", Answers = answers } };
|
||||
}
|
||||
|
||||
private static bool AnyNote(JsonNode? node) => node switch
|
||||
{
|
||||
JsonObject o => o.Any(kv => (kv.Key == "note" && kv.Value is not null) || AnyNote(kv.Value)),
|
||||
JsonArray a => a.Any(AnyNote),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static async Task<(string Read, string List)> BodiesAsync(DeskWebApplicationFactory factory, string account)
|
||||
{
|
||||
var entry = NotedEntry();
|
||||
factory.Door.ReadScript = (_, _) => new DeskReadOutcome.Found(entry);
|
||||
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
|
||||
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry, entry], null));
|
||||
var token = factory.Token(account, "bifrost-desk", "desk.read");
|
||||
var read = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, token);
|
||||
var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token);
|
||||
Assert.Equal(200, read.Status);
|
||||
Assert.Equal(200, list.Status);
|
||||
return (System.Text.Encoding.UTF8.GetString(read.Body), System.Text.Encoding.UTF8.GetString(list.Body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_decider_reads_and_lists_no_notes_anywhere_and_the_body_keeps_the_contract_shape()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
|
||||
Assert.DoesNotContain(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("gamecult-chat"));
|
||||
|
||||
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Member);
|
||||
|
||||
Assert.DoesNotContain("decider's own words", read);
|
||||
Assert.DoesNotContain("decider's own words", list);
|
||||
Assert.False(AnyNote(JsonNode.Parse(read)));
|
||||
Assert.False(AnyNote(JsonNode.Parse(list)));
|
||||
// Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape.
|
||||
var expected = JsonSerializer.SerializeToNode(NotedEntry() with
|
||||
{
|
||||
Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] },
|
||||
}, DeskJson.Options);
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected));
|
||||
var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray();
|
||||
Assert.Equal(new[] { "Counted", "Revision", "Retraction", "Refused" }, answers.Select(a => (string)a!["outcome"]!).ToArray());
|
||||
Assert.All(answers, a => Assert.True(a!.AsObject().ContainsKey("note")));
|
||||
Assert.Equal(2, JsonNode.Parse(list)!["forks"]!.AsArray().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_decider_reads_and_lists_the_notes()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Alice);
|
||||
|
||||
Assert.Contains(Secret + " 3", read);
|
||||
Assert.Equal(8, JsonNode.Parse(list)!["forks"]!.AsArray().Sum(f => f!["record"]!["answers"]!.AsArray().Count(a => a!["note"] is not null)));
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), JsonSerializer.SerializeToNode(NotedEntry(), DeskJson.Options)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decider_status_is_per_mind_the_same_account_is_stripped_where_it_is_not_on_the_list()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true);
|
||||
Assert.Contains(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("nibu"));
|
||||
factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(
|
||||
new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [NotedEntry()], null));
|
||||
var token = factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read");
|
||||
|
||||
var onNibu = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, token);
|
||||
var onChat = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, token);
|
||||
|
||||
Assert.True(AnyNote(JsonNode.Parse(onNibu.Body)));
|
||||
Assert.False(AnyNote(JsonNode.Parse(onChat.Body)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskScenarioTests
|
||||
{
|
||||
private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat");
|
||||
|
||||
private static readonly string[] ConfiguredDeciders = DeskWebApplicationFactory.DecidersOf("gamecult-chat");
|
||||
|
||||
// Replays every answer step of every scenario. The door is scripted from the vector's response; what Bifrost
|
||||
// must produce by itself (edge refusals, unavailable, the attached card) is compared with the vector.
|
||||
private sealed record Counts(int Answers, int Edge, int Unavailable, int Skipped);
|
||||
|
||||
private static async Task<Counts> ReplayAsync(Action<string, DeskReply, DeskWebApplicationFactory, JsonObject>? observe = null)
|
||||
{
|
||||
int answers = 0, edge = 0, unavailable = 0, skipped = 0;
|
||||
foreach (var scenario in DeskVectors.Rows("scenarios"))
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
var door = factory.Door;
|
||||
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
|
||||
var scenarioName = (string)scenario["name"]!;
|
||||
|
||||
foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()))
|
||||
{
|
||||
var kind = (string)step["kind"]!;
|
||||
if (kind == "matrix_event")
|
||||
{
|
||||
skipped++; // no Matrix ingress exists, so there is no route a Matrix event could take
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind == "card")
|
||||
{
|
||||
door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize<ForkCard>(DeskJson.Options)!);
|
||||
continue;
|
||||
}
|
||||
|
||||
var server = step["server"]!.AsObject();
|
||||
var response = step["response"]!.AsObject();
|
||||
var code = (string?)response["code"];
|
||||
var answerer = (string?)server["answerer"];
|
||||
var active = (bool?)server["active"] ?? true;
|
||||
|
||||
door.Calls.Clear();
|
||||
door.StoredIn.Clear();
|
||||
door.Down = false;
|
||||
door.ThrowAfterStore = false;
|
||||
door.AnswerScript = null;
|
||||
if (answerer is not null)
|
||||
{
|
||||
await factory.EnsureAccountAsync(answerer, active);
|
||||
}
|
||||
|
||||
var isDown = (bool?)server["door_down"] == true;
|
||||
var stored = (bool?)server["stored"] == true;
|
||||
if (isDown)
|
||||
{
|
||||
door.Down = !stored;
|
||||
door.ThrowAfterStore = stored;
|
||||
}
|
||||
|
||||
if (code is null)
|
||||
{
|
||||
var wire = response.Deserialize<AnswerResult>(DeskJson.Options)!;
|
||||
// Huginn answers without the card; Bifrost attaches the current one for stale_fork.
|
||||
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(wire with { Card = null });
|
||||
}
|
||||
else if (code is "request_id_conflict" or "fork_unknown")
|
||||
{
|
||||
door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code);
|
||||
}
|
||||
|
||||
var body = DeskVectors.Bytes(step["body"]!);
|
||||
var token = answerer is null
|
||||
? null
|
||||
: factory.Token(answerer, "bifrost-desk", "desk.answer");
|
||||
var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), body, token);
|
||||
|
||||
var label = $"{scenarioName}";
|
||||
if (code == "unavailable")
|
||||
{
|
||||
unavailable++;
|
||||
Assert.True(503 == reply.Status, $"{label}: {reply.Status}");
|
||||
Assert.Equal("30", reply.Headers.RetryAfter.ToString());
|
||||
Assert.Equal(stored ? [Socket] : [], door.StoredIn);
|
||||
}
|
||||
else if (code is not null && DeskReplay.IsEdgeRefusal(code))
|
||||
{
|
||||
edge++;
|
||||
Assert.True(DeskRefusals.StatusOf(code) == reply.Status, $"{label}: {reply.Status}");
|
||||
Assert.True(door.Calls.Count == 0, $"{label}: the door was called for an edge refusal");
|
||||
}
|
||||
else
|
||||
{
|
||||
answers++;
|
||||
var call = Assert.Single(door.Calls.Where(c => c.Kind == "answer"));
|
||||
Assert.Equal(body, call.Body);
|
||||
Assert.Equal(answerer, call.Context.Subject);
|
||||
Assert.Equal((string)server["authority"]!, call.Context.Authority);
|
||||
Assert.Equal("gamecult-chat", call.Context.Mind);
|
||||
Assert.Equal(ConfiguredDeciders, call.Context.Deciders);
|
||||
Assert.Equal(Socket, call.Socket);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
DeskVectors.SameShape(JsonNode.Parse(reply.Body), response),
|
||||
$"{label}: response differs\n{reply.Text}\n{response.ToJsonString()}");
|
||||
observe?.Invoke(scenarioName, reply, factory, step);
|
||||
}
|
||||
}
|
||||
|
||||
return new Counts(answers, edge, unavailable, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Every_scenario_answer_step_replays_through_http()
|
||||
{
|
||||
var counts = await ReplayAsync();
|
||||
|
||||
Assert.Equal(3, counts.Skipped);
|
||||
Assert.Equal(2, counts.Unavailable);
|
||||
Assert.Equal(15, counts.Edge);
|
||||
Assert.Equal(100, counts.Answers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Edge_refusals_never_call_the_door()
|
||||
{
|
||||
var edges = 0;
|
||||
await ReplayAsync((_, reply, factory, step) =>
|
||||
{
|
||||
var code = (string?)step["response"]!["code"];
|
||||
if (code is not null && DeskReplay.IsEdgeRefusal(code))
|
||||
{
|
||||
edges++;
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
});
|
||||
Assert.Equal(15, edges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Answer_results_pass_through_unchanged()
|
||||
{
|
||||
var checkedResults = 0;
|
||||
foreach (var row in DeskVectors.Rows("results"))
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
var message = row["message"]!.AsObject();
|
||||
var answer = message["answer"]!.Deserialize<DeskAnswerDocument>(DeskJson.Options)!;
|
||||
var result = message.Deserialize<AnswerResult>(DeskJson.Options)!;
|
||||
factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry(
|
||||
message["card"] is { } card ? card.Deserialize<ForkCard>(DeskJson.Options)! : DeskReplay.Card(answer.ForkId));
|
||||
factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null });
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var reply = await factory.SendAsync(
|
||||
"POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(answer.ForkId),
|
||||
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
|
||||
|
||||
Assert.True((int)row["httpStatus"]! == reply.Status, $"{row["name"]}: {reply.Status}");
|
||||
Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), message), $"{row["name"]}");
|
||||
checkedResults++;
|
||||
}
|
||||
|
||||
Assert.Equal(6, checkedResults);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unavailable_before_and_after_store()
|
||||
{
|
||||
// The scenarios hold both: door_down before the store, and door_down after it (the retry then replays).
|
||||
var seen = new List<(bool Stored, int Status)>();
|
||||
await ReplayAsync((_, reply, factory, step) =>
|
||||
{
|
||||
if ((string?)step["response"]!["code"] == "unavailable")
|
||||
{
|
||||
seen.Add(((bool?)step["server"]!["stored"] == true, reply.Status));
|
||||
}
|
||||
});
|
||||
Assert.Equal([(false, 503), (true, 503)], seen.OrderBy(s => s.Stored).ToArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskSurfaceTests
|
||||
{
|
||||
private static readonly string AnswerTarget = DeskReplay.Target("answer", "gamecult-chat", null);
|
||||
|
||||
[Fact]
|
||||
public async Task Not_mapped_without_config()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory(configured: false);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
var reply = await factory.SendAsync(
|
||||
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
|
||||
factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"));
|
||||
|
||||
Assert.Equal(404, reply.Status);
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
var routes = factory.Services.GetRequiredService<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>();
|
||||
Assert.DoesNotContain(routes, e => e.RoutePattern.RawText!.StartsWith("/api/desk", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_configured_desk_without_its_ports_stops_startup()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory(registerPorts: false);
|
||||
|
||||
var failure = Assert.ThrowsAny<Exception>(() => factory.CreateClient());
|
||||
|
||||
Assert.Contains("IDeskCredential", failure.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cookie_session_does_not_authenticate()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
|
||||
// The test host signs every request in as an Active-looking site member through the default scheme. The desk ignores it.
|
||||
var reply = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork));
|
||||
var withCookie = await factory.SendAsync(
|
||||
"POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork),
|
||||
configure: http => http.Request.Headers.Cookie = "bifrost_session=anything");
|
||||
|
||||
Assert.Equal(401, reply.Status);
|
||||
Assert.Equal(401, withCookie.Status);
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cors_allows_exactly_the_configured_origin_without_credentials()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
DeskReplay.HoldVectorForks(factory.Door);
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
|
||||
|
||||
var allowed = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin);
|
||||
var refused = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, "https://evil.example");
|
||||
var lookalike = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin + ".evil.example");
|
||||
var preflight = await factory.SendAsync("OPTIONS", AnswerTarget, null, null, DeskWebApplicationFactory.Origin, http =>
|
||||
{
|
||||
http.Request.Headers["Access-Control-Request-Method"] = "POST";
|
||||
http.Request.Headers["Access-Control-Request-Headers"] = "authorization,content-type";
|
||||
});
|
||||
var elsewhere = await factory.SendAsync("GET", "/healthz", null, null, DeskWebApplicationFactory.Origin);
|
||||
|
||||
Assert.Equal(DeskWebApplicationFactory.Origin, allowed.Headers.AccessControlAllowOrigin.ToString());
|
||||
Assert.False(allowed.Headers.ContainsKey("Access-Control-Allow-Credentials"));
|
||||
Assert.Contains("Origin", allowed.Headers.Vary.ToString());
|
||||
Assert.False(refused.Headers.ContainsKey("Access-Control-Allow-Origin"));
|
||||
Assert.False(lookalike.Headers.ContainsKey("Access-Control-Allow-Origin"));
|
||||
Assert.Equal(DeskWebApplicationFactory.Origin, preflight.Headers.AccessControlAllowOrigin.ToString());
|
||||
Assert.False(preflight.Headers.ContainsKey("Access-Control-Allow-Credentials"));
|
||||
Assert.Contains("POST", preflight.Headers.AccessControlAllowMethods.ToString());
|
||||
Assert.False(elsewhere.Headers.ContainsKey("Access-Control-Allow-Origin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unrecognised_target_is_fork_unknown_and_opens_nothing()
|
||||
{
|
||||
using var factory = new DeskWebApplicationFactory();
|
||||
await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true);
|
||||
var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer");
|
||||
|
||||
foreach (var target in new[]
|
||||
{
|
||||
"/api/desk", "/api/desk/", "/api/desk/gamecult-chat", "/api/desk/gamecult-chat/answers/", "/api/desk/a/b/answers",
|
||||
"/api/desk/a/../gamecult-chat/answers", "/api/desk/./gamecult-chat/answers", "/api/desk//answers/x",
|
||||
"/API/DESK/gamecult-chat/answers", "/api/deskx/gamecult-chat/answers", "http://localhost/api/desk/gamecult-chat/answers",
|
||||
})
|
||||
{
|
||||
var reply = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token);
|
||||
Assert.True(reply.Status == 404, $"{target}: {reply.Status}");
|
||||
}
|
||||
|
||||
Assert.Empty(factory.Door.Calls);
|
||||
}
|
||||
|
||||
// Kestrel normalises Request.Path but leaves RawTarget as received. The desk parses RawTarget, so a dot
|
||||
// segment can never be folded into a configured Mind's route.
|
||||
[Fact]
|
||||
public async Task Dot_segments_never_reach_the_route()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
await using var app = builder.Build();
|
||||
app.Run(async http =>
|
||||
{
|
||||
var raw = http.Features.Get<IHttpRequestFeature>()!.RawTarget;
|
||||
var text = $"{http.Request.Path}|{raw}";
|
||||
http.Response.ContentLength = text.Length;
|
||||
await http.Response.WriteAsync(text);
|
||||
});
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
var port = new Uri(address).Port;
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", port);
|
||||
var stream = client.GetStream();
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("GET /api/desk/ghost/../gamecult-chat/answers HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"));
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII);
|
||||
var text = await reader.ReadToEndAsync();
|
||||
var echoed = text[(text.LastIndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..].Trim();
|
||||
|
||||
var parts = echoed.Split('|');
|
||||
Assert.Equal("/api/desk/gamecult-chat/answers", parts[0]);
|
||||
Assert.Equal("/api/desk/ghost/../gamecult-chat/answers", parts[1]);
|
||||
Assert.Null(DeskRoute.Parse("POST", parts[1]));
|
||||
Assert.NotNull(DeskRoute.Parse("POST", parts[0]));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
using System.Security.Cryptography;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class DeskVectorPinTests
|
||||
{
|
||||
[Fact]
|
||||
public void Vectors_are_cultlib_de8a062a()
|
||||
{
|
||||
var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant();
|
||||
|
||||
Assert.True(
|
||||
DeskVectors.FileSha256 == sha,
|
||||
$"fork-card-vectors.json is not CultLib {DeskVectors.SourceCommit}:contracts/cultnet/governance/fork-card-vectors.json (sha256 {sha}). " +
|
||||
"Re-vendor from that commit or move the pin on purpose, with the whole contract.");
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,64 +0,0 @@
|
|||
// Runs the sign-up page's real inline script against a simulated browser and prints what it did as JSON.
|
||||
// Usage: node registration-page-harness.cjs <page.html>
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const html = fs.readFileSync(process.argv[2], "utf8");
|
||||
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
|
||||
if (scripts.length !== 1) {
|
||||
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
|
||||
}
|
||||
const script = scripts[0][1];
|
||||
const challenge = html.match(/<input type="hidden" name="challenge" value="([^"]*)"/)[1];
|
||||
const difficulty = html.match(/data-difficulty="(\d+)"/)[1];
|
||||
|
||||
async function run(name, { withCrypto, submits, skipAfter = false }) {
|
||||
const statuses = [];
|
||||
let handler = null;
|
||||
let submitted = 0;
|
||||
let sawSubmit;
|
||||
const done = new Promise((resolve) => (sawSubmit = resolve));
|
||||
const form = {
|
||||
elements: { challenge: { value: challenge }, nonce: { value: "" }, waited: { disabled: false } },
|
||||
getAttribute: (n) => (n === "data-difficulty" ? difficulty : n === "data-wait" ? "30" : null),
|
||||
addEventListener: (type, fn) => {
|
||||
if (type === "submit") handler = fn;
|
||||
},
|
||||
submit() {
|
||||
submitted++;
|
||||
sawSubmit();
|
||||
},
|
||||
};
|
||||
const status = {
|
||||
set textContent(v) {
|
||||
statuses.push(v);
|
||||
},
|
||||
};
|
||||
const document = { getElementById: (id) => (id === "register" ? form : id === "status" ? status : null) };
|
||||
const window = withCrypto ? { crypto: webcrypto, TextEncoder } : {};
|
||||
vm.runInNewContext(script, { window, document, crypto: webcrypto, TextEncoder, Uint8Array, Promise, setTimeout, Math, parseInt, String });
|
||||
if (!handler) return { name, listening: false, statuses, submitted, nonce: "", waitedDisabled: false, prevented: [], challenge, difficulty };
|
||||
const prevented = [];
|
||||
for (let i = 0; i < submits; i++) handler({ preventDefault: () => prevented.push(true) });
|
||||
if (skipAfter) {
|
||||
// The skip button pressed while the solve is in flight: the script must let this submit through and drop the solve.
|
||||
const skipPrevented = [];
|
||||
handler({ preventDefault: () => skipPrevented.push(true), submitter: { id: "skip" } });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented: skipPrevented, challenge, difficulty };
|
||||
}
|
||||
await done;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return { name, listening: true, statuses, submitted, nonce: form.elements.nonce.value, waitedDisabled: form.elements.waited.disabled, prevented, challenge, difficulty };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const out = [
|
||||
await run("solves", { withCrypto: true, submits: 1 }),
|
||||
await run("double-press", { withCrypto: true, submits: 2 }),
|
||||
await run("skip-during-solve", { withCrypto: true, submits: 1, skipAfter: true }),
|
||||
await run("no-subtle", { withCrypto: false, submits: 0 }),
|
||||
];
|
||||
console.log(JSON.stringify(out));
|
||||
})();
|
||||
|
|
@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Authentication.OAuth;
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
|
@ -865,44 +864,6 @@ public sealed class HeimdallCompletionTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspended_account_stays_suspended_and_gains_no_role_through_a_sign_in_with_the_member_claim()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
var accountId = HeimdallShapes.NewAccountId();
|
||||
Assert.True((await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"])).SignedIn);
|
||||
await host.Db(async db =>
|
||||
{
|
||||
var account = await db.UserAccounts.Include(x => x.Membership).SingleAsync(x => x.HeimdallAccountId == accountId);
|
||||
account.Membership!.Status = MembershipStatus.Suspended;
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
var rolesBefore = (await host.Account(accountId))!.Membership!.RoleAssignments.Count;
|
||||
var again = await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"]);
|
||||
|
||||
Assert.True(again.SignedIn);
|
||||
var after = (await host.Account(accountId))!.Membership!;
|
||||
Assert.Equal(MembershipStatus.Suspended, after.Status);
|
||||
Assert.Equal(rolesBefore, after.RoleAssignments.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
var accountId = HeimdallShapes.NewAccountId();
|
||||
Assert.True((await Genuine(host, await host.StartAsync(member: false), accountId)).SignedIn);
|
||||
Assert.Equal(MembershipStatus.Authenticated, (await host.Account(accountId))!.Membership!.Status);
|
||||
|
||||
Assert.True((await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"])).SignedIn);
|
||||
|
||||
var account = (await host.Account(accountId))!;
|
||||
Assert.Equal(MembershipStatus.Active, account.Membership!.Status);
|
||||
Assert.Contains(account.Membership.RoleAssignments, x => x.Role == MemberRole.StandardMember);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_token_capability_grants_no_role_to_a_sign_in_that_did_not_ask_for_member_access()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -362,9 +362,7 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
MintPermits = mintPermits,
|
||||
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
|
||||
MaxTrackedKeys = maxTrackedKeys
|
||||
}),
|
||||
Options.Create(new DeskOptions()),
|
||||
Options.Create(new RegistrationOptions())).Validate();
|
||||
})).Validate();
|
||||
}
|
||||
|
||||
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Membership;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Pages.App.Members;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
/// <summary>A suspension is an admin's decision: only the admin path sets or lifts it, with a receipt, and the members page's Approve cannot lift it.</summary>
|
||||
public sealed class MembershipSuspensionTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
||||
public MembershipSuspensionTests(TestWebApplicationFactory factory) => _factory = factory;
|
||||
|
||||
private sealed record Seeded(Guid AdminId, Guid MemberId, Guid OrdinaryId, Guid OtherAdminId);
|
||||
|
||||
private async Task<Seeded> SeedAsync(MembershipStatus memberStatus = MembershipStatus.Active, bool approved = true)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
UserAccount Account(string name, MembershipStatus status, bool admin, bool wasApproved) => new()
|
||||
{
|
||||
DisplayName = name,
|
||||
BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
|
||||
NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20],
|
||||
Membership = new Bifrost.Web.Domain.Membership
|
||||
{
|
||||
Status = status,
|
||||
IsPlatformAdmin = admin,
|
||||
ApprovedAtUtc = wasApproved ? DateTimeOffset.UtcNow : null
|
||||
}
|
||||
};
|
||||
|
||||
var admin = Account("admin", MembershipStatus.Active, true, true);
|
||||
var otherAdmin = Account("admin2", MembershipStatus.Active, true, true);
|
||||
var member = Account("member", memberStatus, false, approved);
|
||||
var ordinary = Account("ordinary", MembershipStatus.Active, false, true);
|
||||
db.UserAccounts.AddRange(admin, otherAdmin, member, ordinary);
|
||||
await db.SaveChangesAsync();
|
||||
return new Seeded(admin.Id, member.Id, ordinary.Id, otherAdmin.Id);
|
||||
}
|
||||
|
||||
private async Task<T> InScopeAsync<T>(Func<IServiceProvider, BifrostDbContext, Task<T>> body)
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
return await body(scope.ServiceProvider, scope.ServiceProvider.GetRequiredService<BifrostDbContext>());
|
||||
}
|
||||
|
||||
private Task<CurrentBifrostActor> ActorAsync(Guid id) => InScopeAsync(async (_, db) =>
|
||||
new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == id)));
|
||||
|
||||
private Task<MembershipStatus> StatusAsync(Guid id) => InScopeAsync(async (_, db) =>
|
||||
(await db.Memberships.AsNoTracking().SingleAsync(x => x.UserAccountId == id)).Status);
|
||||
|
||||
private Task<List<AuditEvent>> AuditAsync(string action) => InScopeAsync((_, db) =>
|
||||
db.AuditEvents.AsNoTracking().Where(x => x.Action == action).ToListAsync());
|
||||
|
||||
private Task<SuspensionOutcome> SuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) =>
|
||||
await sp.GetRequiredService<MembershipSuspensionService>().SuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None));
|
||||
|
||||
private Task<SuspensionOutcome> UnsuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) =>
|
||||
await sp.GetRequiredService<MembershipSuspensionService>().UnsuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None));
|
||||
|
||||
[Fact]
|
||||
public async Task Only_a_platform_admin_can_suspend_and_the_receipt_names_who_and_why()
|
||||
{
|
||||
var seed = await SeedAsync();
|
||||
|
||||
Assert.Equal(SuspensionOutcome.NotAdmin, await SuspendAsync(seed.OrdinaryId, seed.MemberId, "because"));
|
||||
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
|
||||
Assert.Equal(SuspensionOutcome.NoReason, await SuspendAsync(seed.AdminId, seed.MemberId, " "));
|
||||
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
|
||||
|
||||
Assert.Equal(SuspensionOutcome.Done, await SuspendAsync(seed.AdminId, seed.MemberId, "spam in chat"));
|
||||
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
|
||||
var receipt = Assert.Single(await AuditAsync("membership.suspended"), x => x.Detail.Contains("spam in chat"));
|
||||
Assert.Equal(seed.AdminId, receipt.ActorUserAccountId);
|
||||
Assert.Contains("Active -> Suspended", receipt.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_admin_cannot_suspend_themselves_or_another_platform_admin()
|
||||
{
|
||||
var seed = await SeedAsync();
|
||||
|
||||
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.AdminId, "oops"));
|
||||
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.OtherAdminId, "coup"));
|
||||
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.AdminId));
|
||||
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.OtherAdminId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspension_reason_does_not_reach_the_member_dashboard_snapshot()
|
||||
{
|
||||
var seed = await SeedAsync();
|
||||
await SuspendAsync(seed.AdminId, seed.MemberId, "private-reason-canary");
|
||||
|
||||
var snapshot = await InScopeAsync(async (sp, _) => await sp.GetRequiredService<DashboardSnapshotService>().GetAsync(seed.OrdinaryId, CancellationToken.None));
|
||||
|
||||
Assert.DoesNotContain(snapshot.RecentActivity, x => x.Detail.Contains("private-reason-canary"));
|
||||
Assert.Contains(await AuditAsync("membership.suspended"), x => x.Detail.Contains("private-reason-canary"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_role_granted_admin_is_protected_like_a_flagged_one()
|
||||
{
|
||||
var seed = await SeedAsync();
|
||||
await InScopeAsync(async (_, db) =>
|
||||
{
|
||||
var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId);
|
||||
db.RoleAssignments.Add(new RoleAssignment { MembershipId = membership.Id, Role = MemberRole.PlatformAdmin });
|
||||
await db.SaveChangesAsync();
|
||||
return 0;
|
||||
});
|
||||
|
||||
Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.MemberId, "role admin"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, MembershipStatus.Active)]
|
||||
[InlineData(false, MembershipStatus.Authenticated)]
|
||||
public async Task Unsuspend_works_only_by_the_admin_path_and_with_a_reason(bool approved, MembershipStatus restored)
|
||||
{
|
||||
var seed = await SeedAsync(MembershipStatus.Suspended, approved);
|
||||
|
||||
Assert.Equal(SuspensionOutcome.NotAdmin, await UnsuspendAsync(seed.OrdinaryId, seed.MemberId, "please"));
|
||||
Assert.Equal(SuspensionOutcome.NoReason, await UnsuspendAsync(seed.AdminId, seed.MemberId, null));
|
||||
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
|
||||
|
||||
Assert.Equal(SuspensionOutcome.Done, await UnsuspendAsync(seed.AdminId, seed.MemberId, "appeal upheld"));
|
||||
Assert.Equal(restored, await StatusAsync(seed.MemberId));
|
||||
Assert.Contains(await AuditAsync("membership.unsuspended"), x => x.Detail.Contains("appeal upheld") && x.ActorUserAccountId == seed.AdminId);
|
||||
Assert.Equal(SuspensionOutcome.NotApplicable, await UnsuspendAsync(seed.AdminId, seed.MemberId, "again"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_members_page_Approve_cannot_lift_a_suspension()
|
||||
{
|
||||
var seed = await SeedAsync(MembershipStatus.Suspended);
|
||||
|
||||
var result = await InScopeAsync(async (sp, db) =>
|
||||
{
|
||||
var page = new IndexModel(
|
||||
db, new FixedActor(await ActorAsync(seed.AdminId)), sp.GetRequiredService<AuditTrailService>(),
|
||||
sp.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
|
||||
return await page.OnPostApproveAsync(seed.MemberId, CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.IsType<RedirectToPageResult>(result);
|
||||
Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_members_page_suspend_handler_forbids_a_non_admin_and_changes_nothing()
|
||||
{
|
||||
var seed = await SeedAsync();
|
||||
|
||||
var result = await InScopeAsync(async (sp, db) =>
|
||||
{
|
||||
var page = new IndexModel(
|
||||
db, new FixedActor(await ActorAsync(seed.OrdinaryId)), sp.GetRequiredService<AuditTrailService>(),
|
||||
sp.GetRequiredService<MembershipSuspensionService>(), TimeProvider.System);
|
||||
return await page.OnPostSuspendAsync(seed.MemberId, "nope", CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.IsType<ForbidResult>(result);
|
||||
Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId));
|
||||
}
|
||||
|
||||
private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor
|
||||
{
|
||||
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ public sealed class MigrationDiscoveryTests
|
|||
"20261022163159_AddOpenIddict",
|
||||
"20261109200000_AddSealedBallotMotions",
|
||||
"20261109210000_AddMindKeys",
|
||||
"20261111090000_AddConsentReceipts",
|
||||
];
|
||||
|
||||
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ using System.Text.Json;
|
|||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -20,7 +19,7 @@ namespace Bifrost.Web.Tests;
|
|||
public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
||||
{
|
||||
private static readonly Uri Origin = new("https://localhost");
|
||||
private OidcWebApplicationFactory _factory;
|
||||
private readonly OidcWebApplicationFactory _factory;
|
||||
|
||||
public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory;
|
||||
|
||||
|
|
@ -107,74 +106,20 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
Assert.Equal("Second Name", second.Claim("name"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_suspended_account_is_refused_with_access_denied_and_no_code()
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status)
|
||||
{
|
||||
var (client, _) = await SignedInAsync(MembershipStatus.Suspended, "Suspended One");
|
||||
var (client, _) = await SignedInAsync(status, "Not Active");
|
||||
var request = NewRequest();
|
||||
|
||||
var final = await DriveAsync(client, request);
|
||||
|
||||
var redirect = AssertRedirectToClient(final, request.State);
|
||||
Assert.Equal("access_denied", redirect["error"]);
|
||||
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.SuspendedDescription, redirect["error_description"]);
|
||||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
public async Task A_non_member_gets_a_chat_only_token_and_writes_no_membership_or_vote(MembershipStatus status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(status, "Chat Only");
|
||||
var accountId = _factory.AccountId(identity);
|
||||
var before = await _factory.RowCountsAsync();
|
||||
|
||||
var tokens = await CompleteFlowAsync(client);
|
||||
|
||||
var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce);
|
||||
Assert.Equal(accountId.ToString("D"), verified.Claim("sub"));
|
||||
Assert.Equal(identity, verified.Claim("preferred_username"));
|
||||
|
||||
// The issuer wrote nothing about membership or votes, and the status is exactly what it was.
|
||||
Assert.Equal(before, await _factory.RowCountsAsync());
|
||||
Assert.Equal(status, await _factory.MembershipStatusAsync(identity));
|
||||
Assert.Equal(0, (await _factory.RowCountsAsync()).Votes);
|
||||
|
||||
// Neither the session that signed in to chat nor its token opens a member or governance surface.
|
||||
foreach (var path in new[] { "/eve/governance/surface", "/api/sealed-motions", "/App" })
|
||||
{
|
||||
using var viaSession = await client.GetAsync(path);
|
||||
Assert.NotEqual(HttpStatusCode.OK, viaSession.StatusCode);
|
||||
|
||||
using var viaToken = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
viaToken.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
using var bearer = await NewClient().SendAsync(viaToken);
|
||||
Assert.NotEqual(HttpStatusCode.OK, bearer.StatusCode);
|
||||
}
|
||||
|
||||
using var desk = new HttpRequestMessage(HttpMethod.Get, "/api/desk/forks");
|
||||
desk.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
using var deskResponse = await NewClient().SendAsync(desk);
|
||||
Assert.NotEqual(HttpStatusCode.OK, deskResponse.StatusCode);
|
||||
|
||||
// Last, because userinfo answers 500 on a .NET 10 runtime (the six known failures), and the rest must still run there.
|
||||
using var userinfo = await UserInfoAsync(tokens.AccessToken);
|
||||
Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode);
|
||||
var info = JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(["name", "preferred_username", "sub"], info.EnumerateObject().Select(x => x.Name).Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_chat_tier_account_with_a_reserved_identity_is_still_refused()
|
||||
{
|
||||
using var factory = new OidcFixedIdentityFactory("eureka", MembershipStatus.Authenticated);
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}");
|
||||
var request = NewRequest();
|
||||
|
||||
var redirect = AssertRedirectToClient(await DriveAsync(client, request), request.State);
|
||||
Assert.Equal("access_denied", redirect["error"]);
|
||||
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NotActiveDescription, redirect["error_description"]);
|
||||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
|
|
@ -194,289 +139,6 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("meta")] // an operator's or a Mind's Matrix localpart
|
||||
[InlineData("eureka")]
|
||||
[InlineData("admin")]
|
||||
[InlineData("system")] // a system name
|
||||
[InlineData("Heimdall")] // the row's casing does not matter
|
||||
public async Task An_active_member_whose_identity_is_a_reserved_localpart_gets_no_code(string reserved)
|
||||
{
|
||||
using var factory = new OidcFixedIdentityFactory(reserved);
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); // any session: the actor is the fixed account
|
||||
var request = NewRequest();
|
||||
|
||||
var final = await DriveAsync(client, request);
|
||||
|
||||
var redirect = AssertRedirectToClient(final, request.State);
|
||||
Assert.Equal("access_denied", redirect["error"]);
|
||||
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.ReservedIdentityDescription, redirect["error_description"]);
|
||||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("meta2")] // look-alikes are ordinary names
|
||||
[InlineData("admins")]
|
||||
[InlineData("kiko-fan")]
|
||||
public async Task A_name_that_only_resembles_a_reserved_localpart_is_given_a_code(string ordinary)
|
||||
{
|
||||
using var factory = new OidcFixedIdentityFactory(ordinary);
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}");
|
||||
var request = NewRequest();
|
||||
|
||||
var final = await DriveAsync(client, request);
|
||||
|
||||
var redirect = AssertRedirectToClient(final, request.State);
|
||||
Assert.True(redirect.ContainsKey("code"));
|
||||
Assert.False(redirect.ContainsKey("error"));
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?> Settings(params (string Key, string Value)[] pairs) =>
|
||||
pairs.ToDictionary(p => $"Bifrost:Oidc:{p.Key}", p => (string?)p.Value);
|
||||
|
||||
[Fact]
|
||||
public async Task A_flood_from_one_address_is_refused_with_429_and_a_retry_after()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "5")));
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
HttpResponseMessage? last = null;
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
last?.Dispose();
|
||||
last = await client.GetAsync(NewRequest().Path);
|
||||
statuses.Add(last.StatusCode);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(5));
|
||||
Assert.All(statuses.Skip(5), status => Assert.Equal(HttpStatusCode.TooManyRequests, status));
|
||||
Assert.True(int.Parse(last!.Headers.GetValues("Retry-After").Single()) >= 1);
|
||||
using var tokenFlood = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, tokenFlood.StatusCode); // one budget over all three endpoints
|
||||
using var userinfoFlood = await client.GetAsync("/connect/userinfo");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, userinfoFlood.StatusCode);
|
||||
last.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_flood_naming_the_real_client_does_not_spend_anyone_elses_permits_and_a_trailing_slash_is_no_way_around()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "4")));
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
foreach (var path in new[] { NewRequest().Path, NewRequest().Path, "/connect/authorize/?client_id=x", "/connect/token/", "/connect/userinfo/" })
|
||||
{
|
||||
using var response = await client.GetAsync(path);
|
||||
statuses.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, statuses[4]); // the slashed forms spend the same budget
|
||||
Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(4));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/connect/authorize")]
|
||||
[InlineData("/connect/authorize/")]
|
||||
[InlineData("/connect/token/")]
|
||||
[InlineData("/connect/userinfo/")]
|
||||
[InlineData("/CONNECT/Userinfo")]
|
||||
public async Task The_brake_holds_for_every_spelling_of_an_endpoint(string path)
|
||||
{
|
||||
var marker = Path.Combine(Path.GetTempPath(), $"oidc-brake-{Guid.NewGuid():N}");
|
||||
File.WriteAllText(marker, "braked");
|
||||
try
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker)));
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
using var response = await client.GetAsync(path == "/connect/authorize" || path == "/connect/authorize/" ? path + "?client_id=x" : path);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_guard_that_cannot_decide_answers_503_and_never_passes_the_request_on()
|
||||
{
|
||||
var reached = false;
|
||||
var options = new Bifrost.Web.Configuration.OidcIssuerOptions();
|
||||
var middleware = new Bifrost.Web.Features.Oidc.OidcGuardMiddleware(
|
||||
_ => { reached = true; return Task.CompletedTask; }, options,
|
||||
new Bifrost.Web.Features.Oidc.OidcRateLimit(options, TimeProvider.System),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<Bifrost.Web.Features.Oidc.OidcGuardMiddleware>.Instance);
|
||||
var http = new DefaultHttpContext();
|
||||
http.Request.Path = "/connect/authorize";
|
||||
http.Request.QueryString = new QueryString("?client_id=x");
|
||||
http.Response.Body = new MemoryStream();
|
||||
|
||||
await middleware.InvokeAsync(http, System.Reflection.DispatchProxy.Create<OpenIddict.Abstractions.IOpenIddictTokenManager, ThrowingProxy>());
|
||||
|
||||
Assert.False(reached);
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, http.Response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Every call fails: the row count the guard asks of the token table cannot be answered.</summary>
|
||||
public class ThrowingProxy : System.Reflection.DispatchProxy
|
||||
{
|
||||
protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) => throw new InvalidOperationException("the token table is down");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_resumed_authorize_request_is_not_refused_by_the_row_cap_but_a_fresh_one_is()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3")));
|
||||
_factory = factory;
|
||||
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Resumer");
|
||||
using var first = await client.GetAsync(NewRequest().Path); // parks one row, answers with a redirect carrying the request_uri
|
||||
var resume = first.Headers.Location!.ToString();
|
||||
Assert.Contains("request_uri", resume);
|
||||
|
||||
using var anonymous = factory.CreateClient(ClientOptions());
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
using var filler = await anonymous.GetAsync(NewRequest().Path);
|
||||
}
|
||||
|
||||
using var fresh = await anonymous.GetAsync(NewRequest().Path);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, fresh.StatusCode);
|
||||
using var resumed = await client.GetAsync(resume);
|
||||
Assert.NotEqual(HttpStatusCode.ServiceUnavailable, resumed.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_mas_flow_fits_the_default_budgets_and_the_window_reopens()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "30")));
|
||||
_factory = factory;
|
||||
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Within Budget");
|
||||
|
||||
var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null);
|
||||
Assert.Equal(identity, verified.Claim("preferred_username"));
|
||||
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
using var burst = await client.GetAsync("/connect/userinfo");
|
||||
}
|
||||
|
||||
using var refused = await client.GetAsync("/connect/userinfo");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
factory.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
using var reopened = await client.GetAsync("/connect/userinfo");
|
||||
Assert.NotEqual(HttpStatusCode.TooManyRequests, reopened.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_brake_marker_stops_the_issuer_and_releasing_it_needs_no_restart()
|
||||
{
|
||||
var marker = Path.Combine(Path.GetTempPath(), $"bifrost-oidc-brake-{Guid.NewGuid():N}");
|
||||
using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker)));
|
||||
_factory = factory;
|
||||
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Braked");
|
||||
try
|
||||
{
|
||||
Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released: the flow passes");
|
||||
|
||||
await File.WriteAllTextAsync(marker, "held");
|
||||
foreach (var path in new[] { NewRequest().Path, "/connect/userinfo" })
|
||||
{
|
||||
using var held = await client.GetAsync(path);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, held.StatusCode);
|
||||
Assert.True(held.Headers.Contains("Retry-After"));
|
||||
}
|
||||
|
||||
using var token = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>()));
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, token.StatusCode);
|
||||
using var discovery = await client.GetAsync("/.well-known/openid-configuration");
|
||||
Assert.Equal(HttpStatusCode.OK, discovery.StatusCode); // the brake stops issuing, not the published keys
|
||||
|
||||
File.Delete(marker);
|
||||
Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released again, same process");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_authorize_requests_cannot_park_more_than_the_row_cap_and_the_prune_gives_the_room_back()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3")));
|
||||
_factory = factory;
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
|
||||
var parked = new List<HttpStatusCode>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var response = await client.GetAsync(NewRequest().Path);
|
||||
parked.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(3, await factory.TokenRowsAsync());
|
||||
Assert.All(parked.Skip(3), status => Assert.Equal(HttpStatusCode.ServiceUnavailable, status));
|
||||
Assert.All(parked.Take(3), status => Assert.NotEqual(HttpStatusCode.ServiceUnavailable, status));
|
||||
|
||||
factory.Clock.Advance(TimeSpan.FromMinutes(11)); // past the ten-minute parked lifetime
|
||||
await factory.PruneAsync();
|
||||
Assert.Equal(0, await factory.TokenRowsAsync());
|
||||
using var again = await client.GetAsync(NewRequest().Path);
|
||||
Assert.NotEqual(HttpStatusCode.ServiceUnavailable, again.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_prune_removes_expired_rows_and_leaves_a_live_token_alone()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings());
|
||||
_factory = factory;
|
||||
var (client, _) = await SignedInAsync(MembershipStatus.Active, "Pruned");
|
||||
var tokens = await CompleteFlowAsync(client); // a parked request, a code and an access token existed along the way
|
||||
using (var anonymous = factory.CreateClient(ClientOptions()))
|
||||
{
|
||||
using var parked = await anonymous.GetAsync(NewRequest().Path); // a parked request that will expire
|
||||
}
|
||||
|
||||
var before = await factory.TokenRowsAsync();
|
||||
await factory.PruneAsync();
|
||||
Assert.Equal(before, await factory.TokenRowsAsync()); // nothing has expired yet: nothing goes
|
||||
|
||||
factory.Clock.Advance(TimeSpan.FromMinutes(11)); // parked requests and codes are dead, the access token (an hour) is not
|
||||
await factory.PruneAsync();
|
||||
var after = await factory.TokenRowsAsync();
|
||||
Assert.True(after < before, "expired rows went");
|
||||
Assert.Contains("urn:ietf:params:oauth:token-type:access_token", await factory.TokenTypesAsync()); // the live one, not merely some row
|
||||
|
||||
using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo");
|
||||
userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
using var response = await factory.CreateClient(ClientOptions()).SendAsync(userinfo);
|
||||
Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_prune_takes_a_revoked_authorization_with_its_tokens_and_touches_no_live_authorization()
|
||||
{
|
||||
using var factory = new OidcSettingsFactory(Settings());
|
||||
_factory = factory;
|
||||
var (revoked, _) = await SignedInAsync(MembershipStatus.Active, "Revoked");
|
||||
await CompleteFlowAsync(revoked);
|
||||
var (live, _) = await SignedInAsync(MembershipStatus.Active, "Live");
|
||||
await CompleteFlowAsync(live);
|
||||
Assert.Equal(2, await factory.AuthorizationRowsAsync());
|
||||
Assert.Equal(6, await factory.TokensOfAuthorizationsAsync()); // a spent code, an access token and an id token each
|
||||
await factory.RevokeFirstAuthorizationAsync();
|
||||
|
||||
factory.Clock.Advance(TimeSpan.FromMinutes(6)); // past the grace for a young row; the access tokens (an hour) are still alive
|
||||
await factory.PruneAsync();
|
||||
|
||||
Assert.Equal(1, await factory.AuthorizationRowsAsync());
|
||||
Assert.Equal(2, await factory.TokenRowsAsync()); // no orphan stays either: the revoked one's tokens went with it; the live one's access and id tokens stayed, spent codes go
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unknown-client", OidcTestKit.RedirectUri, true)]
|
||||
[InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)]
|
||||
|
|
@ -673,9 +335,11 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(null)]
|
||||
public async Task An_account_suspended_or_gone_after_authorize_gets_no_tokens(MembershipStatus? status)
|
||||
public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
|
||||
var request = NewRequest();
|
||||
|
|
@ -692,9 +356,11 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(null)]
|
||||
public async Task Userinfo_refuses_an_access_token_whose_account_is_suspended_or_gone(MembershipStatus? status)
|
||||
public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
|
||||
var tokens = await CompleteFlowAsync(client);
|
||||
|
|
@ -838,7 +504,7 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
|
||||
private static async Task<string> RegisterAsync(HttpClient client, string identity)
|
||||
{
|
||||
using var response = await RegistrationKit.RegisterAsync(client, identity);
|
||||
using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity });
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return identity.ToLowerInvariant();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ public sealed class OidcLogoutTests
|
|||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
public async Task A_suspended_account_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, idToken) = await factory.GrantAsync(status);
|
||||
|
|
@ -110,23 +112,6 @@ public sealed class OidcLogoutTests
|
|||
Assert.Equal(before, factory.ValidAuthorizations(activeSubject));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
public async Task A_chat_tier_non_member_is_never_sent_a_logout_and_keeps_the_authorizations(MembershipStatus status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (chat, _) = await factory.GrantAsync(status);
|
||||
var subject = factory.AccountId(chat).ToString("D");
|
||||
var before = factory.ValidAuthorizations(subject);
|
||||
Assert.True(before > 0);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(factory.Mas.LogoutTokens());
|
||||
Assert.Equal(before, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_subject_that_cannot_be_delivered_does_not_block_another()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,8 +45,16 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
public async Task Bifrost_native_registration_creates_authenticated_identity_without_oauth()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
using var response = await RegistrationKit.RegisterAsync(
|
||||
client, $"native-{Guid.NewGuid():N}", "Native Bifrost User", "/App", anonymous: true);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
request.Content = JsonContent.Create(new
|
||||
{
|
||||
identity = $"native-{Guid.NewGuid():N}",
|
||||
displayName = "Native Bifrost User",
|
||||
returnUrl = "/App"
|
||||
});
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
|
@ -71,8 +79,15 @@ public sealed class PlatformEndpointsTests : IClassFixture<TestWebApplicationFac
|
|||
using var client = _factory.CreateClient();
|
||||
var identity = $"native-{Guid.NewGuid():N}";
|
||||
|
||||
using var firstResponse = await RegistrationKit.RegisterAsync(client, identity, "First", anonymous: true);
|
||||
using var secondResponse = await RegistrationKit.RegisterAsync(client, identity.ToUpperInvariant(), "Second", anonymous: true);
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
firstRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||
firstRequest.Content = JsonContent.Create(new { identity, displayName = "First" });
|
||||
using var firstResponse = await client.SendAsync(firstRequest);
|
||||
|
||||
using var secondRequest = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register");
|
||||
secondRequest.Headers.Add("X-Test-Anonymous", "true");
|
||||
secondRequest.Content = JsonContent.Create(new { identity = identity.ToUpperInvariant(), displayName = "Second" });
|
||||
using var secondResponse = await client.SendAsync(secondRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode);
|
||||
|
|
|
|||
|
|
@ -1,897 +0,0 @@
|
|||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public class RegistrationFormFactory : TestWebApplicationFactory
|
||||
{
|
||||
public ManualClock Time { get; } = new();
|
||||
|
||||
protected virtual string FallbackPermits => "100";
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Bifrost:Registration:Captcha:FallbackWaitSeconds"] = "30",
|
||||
["Bifrost:Registration:Limits:FallbackPermits"] = FallbackPermits
|
||||
}));
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<TimeProvider>();
|
||||
services.AddSingleton<TimeProvider>(Time);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFallbackBudgetFactory : RegistrationFormFactory
|
||||
{
|
||||
protected override string FallbackPermits => "2";
|
||||
}
|
||||
|
||||
internal static class FormKit
|
||||
{
|
||||
public static string Hidden(string html, string name)
|
||||
{
|
||||
var match = Regex.Match(html, $"<input type=\"hidden\" name=\"{name}\" value=\"([^\"]*)\"");
|
||||
Assert.True(match.Success, $"no hidden field {name}");
|
||||
return WebUtility.HtmlDecode(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
public static async Task<string> PageAsync(HttpClient client, string query = "")
|
||||
{
|
||||
using var response = await client.GetAsync("/auth/bifrost/register" + query);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
public const string SiteOrigin = "https://localhost";
|
||||
|
||||
// A browser on the site's own page sends its Origin; the guard refuses a post without one.
|
||||
public static Task<HttpResponseMessage> PostAsync(HttpClient client, Dictionary<string, string> fields, string? origin = SiteOrigin, string? peer = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) };
|
||||
if (origin is not null)
|
||||
{
|
||||
request.Headers.Add("Origin", origin);
|
||||
}
|
||||
|
||||
if (peer is not null)
|
||||
{
|
||||
request.Headers.Add("X-Test-Peer", peer);
|
||||
}
|
||||
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["identity"] = identity,
|
||||
["displayName"] = "",
|
||||
["challenge"] = Hidden(html, "challenge"),
|
||||
["acceptedNoticeVersion"] = Hidden(html, "acceptedNoticeVersion"),
|
||||
["returnUrl"] = returnUrl ?? Hidden(html, "returnUrl")
|
||||
};
|
||||
if (consent)
|
||||
{
|
||||
fields["consent"] = "yes";
|
||||
}
|
||||
|
||||
if (nonce is not null)
|
||||
{
|
||||
fields["nonce"] = nonce;
|
||||
}
|
||||
|
||||
if (waited)
|
||||
{
|
||||
fields["waited"] = "true";
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
public static string Solved(string html) => RegistrationKit.Solve(Hidden(html, "challenge"), int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value));
|
||||
}
|
||||
|
||||
public sealed class RegistrationFormTests(RegistrationFormFactory factory) : IClassFixture<RegistrationFormFactory>
|
||||
{
|
||||
private static string NewIdentity() => $"form-{Guid.NewGuid():N}";
|
||||
|
||||
private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
|
||||
private (int Accounts, int Receipts) Counts()
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
|
||||
}
|
||||
|
||||
private bool Exists(string identity)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
return scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts.Any(x => x.NormalizedBifrostIdentity == identity);
|
||||
}
|
||||
|
||||
private (bool Account, bool Receipt) Created(string identity)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = db.UserAccounts.SingleOrDefault(x => x.NormalizedBifrostIdentity == identity);
|
||||
return (account is not null, account is not null && db.ConsentReceipts.Any(x => x.UserAccountId == account.Id && x.NoticeVersion == TrainingNotice.Version));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_page_shows_the_whole_notice_beside_an_unchecked_consent_and_touches_nothing_outside()
|
||||
{
|
||||
using var client = Client();
|
||||
using var response = await client.GetAsync("/auth/bifrost/register");
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
Assert.Contains(WebUtility.HtmlEncode(TrainingNotice.Text), html);
|
||||
Assert.Contains($"version {TrainingNotice.Version}", html);
|
||||
Assert.Contains("<html lang=\"en\">", html);
|
||||
Assert.Matches("<input type=\"checkbox\" id=\"consent\" name=\"consent\" value=\"yes\" required [^>]*/>", html);
|
||||
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", html);
|
||||
Assert.Contains("I have read this notice and consent", html);
|
||||
foreach (var id in new[] { "identity", "displayName", "consent" })
|
||||
{
|
||||
Assert.Matches($"<label[^>]*for=\"{id}\"", html);
|
||||
}
|
||||
|
||||
Assert.Contains("<fieldset>", html);
|
||||
Assert.Contains("<legend>", html);
|
||||
Assert.Contains("aria-live=\"polite\"", html);
|
||||
Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion"));
|
||||
Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge")));
|
||||
Assert.Matches("wait \\d+ seconds", html);
|
||||
Assert.Matches("<button type=\"submit\" id=\"skip\"[^>]*>Skip the automatic check and wait \\d+ seconds instead</button>", html);
|
||||
Assert.DoesNotContain("a few seconds", html);
|
||||
Assert.DoesNotContain("nothing is lost", html);
|
||||
Assert.Contains("tick the box again", html);
|
||||
Assert.DoesNotContain("<noscript", html);
|
||||
Assert.DoesNotContain("http://", html);
|
||||
Assert.DoesNotContain("https://", html);
|
||||
foreach (Match url in Regex.Matches(html, "(?:src|href|action)=\"([^\"]*)\""))
|
||||
{
|
||||
Assert.Matches("^(/(?!/)|#)", url.Groups[1].Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_solved_form_creates_the_account_and_receipt_signs_in_and_redirects_to_the_return_path()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client, "?returnUrl=%2FTasks%3Fx%3D1");
|
||||
Assert.Equal("/Tasks?x=1", FormKit.Hidden(html, "returnUrl"));
|
||||
var identity = NewIdentity();
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: FormKit.Solved(html)));
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
Assert.Equal("/Tasks?x=1", response.Headers.Location!.OriginalString);
|
||||
Assert.True(response.Headers.Contains("Set-Cookie"));
|
||||
Assert.Equal((true, true), Created(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_default_return_path_is_App()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: ""));
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
Assert.Equal("/App", response.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("//evil.example")]
|
||||
[InlineData("https://evil.example/App")]
|
||||
[InlineData("/\\evil.example")]
|
||||
[InlineData("\\\\evil.example")]
|
||||
public async Task A_return_path_that_leaves_the_site_lands_on_App(string probe)
|
||||
{
|
||||
using var client = Client();
|
||||
var page = await FormKit.PageAsync(client, "?returnUrl=" + Uri.EscapeDataString(probe));
|
||||
Assert.Equal("/App", FormKit.Hidden(page, "returnUrl"));
|
||||
|
||||
var html = await FormKit.PageAsync(client);
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: probe));
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
Assert.Equal("/App", response.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_ASCII_return_path_never_reaches_the_Location_header()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html), returnUrl: "/Tasks\u00e9"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
Assert.Equal("/App", response.Headers.Location!.OriginalString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_the_consent_box_the_page_returns_with_an_error_a_fresh_challenge_and_nothing_created()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var challenge = FormKit.Hidden(html, "challenge");
|
||||
var identity = NewIdentity();
|
||||
var before = Counts();
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, consent: false, nonce: FormKit.Solved(html)));
|
||||
var again = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Matches("role=\"alert\"", again);
|
||||
Assert.Contains("requires accepting the training notice", again);
|
||||
Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
|
||||
Assert.Contains("<a href=\"#consent\">", again);
|
||||
Assert.DoesNotMatch("<input type=\"checkbox\"[^>]*\\bchecked\\b", again);
|
||||
Assert.Contains($"value=\"{identity}\"", again);
|
||||
Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge"));
|
||||
Assert.DoesNotContain(challenge, again);
|
||||
Assert.Equal(before, Counts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var challenge = FormKit.Hidden(html, "challenge");
|
||||
var identity = NewIdentity();
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: "7777777777"));
|
||||
var again = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("Registration challenge failed.", again);
|
||||
Assert.DoesNotContain(challenge, again);
|
||||
Assert.DoesNotContain("7777777777", again);
|
||||
Assert.False(Exists(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_with_markup_is_escaped_when_the_page_comes_back()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
const string hostile = "<script>alert(1)</script>\"><img src=x>";
|
||||
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false));
|
||||
var again = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.DoesNotContain("<script>alert(1)", again);
|
||||
Assert.DoesNotContain("<img src=x>", again);
|
||||
Assert.Contains("<script>alert(1)</script>", again);
|
||||
Assert.Single(Regex.Matches(again, "<script>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_taken_identity_marks_the_identity_field_invalid()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
var first = await FormKit.PageAsync(client);
|
||||
using (await FormKit.PostAsync(client, FormKit.Fields(first, identity, nonce: FormKit.Solved(first)))) { }
|
||||
|
||||
var second = await FormKit.PageAsync(client);
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(second, identity, nonce: FormKit.Solved(second)));
|
||||
var again = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("already registered", again);
|
||||
Assert.Matches("id=\"identity\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again);
|
||||
Assert.Contains($"value=\"{identity}\"", again);
|
||||
Assert.Contains("<a href=\"#identity\">", again);
|
||||
Assert.Equal(FormKit.Hidden(second, "challenge"), FormKit.Hidden(again, "challenge"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_time_lock_refuses_before_the_wait_with_the_seconds_left_keeps_the_challenge_and_accepts_after()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var challenge = FormKit.Hidden(html, "challenge");
|
||||
var identity = NewIdentity();
|
||||
var before = Counts();
|
||||
|
||||
using var early = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
|
||||
var earlyPage = await early.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
|
||||
Assert.Contains("30 more seconds", earlyPage);
|
||||
Assert.Equal(challenge, FormKit.Hidden(earlyPage, "challenge"));
|
||||
Assert.Equal(before, Counts());
|
||||
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(10));
|
||||
using var stillEarly = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
|
||||
Assert.Contains("20 more seconds", await stillEarly.Content.ReadAsStringAsync());
|
||||
Assert.Equal(before, Counts());
|
||||
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(20));
|
||||
using var ready = await FormKit.PostAsync(client, FormKit.Fields(earlyPage, identity, waited: true));
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, ready.StatusCode);
|
||||
Assert.Equal((true, true), Created(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_spent_time_lock_challenge_cannot_be_used_again_by_either_path()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
using (var first = await FormKit.PostAsync(client, FormKit.Fields(html, NewIdentity(), waited: true)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode);
|
||||
}
|
||||
|
||||
var replayWaited = NewIdentity();
|
||||
using var again = await FormKit.PostAsync(client, FormKit.Fields(html, replayWaited, waited: true));
|
||||
var replayPow = NewIdentity();
|
||||
using var viaWork = await FormKit.PostAsync(client, FormKit.Fields(html, replayPow, nonce: FormKit.Solved(html)));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, again.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, viaWork.StatusCode);
|
||||
Assert.False(Exists(replayWaited));
|
||||
Assert.False(Exists(replayPow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_nonce_together_with_waited_is_refused_and_spends_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var identity = NewIdentity();
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
var nonce = FormKit.Solved(html);
|
||||
|
||||
using var both = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce, waited: true));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, both.StatusCode);
|
||||
Assert.Contains("not both", await both.Content.ReadAsStringAsync());
|
||||
Assert.False(Exists(identity));
|
||||
|
||||
using var json = await RegistrationKit.PostAsync(client, new
|
||||
{
|
||||
identity,
|
||||
challenge = FormKit.Hidden(html, "challenge"),
|
||||
nonce,
|
||||
waited = true,
|
||||
acceptedNoticeVersion = TrainingNotice.Version
|
||||
}, anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, json.StatusCode);
|
||||
Assert.False(Exists(identity));
|
||||
|
||||
// The challenge was not spent by either refusal.
|
||||
using var ok = await RegistrationKit.PostAsync(client, new
|
||||
{
|
||||
identity,
|
||||
challenge = FormKit.Hidden(html, "challenge"),
|
||||
nonce,
|
||||
acceptedNoticeVersion = TrainingNotice.Version
|
||||
}, anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_JSON_endpoint_takes_waited_true_with_the_same_time_lock()
|
||||
{
|
||||
using var client = Client();
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var challenge = issued.GetProperty("challenge").GetString()!;
|
||||
var identity = NewIdentity();
|
||||
object Body() => new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version };
|
||||
|
||||
using var early = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, early.StatusCode);
|
||||
Assert.Contains("more second", await early.Content.ReadAsStringAsync());
|
||||
Assert.False(Exists(identity));
|
||||
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(30));
|
||||
using var ready = await RegistrationKit.PostAsync(client, Body(), anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.OK, ready.StatusCode);
|
||||
Assert.Equal((true, true), Created(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tampered_challenge_fails_the_time_lock()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var challenge = FormKit.Hidden(html, "challenge");
|
||||
var fields = FormKit.Fields(html, NewIdentity(), waited: true);
|
||||
fields["challenge"] = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
|
||||
using var response = await FormKit.PostAsync(client, fields);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("Registration challenge failed.", await response.Content.ReadAsStringAsync());
|
||||
Assert.False(Exists(fields["identity"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_form_must_be_urlencoded()
|
||||
{
|
||||
using var client = Client();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = JsonContent.Create(new { identity = "x" }) };
|
||||
request.Headers.Add("Origin", FormKit.SiteOrigin);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
|
||||
}
|
||||
|
||||
public static TheoryData<string?, string?> CrossSite => new()
|
||||
{
|
||||
{ "https://evil.example", null },
|
||||
{ "https://evil.example", "same-origin" },
|
||||
{ "null", null },
|
||||
{ "https://localhost.evil.example", null },
|
||||
{ null, "cross-site" },
|
||||
{ null, "same-site" },
|
||||
{ null, "none" }
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(CrossSite))]
|
||||
public async Task A_cross_site_or_unattributed_post_is_refused_before_anything_is_read_or_spent(string? origin, string? fetchSite)
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var identity = NewIdentity();
|
||||
var before = Counts();
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, waited: true))
|
||||
};
|
||||
if (origin is not null)
|
||||
{
|
||||
request.Headers.Add("Origin", origin);
|
||||
}
|
||||
|
||||
if (fetchSite is not null)
|
||||
{
|
||||
request.Headers.Add("Sec-Fetch-Site", fetchSite);
|
||||
}
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
Assert.Equal(before, Counts());
|
||||
// The challenge was not spent by the refusal.
|
||||
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, identity, waited: true));
|
||||
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> PostWithAsync(HttpClient client, string html, string identity, string? origin, string? fetchSite, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(FormKit.Fields(html, identity, nonce: FormKit.Solved(html)))
|
||||
};
|
||||
if (origin is not null) request.Headers.Add("Origin", origin);
|
||||
if (fetchSite is not null) request.Headers.Add("Sec-Fetch-Site", fetchSite);
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_text_browser_sending_neither_header_is_accepted_only_without_a_session_cookie()
|
||||
{
|
||||
const string session = "__Host-bifrost_session=abc";
|
||||
using var client = Client();
|
||||
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var plain = NewIdentity();
|
||||
using (var ok = await PostWithAsync(client, html, plain, null, null, null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
|
||||
Assert.Equal((true, true), Created(plain));
|
||||
}
|
||||
|
||||
var second = await FormKit.PageAsync(client);
|
||||
var withSession = NewIdentity();
|
||||
using (var refused = await PostWithAsync(client, second, withSession, null, null, session))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
Assert.False(refused.Headers.Contains("Set-Cookie"));
|
||||
Assert.False(Exists(withSession));
|
||||
}
|
||||
|
||||
// Any header present is judged as before, cookie or not; same-origin with a session passes.
|
||||
using (var crossNoCookie = await PostWithAsync(client, second, withSession, null, "cross-site", null))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Forbidden, crossNoCookie.StatusCode);
|
||||
}
|
||||
|
||||
using var sameOrigin = await PostWithAsync(client, second, withSession, FormKit.SiteOrigin, null, session);
|
||||
Assert.Equal(HttpStatusCode.SeeOther, sameOrigin.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_error_carries_the_challenge_on_only_while_it_is_live()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var live = FormKit.Hidden(html, "challenge");
|
||||
|
||||
// Live: kept.
|
||||
using (var kept = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1")))
|
||||
{
|
||||
Assert.Equal(live, FormKit.Hidden(await kept.Content.ReadAsStringAsync(), "challenge"));
|
||||
}
|
||||
|
||||
// Garbage: replaced by a fresh live one, never echoed.
|
||||
var garbage = FormKit.Fields(html, "a!", nonce: "1");
|
||||
garbage["challenge"] = "garbage-token";
|
||||
using (var replaced = await FormKit.PostAsync(client, garbage))
|
||||
{
|
||||
var page = await replaced.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("garbage-token", page);
|
||||
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(page, "challenge")));
|
||||
}
|
||||
|
||||
// Spent: replaced.
|
||||
var spentPage = await FormKit.PageAsync(client);
|
||||
using (var spend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, NewIdentity(), nonce: FormKit.Solved(spentPage))))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.SeeOther, spend.StatusCode);
|
||||
}
|
||||
|
||||
using (var afterSpend = await FormKit.PostAsync(client, FormKit.Fields(spentPage, "a!", nonce: "1")))
|
||||
{
|
||||
Assert.NotEqual(FormKit.Hidden(spentPage, "challenge"), FormKit.Hidden(await afterSpend.Content.ReadAsStringAsync(), "challenge"));
|
||||
}
|
||||
|
||||
// Expired: replaced.
|
||||
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
using var afterExpiry = await FormKit.PostAsync(client, FormKit.Fields(html, "a!", nonce: "1"));
|
||||
var expiredPage = await afterExpiry.Content.ReadAsStringAsync();
|
||||
Assert.NotEqual(live, FormKit.Hidden(expiredPage, "challenge"));
|
||||
Assert.True(factory.Services.GetRequiredService<RegistrationChallengeService>().IsLive(FormKit.Hidden(expiredPage, "challenge")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_same_origin_post_without_an_Origin_header_is_accepted_on_Sec_Fetch_Site()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form")
|
||||
{
|
||||
Content = new FormUrlEncodedContent(FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html)))
|
||||
};
|
||||
request.Headers.Add("Sec-Fetch-Site", "same-origin");
|
||||
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_form_body_over_the_cap_is_refused_with_413_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var fields = FormKit.Fields(html, NewIdentity(), nonce: FormKit.Solved(html));
|
||||
fields["padding"] = new string('x', 17 * 1024);
|
||||
var before = Counts();
|
||||
|
||||
using var response = await FormKit.PostAsync(client, fields);
|
||||
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode);
|
||||
Assert.Equal(before, Counts());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("support", "reserved")]
|
||||
[InlineData("a!", "3-80 characters")]
|
||||
public async Task A_bad_identity_is_refused_without_spending_the_challenge(string identity, string fragment)
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var nonce = FormKit.Solved(html);
|
||||
|
||||
using var bad = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
|
||||
var again = await bad.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
Assert.Contains(fragment, again);
|
||||
Assert.Equal(FormKit.Hidden(html, "challenge"), FormKit.Hidden(again, "challenge"));
|
||||
|
||||
var good = NewIdentity();
|
||||
using var ok = await FormKit.PostAsync(client, FormKit.Fields(html, good, nonce: nonce));
|
||||
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
|
||||
Assert.Equal((true, true), Created(good));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3)]
|
||||
[InlineData(5)]
|
||||
public async Task A_challenge_payload_of_another_shape_is_refused(int parts)
|
||||
{
|
||||
using var client = Client();
|
||||
var protector = factory.Services.GetRequiredService<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>()
|
||||
.CreateProtector(RegistrationChallengeService.Purpose);
|
||||
var now = factory.Time.GetUtcNow();
|
||||
var expiry = (now + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
|
||||
var issued = (now - TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
|
||||
var fields = new[] { Guid.NewGuid().ToString("N"), expiry.ToString(), "1", issued.ToString(), "extra" };
|
||||
var challenge = protector.Protect(string.Join('.', fields.Take(parts)));
|
||||
var identity = NewIdentity();
|
||||
|
||||
using var pow = await RegistrationKit.PostAsync(client, new { identity, challenge, nonce = RegistrationKit.Solve(challenge, 1), acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
|
||||
using var waited = await RegistrationKit.PostAsync(client, new { identity, challenge, waited = true, acceptedNoticeVersion = TrainingNotice.Version }, anonymous: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, pow.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, waited.StatusCode);
|
||||
Assert.False(Exists(identity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_page_solver_script_finds_a_nonce_the_server_accepts_under_node()
|
||||
{
|
||||
using var client = Client();
|
||||
var html = await FormKit.PageAsync(client);
|
||||
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-register-{Guid.NewGuid():N}.html");
|
||||
await File.WriteAllTextAsync(pagePath, html);
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
|
||||
start.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "Fixtures", "registration-page-harness.cjs"));
|
||||
start.ArgumentList.Add(pagePath);
|
||||
Process process;
|
||||
try
|
||||
{
|
||||
process = Process.Start(start)!;
|
||||
}
|
||||
catch (Win32Exception error)
|
||||
{
|
||||
throw new InvalidOperationException("Node must be on PATH to run the sign-up page's script: " + error.Message, error);
|
||||
}
|
||||
|
||||
using (process)
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEndAsync();
|
||||
var errors = process.StandardError.ReadToEndAsync();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await errors);
|
||||
var runs = JsonDocument.Parse(await output).RootElement.EnumerateArray().ToDictionary(x => x.GetProperty("name").GetString()!);
|
||||
|
||||
var solved = runs["solves"];
|
||||
Assert.Equal(1, solved.GetProperty("submitted").GetInt32());
|
||||
Assert.True(solved.GetProperty("waitedDisabled").GetBoolean());
|
||||
var first = solved.GetProperty("statuses")[0].GetString()!;
|
||||
Assert.StartsWith("Checking.", first);
|
||||
Assert.Contains("Skip the automatic check", first);
|
||||
Assert.Contains("slow device", first);
|
||||
Assert.DoesNotContain("a few seconds", first);
|
||||
var nonce = solved.GetProperty("nonce").GetString()!;
|
||||
var challenge = FormKit.Hidden(html, "challenge");
|
||||
Assert.True(RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(challenge + ":" + nonce))) >= int.Parse(solved.GetProperty("difficulty").GetString()!));
|
||||
|
||||
// The server accepts the script's own nonce.
|
||||
var identity = NewIdentity();
|
||||
using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce));
|
||||
Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode);
|
||||
Assert.Equal((true, true), Created(identity));
|
||||
|
||||
var skip = runs["skip-during-solve"];
|
||||
Assert.Equal(0, skip.GetProperty("submitted").GetInt32());
|
||||
Assert.Equal("", skip.GetProperty("nonce").GetString());
|
||||
Assert.False(skip.GetProperty("waitedDisabled").GetBoolean());
|
||||
Assert.Equal(0, skip.GetProperty("prevented").GetArrayLength());
|
||||
|
||||
Assert.Equal(1, runs["double-press"].GetProperty("submitted").GetInt32());
|
||||
Assert.False(runs["no-subtle"].GetProperty("listening").GetBoolean());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFallbackBudgetTests(RegistrationFallbackBudgetFactory factory) : IClassFixture<RegistrationFallbackBudgetFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Past_the_fallback_budget_the_time_lock_gets_429_with_retry_after_and_work_still_passes()
|
||||
{
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var pages = new List<string>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
pages.Add(await FormKit.PageAsync(client));
|
||||
}
|
||||
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
var created = new List<string>();
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
HttpResponseMessage? third = null;
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var identity = $"fb-{Guid.NewGuid():N}";
|
||||
created.Add(identity);
|
||||
var response = await FormKit.PostAsync(client, FormKit.Fields(pages[i], identity, waited: true));
|
||||
statuses.Add(response.StatusCode);
|
||||
third = response;
|
||||
}
|
||||
|
||||
Assert.Equal([HttpStatusCode.SeeOther, HttpStatusCode.SeeOther, HttpStatusCode.TooManyRequests], statuses);
|
||||
Assert.True(third!.Headers.RetryAfter is not null);
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Equal(2, db.UserAccounts.Count(x => created.Contains(x.NormalizedBifrostIdentity!)));
|
||||
}
|
||||
|
||||
// The refused challenge was not spent, and proof of work is not under the fallback budget.
|
||||
var viaWork = $"fb-{Guid.NewGuid():N}";
|
||||
using var ok = await FormKit.PostAsync(client, FormKit.Fields(pages[2], viaWork, nonce: FormKit.Solved(pages[2])));
|
||||
Assert.Equal(HttpStatusCode.SeeOther, ok.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationPageBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task The_page_spends_the_same_per_address_budget_as_the_challenge_endpoint()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
foreach (var path in new[] { "/auth/bifrost/register", "/auth/bifrost/challenge", "/auth/bifrost/register", "/auth/bifrost/register" })
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
request.Headers.Add("X-Test-Peer", "203.0.113.99");
|
||||
using var response = await client.SendAsync(request);
|
||||
statuses.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal([HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests], statuses);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFallbackOptionsTests
|
||||
{
|
||||
private sealed class StubEnvironment(string name) : Microsoft.Extensions.Hosting.IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = name;
|
||||
public string ApplicationName { get; set; } = "Bifrost.Web";
|
||||
public string ContentRootPath { get; set; } = string.Empty;
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
|
||||
private static void Validate(RegistrationOptions options) =>
|
||||
new StartupConfigurationValidator(
|
||||
new ConfigurationBuilder().Build(),
|
||||
new StubEnvironment("Testing"),
|
||||
Options.Create(new GitHubOAuthOptions()),
|
||||
Options.Create(new GitHubAppOptions()),
|
||||
Options.Create(new HeimdallOptions()),
|
||||
Options.Create(new StripeOptions()),
|
||||
Options.Create(new BifrostHostOptions()),
|
||||
Options.Create(new ConceptVoteOptions()),
|
||||
Options.Create(new DeskOptions()),
|
||||
Options.Create(options)).Validate();
|
||||
|
||||
[Fact]
|
||||
public void The_defaults_pass() => Validate(new RegistrationOptions());
|
||||
|
||||
[Theory]
|
||||
[InlineData(4)]
|
||||
[InlineData(301)]
|
||||
[InlineData(300)]
|
||||
public void A_wait_outside_5_to_299_seconds_is_refused(int seconds)
|
||||
{
|
||||
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { FallbackWaitSeconds = seconds } };
|
||||
|
||||
Assert.Contains("FallbackWaitSeconds", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 3600)]
|
||||
[InlineData(3, 0)]
|
||||
public void A_fallback_budget_that_cannot_be_spent_is_refused(int permits, int windowSeconds)
|
||||
{
|
||||
var options = new RegistrationOptions { Limits = new RegistrationLimitsOptions { FallbackPermits = permits, FallbackWindow = TimeSpan.FromSeconds(windowSeconds) } };
|
||||
|
||||
Assert.Contains("FallbackPermits", Assert.Throws<InvalidOperationException>(() => Validate(options)).Message);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationValidationBudgetFactory : RegistrationFormFactory
|
||||
{
|
||||
protected override string FallbackPermits => "2";
|
||||
}
|
||||
|
||||
public sealed class RegistrationValidationBudgetTests(RegistrationValidationBudgetFactory factory) : IClassFixture<RegistrationValidationBudgetFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Identity_refusals_spend_neither_a_fallback_permit_nor_the_challenge()
|
||||
{
|
||||
using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var first = await FormKit.PageAsync(client);
|
||||
var second = await FormKit.PageAsync(client);
|
||||
var third = await FormKit.PageAsync(client);
|
||||
factory.Time.Advance(TimeSpan.FromSeconds(31));
|
||||
var taken = $"vb-{Guid.NewGuid():N}";
|
||||
|
||||
using (var one = await FormKit.PostAsync(client, FormKit.Fields(first, taken, waited: true)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.SeeOther, one.StatusCode);
|
||||
}
|
||||
|
||||
foreach (var bad in new[] { taken, "support", "a!" })
|
||||
{
|
||||
using var refused = await FormKit.PostAsync(client, FormKit.Fields(second, bad, waited: true));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, refused.StatusCode);
|
||||
}
|
||||
|
||||
using (var fresh = await FormKit.PostAsync(client, FormKit.Fields(second, $"vb-{Guid.NewGuid():N}", waited: true)))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.SeeOther, fresh.StatusCode);
|
||||
}
|
||||
|
||||
// Two permits, two registrations: the three refusals above cost nothing.
|
||||
using var over = await FormKit.PostAsync(client, FormKit.Fields(third, $"vb-{Guid.NewGuid():N}", waited: true));
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, over.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFormBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task The_general_budget_is_spent_before_the_body_is_read_and_cross_site_posts_spend_nothing()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
const string peer = "203.0.113.150";
|
||||
var fields = new Dictionary<string, string> { ["identity"] = "x", ["padding"] = new string('x', 17 * 1024) };
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var crossSite = await FormKit.PostAsync(client, fields, origin: "https://evil.example", peer: peer);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, crossSite.StatusCode);
|
||||
}
|
||||
|
||||
// Three permits: the over-cap posts spend them (413), the next is 429 whatever its body.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var big = await FormKit.PostAsync(client, fields, peer: peer);
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, big.StatusCode);
|
||||
}
|
||||
|
||||
using var refused = await FormKit.PostAsync(client, fields, peer: peer);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
public sealed class RegistrationGateFactory : TestWebApplicationFactory
|
||||
{
|
||||
public ManualClock Time { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<TimeProvider>();
|
||||
services.AddSingleton<TimeProvider>(Time);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The anonymous register is gated by consent and a proof of work; every refusal must create nothing.
|
||||
public sealed class RegistrationGateTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
|
||||
{
|
||||
private static string NewIdentity() => $"gate-{Guid.NewGuid():N}";
|
||||
|
||||
private HttpClient Client() => factory.CreateClient();
|
||||
|
||||
private (int Accounts, int Receipts) Counts()
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return (db.UserAccounts.Count(), db.ConsentReceipts.Count());
|
||||
}
|
||||
|
||||
private async Task<(string Challenge, string Nonce)> SolvedAsync(HttpClient client) =>
|
||||
await RegistrationKit.SolveAsync(client);
|
||||
|
||||
private async Task AssertRefusedCreatesNothingAsync(HttpClient client, object body, string? expectedFragment = null)
|
||||
{
|
||||
var before = Counts();
|
||||
using var response = await RegistrationKit.PostAsync(client, body, anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
if (expectedFragment is not null)
|
||||
{
|
||||
Assert.Contains(expectedFragment, await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
Assert.Equal(before, Counts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_consent_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce },
|
||||
$"current version {TrainingNotice.Version}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(2)]
|
||||
public async Task A_wrong_notice_version_is_refused_and_creates_nothing(int version)
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_missing_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("garbage")]
|
||||
[InlineData("")]
|
||||
public async Task A_garbage_challenge_is_refused_and_creates_nothing(string challenge)
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce = "1", acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_tampered_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, _) = await SolvedAsync(client);
|
||||
var tampered = challenge[..^2] + (challenge[^2] == 'A' ? 'B' : 'A') + challenge[^1];
|
||||
var nonce = RegistrationKit.Solve(tampered, 4);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge = tampered, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_wrong_nonce_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var challenge = issued.GetProperty("challenge").GetString()!;
|
||||
var difficulty = issued.GetProperty("difficulty").GetInt32();
|
||||
var correct = RegistrationKit.Solve(challenge, difficulty);
|
||||
// The next nonce that does not meet the difficulty.
|
||||
var wrong = Enumerable.Range(0, int.MaxValue)
|
||||
.Select(i => i.ToString())
|
||||
.First(n => n != correct && RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) < difficulty);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce = wrong, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
// Each malformed shape is padded until it meets the difficulty, so only the nonce-format guard can refuse it.
|
||||
[Theory]
|
||||
[InlineData("x", "")]
|
||||
[InlineData("-", "")]
|
||||
[InlineData("", "e")]
|
||||
[InlineData("000000000000000000000", "")]
|
||||
public async Task A_malformed_nonce_is_refused_even_when_its_hash_would_pass(string prefix, string suffix)
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, _) = await SolvedAsync(client);
|
||||
var nonce = Enumerable.Range(0, int.MaxValue)
|
||||
.Select(i => prefix + i + suffix)
|
||||
.First(n => RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + n))) >= 4);
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_expired_challenge_is_refused_and_creates_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_consumed_challenge_cannot_be_replayed()
|
||||
{
|
||||
using var client = Client();
|
||||
var (challenge, nonce) = await SolvedAsync(client);
|
||||
var first = NewIdentity();
|
||||
var body = (string identity) => new { identity, challenge, nonce, acceptedNoticeVersion = TrainingNotice.Version };
|
||||
|
||||
using var accepted = await RegistrationKit.PostAsync(client, body(first), anonymous: true);
|
||||
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
||||
var before = Counts();
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(client, body(NewIdentity()));
|
||||
Assert.Equal(before, Counts());
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.Single(db.UserAccounts.Where(x => x.NormalizedBifrostIdentity == first));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_nonce_solved_for_one_challenge_does_not_pass_another()
|
||||
{
|
||||
using var client = Client();
|
||||
var (_, nonceForFirst) = await SolvedAsync(client);
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var second = issued.GetProperty("challenge").GetString()!;
|
||||
var difficulty = issued.GetProperty("difficulty").GetInt32();
|
||||
// Skip the rare case where the nonce happens to also solve the second challenge.
|
||||
if (RegistrationChallengeService.LeadingZeroBits(
|
||||
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(second + ":" + nonceForFirst))) >= difficulty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AssertRefusedCreatesNothingAsync(
|
||||
client,
|
||||
new { identity = NewIdentity(), challenge = second, nonce = nonceForFirst, acceptedNoticeVersion = TrainingNotice.Version });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Success_stores_the_receipt_with_the_account()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
|
||||
using var response = await RegistrationKit.RegisterAsync(client, identity, anonymous: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
|
||||
var receipt = db.ConsentReceipts.Single(x => x.UserAccountId == account.Id);
|
||||
Assert.Equal(1, receipt.NoticeVersion);
|
||||
Assert.Null(receipt.WithdrawnAtUtc);
|
||||
Assert.InRange((factory.Time.GetUtcNow() - receipt.AcceptedAtUtc).Duration(), TimeSpan.Zero, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_stamps_once_and_touches_only_the_caller()
|
||||
{
|
||||
using var client = Client();
|
||||
var mine = NewIdentity();
|
||||
var theirs = NewIdentity();
|
||||
(await RegistrationKit.RegisterAsync(client, mine, anonymous: true)).Dispose();
|
||||
(await RegistrationKit.RegisterAsync(client, theirs, anonymous: true)).Dispose();
|
||||
|
||||
async Task<JsonElement> WithdrawAsync()
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Bifrost-Identity", mine);
|
||||
using var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
return await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
}
|
||||
|
||||
var first = await WithdrawAsync();
|
||||
factory.Time.Advance(TimeSpan.FromHours(1));
|
||||
var second = await WithdrawAsync();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var myReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == mine);
|
||||
var theirReceipt = db.ConsentReceipts.Single(x => x.UserAccount!.NormalizedBifrostIdentity == theirs);
|
||||
Assert.NotNull(myReceipt.WithdrawnAtUtc);
|
||||
Assert.Equal(myReceipt.WithdrawnAtUtc, first.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
|
||||
Assert.Equal(first.GetProperty("withdrawnAtUtc").GetDateTimeOffset(), second.GetProperty("withdrawnAtUtc").GetDateTimeOffset());
|
||||
Assert.Null(theirReceipt.WithdrawnAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Anonymous_withdraw_is_refused_and_changes_nothing()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
(await RegistrationKit.RegisterAsync(client, identity, anonymous: true)).Dispose();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
Assert.DoesNotContain(db.ConsentReceipts, x => x.WithdrawnAtUtc != null && x.UserAccount!.NormalizedBifrostIdentity == identity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_for_an_account_with_no_receipt_is_404()
|
||||
{
|
||||
using var client = Client();
|
||||
var identity = NewIdentity();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
db.UserAccounts.Add(new Bifrost.Web.Domain.UserAccount
|
||||
{
|
||||
BifrostIdentity = identity,
|
||||
NormalizedBifrostIdentity = identity,
|
||||
DisplayName = identity
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/withdraw");
|
||||
request.Headers.Add("X-Test-Bifrost-Identity", identity);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_notice_endpoint_names_the_version_and_the_discord_exclusion()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
var notice = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/notice");
|
||||
|
||||
Assert.Equal(1, notice.GetProperty("version").GetInt32());
|
||||
Assert.Contains("Discord", notice.GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_challenge_response_is_not_cached()
|
||||
{
|
||||
using var client = Client();
|
||||
|
||||
using var response = await client.GetAsync("/auth/bifrost/challenge");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("no-store", response.Headers.CacheControl?.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(18, true)]
|
||||
[InlineData(24, true)]
|
||||
[InlineData(25, false)]
|
||||
public void Difficulty_outside_one_to_twenty_four_is_refused_at_startup(int difficulty, bool valid)
|
||||
{
|
||||
var options = new RegistrationOptions { Captcha = new RegistrationCaptchaOptions { Difficulty = difficulty } };
|
||||
Action validate = () => new StartupConfigurationValidator(
|
||||
new ConfigurationBuilder().Build(),
|
||||
new TestingEnvironment(),
|
||||
Options.Create(new GitHubOAuthOptions()),
|
||||
Options.Create(new GitHubAppOptions()),
|
||||
Options.Create(new HeimdallOptions()),
|
||||
Options.Create(new StripeOptions()),
|
||||
Options.Create(new BifrostHostOptions()),
|
||||
Options.Create(new ConceptVoteOptions()),
|
||||
Options.Create(new DeskOptions()),
|
||||
Options.Create(options)).Validate();
|
||||
|
||||
if (valid)
|
||||
{
|
||||
validate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("Difficulty", Assert.Throws<InvalidOperationException>(validate).Message);
|
||||
}
|
||||
|
||||
Assert.Equal(18, new RegistrationOptions().Captcha.Difficulty);
|
||||
}
|
||||
|
||||
private sealed class TestingEnvironment : IHostEnvironment
|
||||
{
|
||||
public string EnvironmentName { get; set; } = "Testing";
|
||||
public string ApplicationName { get; set; } = "Bifrost.Web";
|
||||
public string ContentRootPath { get; set; } = string.Empty;
|
||||
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// The challenge service on its own: single use under contention, the bounded set, the purpose, and the difficulty it issued.
|
||||
public sealed class RegistrationChallengeServiceTests
|
||||
{
|
||||
private sealed class MutableOptions(int difficulty) : IOptions<RegistrationOptions>
|
||||
{
|
||||
public RegistrationOptions Value { get; set; } = With(difficulty);
|
||||
public static RegistrationOptions With(int d) => new() { Captcha = new RegistrationCaptchaOptions { Difficulty = d } };
|
||||
}
|
||||
|
||||
private static RegistrationChallengeService NewService(IDataProtectionProvider provider, IOptions<RegistrationOptions> options, TimeProvider clock) =>
|
||||
new(provider, options, clock);
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_consumes_of_one_challenge_yield_exactly_one_success()
|
||||
{
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), new ManualClock());
|
||||
for (var round = 0; round < 300; round++)
|
||||
{
|
||||
var challenge = service.Issue()!;
|
||||
var nonce = RegistrationKit.Solve(challenge.Challenge, challenge.Difficulty);
|
||||
using var start = new ManualResetEventSlim();
|
||||
|
||||
var tasks = Enumerable.Range(0, 16).Select(_ => Task.Factory.StartNew(
|
||||
() => { start.Wait(); return service.TryConsume(challenge.Challenge, nonce); },
|
||||
TaskCreationOptions.LongRunning)).ToArray();
|
||||
start.Set();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Equal(1, results.Count(x => x));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_full_consumed_set_refuses_to_issue_and_to_consume_until_entries_expire()
|
||||
{
|
||||
var clock = new ManualClock();
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), new MutableOptions(1), clock);
|
||||
var pending = new List<(string, string)>();
|
||||
for (var i = 0; i < RegistrationChallengeService.MaxConsumed + 1; i++)
|
||||
{
|
||||
var c = service.Issue()!;
|
||||
pending.Add((c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
|
||||
}
|
||||
|
||||
foreach (var (c, n) in pending.Take(RegistrationChallengeService.MaxConsumed))
|
||||
{
|
||||
Assert.True(service.TryConsume(c, n));
|
||||
}
|
||||
|
||||
Assert.Null(service.Issue());
|
||||
Assert.False(service.TryConsume(pending[^1].Item1, pending[^1].Item2));
|
||||
|
||||
clock.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
Assert.NotNull(service.Issue());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_token_protected_for_another_purpose_is_refused()
|
||||
{
|
||||
var provider = new EphemeralDataProtectionProvider();
|
||||
var clock = new ManualClock();
|
||||
var service = NewService(provider, new MutableOptions(1), clock);
|
||||
var expiry = (clock.GetUtcNow() + TimeSpan.FromMinutes(1)).ToUnixTimeMilliseconds();
|
||||
var forged = provider.CreateProtector("Bifrost.Something.Else.v1").Protect($"{Guid.NewGuid():N}.{expiry}.1");
|
||||
var nonce = RegistrationKit.Solve(forged, 1);
|
||||
|
||||
Assert.False(service.TryConsume(forged, nonce));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_challenge_verifies_at_the_difficulty_it_was_issued_with()
|
||||
{
|
||||
var options = new MutableOptions(1);
|
||||
var service = NewService(new EphemeralDataProtectionProvider(), options, new ManualClock());
|
||||
var easy = service.Issue()!;
|
||||
var easyNonce = RegistrationKit.Solve(easy.Challenge, 1);
|
||||
// A nonce that meets 1 bit but not 12: only the embedded difficulty lets it through after the option rises.
|
||||
var weak = Enumerable.Range(0, int.MaxValue).Select(i => i.ToString()).First(n =>
|
||||
{
|
||||
var bits = RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(easy.Challenge + ":" + n)));
|
||||
return bits >= 1 && bits < 12;
|
||||
});
|
||||
|
||||
options.Value = MutableOptions.With(12);
|
||||
|
||||
Assert.Equal(1, easy.Difficulty);
|
||||
Assert.True(service.TryConsume(easy.Challenge, weak));
|
||||
Assert.Equal(12, service.Issue()!.Difficulty);
|
||||
_ = easyNonce;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationBudgetFactory : TestWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Bifrost:Registration:Limits:PerAddressPermits"] = "3"
|
||||
}));
|
||||
builder.ConfigureServices(services => services.AddTransient<Microsoft.AspNetCore.Hosting.IStartupFilter, PeerFilter>());
|
||||
}
|
||||
|
||||
private sealed class PeerFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
|
||||
{
|
||||
public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next) => app =>
|
||||
{
|
||||
app.Use((context, nextMiddleware) =>
|
||||
{
|
||||
context.Connection.RemoteIpAddress = IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "198.51.100.1");
|
||||
return nextMiddleware();
|
||||
});
|
||||
next(app);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationBudgetTests(RegistrationBudgetFactory factory) : IClassFixture<RegistrationBudgetFactory>
|
||||
{
|
||||
private static async Task<HttpResponseMessage> ChallengeAsync(HttpClient client, string peer)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/challenge");
|
||||
request.Headers.Add("X-Test-Peer", peer);
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_request_past_the_address_budget_gets_429_and_another_address_is_unaffected()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, "203.0.113.5");
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var refused = await ChallengeAsync(client, "203.0.113.5");
|
||||
using var other = await ChallengeAsync(client, "203.0.113.6");
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode);
|
||||
Assert.True(refused.Headers.RetryAfter is not null);
|
||||
Assert.Equal(HttpStatusCode.OK, other.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_IPv6_caller_owns_its_slash_64_and_a_mapped_IPv4_shares_its_IPv4_key()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
string[] sameSlash64 = ["2001:db8:1:2::1", "2001:db8:1:2::2", "2001:db8:1:2:aaaa::3", "2001:db8:1:2:ffff:ffff:ffff:ffff", "2001:db8:1:2::5"];
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
foreach (var peer in sameSlash64)
|
||||
{
|
||||
using var response = await ChallengeAsync(client, peer);
|
||||
statuses.Add(response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
[HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests],
|
||||
statuses);
|
||||
using var otherSlash64 = await ChallengeAsync(client, "2001:db8:1:3::1");
|
||||
Assert.Equal(HttpStatusCode.OK, otherSlash64.StatusCode);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, "203.0.113.50");
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var mapped = await ChallengeAsync(client, "::ffff:203.0.113.50");
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, mapped.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Challenge_and_register_spend_one_budget_and_a_refused_register_creates_nothing()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
const string peer = "203.0.113.7";
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var ok = await ChallengeAsync(client, peer);
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
|
||||
{
|
||||
Content = JsonContent.Create(new { identity = $"budget-{Guid.NewGuid():N}", acceptedNoticeVersion = TrainingNotice.Version })
|
||||
};
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
request.Headers.Add("X-Test-Peer", peer);
|
||||
using var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, response.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
Assert.DoesNotContain(scope.ServiceProvider.GetRequiredService<BifrostDbContext>().UserAccounts, x => x.NormalizedBifrostIdentity!.StartsWith("budget-"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RegistrationFullSetTests(RegistrationGateFactory factory) : IClassFixture<RegistrationGateFactory>
|
||||
{
|
||||
[Fact]
|
||||
public async Task A_full_consumed_set_makes_the_challenge_endpoint_answer_503()
|
||||
{
|
||||
var service = factory.Services.GetRequiredService<RegistrationChallengeService>();
|
||||
for (var i = 0; i < RegistrationChallengeService.MaxConsumed; i++)
|
||||
{
|
||||
var c = service.Issue()!;
|
||||
Assert.True(service.TryConsume(c.Challenge, RegistrationKit.Solve(c.Challenge, c.Difficulty)));
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var response = await client.GetAsync("/auth/bifrost/challenge");
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
factory.Time.Advance(RegistrationChallengeService.Lifetime + TimeSpan.FromSeconds(1));
|
||||
using var after = await client.GetAsync("/auth/bifrost/challenge");
|
||||
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
|
||||
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
|
@ -42,7 +42,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
|
||||
Assert.Empty(db.UserAccounts);
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var account = await service.RegisterNativeAsync(name, null, 1, default);
|
||||
var account = await service.RegisterNativeAsync(name, null, default);
|
||||
|
||||
Assert.Equal(name, account.NormalizedBifrostIdentity);
|
||||
Assert.Single(db.UserAccounts);
|
||||
|
|
@ -112,7 +112,7 @@ public sealed class ReservedIdentityTests
|
|||
{
|
||||
var service = NewService(out var db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, 1, default));
|
||||
var ex = await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(name, null, default));
|
||||
|
||||
Assert.Equal("That Bifrost identity is reserved.", ex.Message);
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
|
@ -147,7 +147,7 @@ public sealed class ReservedIdentityTests
|
|||
public async Task Mixed_case_input_is_refused_by_register_and_suffixed_when_derived(string input)
|
||||
{
|
||||
var service = NewService(out var db);
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, 1, default));
|
||||
await Assert.ThrowsAsync<BifrostIdentityException>(() => service.RegisterNativeAsync(input, null, default));
|
||||
Assert.Empty(db.UserAccounts);
|
||||
|
||||
var account = new UserAccount { DisplayName = input };
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
// Shared plumbing for replaying CultLib's transport vectors through HTTP.
|
||||
public static class DeskReplay
|
||||
{
|
||||
public const string ChatFork = "gamecult-chat:question:slot-mark-consent";
|
||||
|
||||
private static JsonNode BaseCard => DeskVectors.Root["scenarios"]![0]!["card"]!;
|
||||
|
||||
public static ForkCard Card(string forkId, string title = "Slot mark consent")
|
||||
{
|
||||
var card = JsonNode.Parse(BaseCard.ToJsonString())!.AsObject();
|
||||
card["fork_id"] = forkId;
|
||||
card["title"] = title;
|
||||
return card.Deserialize<ForkCard>(DeskJson.Options)!;
|
||||
}
|
||||
|
||||
public static ForkEntry Entry(ForkCard card) => new(
|
||||
"gamecult.fork.entry.v0",
|
||||
card,
|
||||
new ForkRecord("gamecult.fork.record.v0", card.ForkId, card.OptionsHash, null, []));
|
||||
|
||||
// Every fork the vectors say each Mind holds, on that Mind's socket.
|
||||
public static void HoldVectorForks(FakeDeskDoor door)
|
||||
{
|
||||
var forks = DeskVectors.Root["transport"]!["mindRoutes"]!["forks"]!.AsObject();
|
||||
foreach (var (mind, ids) in forks)
|
||||
{
|
||||
foreach (var id in ids!.AsArray())
|
||||
{
|
||||
door.Forks[(DeskWebApplicationFactory.SocketOf(mind), (string)id!)] = Entry(Card((string)id!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] AnswerBody(string forkId, string requestId = "00000001-0000-4000-8000-000000000001")
|
||||
{
|
||||
var body = JsonNode.Parse(DeskVectors.Root["scenarios"]![0]!["steps"]![0]!["body"]!.ToJsonString())!.AsObject();
|
||||
body["fork_id"] = forkId;
|
||||
body["request_id"] = requestId;
|
||||
return DeskVectors.Bytes(body);
|
||||
}
|
||||
|
||||
// The raw segment as it would travel in one path segment: a literal '/' cannot, so it is sent as %2F.
|
||||
public static string SegmentOnWire(string rawMind) => rawMind.Replace("/", "%2F", StringComparison.Ordinal);
|
||||
|
||||
public static string Target(string route, string rawMind, string? forkId) => route switch
|
||||
{
|
||||
"answer" => $"/api/desk/{SegmentOnWire(rawMind)}/answers",
|
||||
"list" => $"/api/desk/{SegmentOnWire(rawMind)}/forks",
|
||||
"read" => $"/api/desk/{SegmentOnWire(rawMind)}/forks/{Uri.EscapeDataString(forkId!)}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(route)),
|
||||
};
|
||||
|
||||
// Seeds the account the vector token stands for and returns the header value (null for no token).
|
||||
public static async Task<string?> TokenAsync(DeskWebApplicationFactory factory, JsonNode? token)
|
||||
{
|
||||
if (token is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var active = (bool)token["active"]!;
|
||||
var subject = active ? DeskWebApplicationFactory.Alice : DeskWebApplicationFactory.Member;
|
||||
await factory.EnsureAccountAsync(subject, active);
|
||||
return factory.Token(subject, (string)token["audience"]!, token["scopes"]!.AsArray().Select(s => (string)s!).ToArray());
|
||||
}
|
||||
|
||||
public static bool IsEdgeRefusal(string code) =>
|
||||
code is "unauthenticated" or "insufficient_scope" or "forbidden" or "body_forbidden_field" or "body_unknown_field" or "body_invalid";
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
// CultLib's fork-card vectors, vendored at commit de8a062ab9d8c510c8b36e634fae8f108a570aff.
|
||||
public static class DeskVectors
|
||||
{
|
||||
public const string SourceCommit = "de8a062ab9d8c510c8b36e634fae8f108a570aff";
|
||||
|
||||
public const string FileSha256 = "a7129eee00cb38c602481b0de7de0182f83122685b69ff441e3123adc3e0f774";
|
||||
|
||||
public static string Path => System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "fork-card-vectors.json");
|
||||
|
||||
public static JsonNode Root { get; } = JsonNode.Parse(File.ReadAllBytes(Path))!;
|
||||
|
||||
public static JsonArray Array(params string[] path)
|
||||
{
|
||||
JsonNode node = Root;
|
||||
foreach (var step in path)
|
||||
{
|
||||
node = node[step]!;
|
||||
}
|
||||
|
||||
return node.AsArray();
|
||||
}
|
||||
|
||||
public static IEnumerable<JsonObject> Rows(params string[] path) => Array(path).Select(row => row!.AsObject());
|
||||
|
||||
// True when both are the same JSON with the same keys in the same order.
|
||||
public static bool SameShape(JsonNode? left, JsonNode? right)
|
||||
{
|
||||
switch (left, right)
|
||||
{
|
||||
case (null, null):
|
||||
return true;
|
||||
case (JsonObject a, JsonObject b):
|
||||
return a.Select(p => p.Key).SequenceEqual(b.Select(p => p.Key)) &&
|
||||
a.All(p => SameShape(p.Value, b[p.Key]));
|
||||
case (JsonArray a, JsonArray b):
|
||||
return a.Count == b.Count && a.Zip(b).All(pair => SameShape(pair.First, pair.Second));
|
||||
case (JsonValue, JsonValue):
|
||||
return JsonNode.DeepEquals(left, right);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The vectors' refusal `error` text is illustrative (Libby): the contract pins the code, the key set, the status
|
||||
// (asserted by the caller) and that the text names the fork id, and its order on a list.
|
||||
public static bool SameRefusal(JsonNode? actual, JsonNode? vector, string forkId, int? order)
|
||||
{
|
||||
if (actual is not JsonObject a || vector is not JsonObject v ||
|
||||
!a.Select(p => p.Key).SequenceEqual(v.Select(p => p.Key)) ||
|
||||
(string?)a["schemaVersion"] != (string?)v["schemaVersion"] || (string?)a["code"] != (string?)v["code"])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var error = (string?)a["error"] ?? string.Empty;
|
||||
return error.Contains(forkId, StringComparison.Ordinal) &&
|
||||
(order is null || error.Contains($"order {order}", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static byte[] Bytes(JsonNode node) => System.Text.Encoding.UTF8.GetBytes(node.ToJsonString());
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
using System.Text;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
public sealed record DeskReply(int Status, string ContentType, byte[] Body, IHeaderDictionary Headers)
|
||||
{
|
||||
public string Text => Encoding.UTF8.GetString(Body);
|
||||
}
|
||||
|
||||
// The app with the Desk section configured and the two ports faked. `Configuration` is the Desk section as
|
||||
// key/value pairs; null leaves the Desk section out, `RegisterPorts` false leaves the ports unregistered.
|
||||
public sealed class DeskWebApplicationFactory : TestWebApplicationFactory
|
||||
{
|
||||
public const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f";
|
||||
public const string Steward = "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a";
|
||||
public const string Member = "11111111-2222-4333-8444-555555555555";
|
||||
public const string Origin = "https://chat.gamecult.org";
|
||||
|
||||
private readonly Dictionary<string, string?>? configuration;
|
||||
private readonly bool registerPorts;
|
||||
|
||||
public DeskWebApplicationFactory(Dictionary<string, string?>? configuration = null, bool configured = true, bool registerPorts = true)
|
||||
{
|
||||
this.configuration = configured ? configuration ?? DefaultConfiguration() : null;
|
||||
this.registerPorts = registerPorts;
|
||||
}
|
||||
|
||||
public FakeDeskDoor Door { get; } = new();
|
||||
|
||||
public FakeDeskCredential Credential { get; } = new();
|
||||
|
||||
|
||||
public static Dictionary<string, string?> DefaultConfiguration()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["Desk:AllowedOrigin"] = Origin,
|
||||
["Desk:RetryAfterSeconds"] = "30",
|
||||
["Desk:Attribution:Operator"] = Alice,
|
||||
["Desk:Attribution:Stewards:0"] = Steward,
|
||||
};
|
||||
var i = 0;
|
||||
foreach (var mind in DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject())
|
||||
{
|
||||
values[$"Desk:Minds:{i}:Id"] = mind.Key;
|
||||
values[$"Desk:Minds:{i}:Socket"] = SocketOf(mind.Key);
|
||||
var deciders = DecidersOf(mind.Key);
|
||||
for (var d = 0; d < deciders.Length; d++)
|
||||
{
|
||||
values[$"Desk:Minds:{i}:Deciders:{d}"] = deciders[d];
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
// A distinct list per Mind (different members, order and length), so a door context that carries another
|
||||
// Mind's list cannot pass for the route Mind's.
|
||||
public static string[] DecidersOf(string mindId) =>
|
||||
mindId == "nibu" ? [Steward, Alice, Member] : [Alice, Steward];
|
||||
|
||||
public static string SocketOf(string mindId) =>
|
||||
"/run/huginn/" + (string)DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]![mindId]!;
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
if (configuration is not null)
|
||||
{
|
||||
foreach (var (key, value) in configuration)
|
||||
{
|
||||
builder.UseSetting(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (registerPorts)
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IDeskCredential>(Credential);
|
||||
services.AddSingleton<IDeskDoor>(Door);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Upserts the account with an Active (or Suspended) membership.
|
||||
public async Task EnsureAccountAsync(string id, bool active)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var guid = Guid.Parse(id);
|
||||
var account = await db.UserAccounts.FindAsync(guid);
|
||||
if (account is null)
|
||||
{
|
||||
account = new UserAccount { Id = guid, GitHubLogin = "desk-" + id[..8], NormalizedGitHubLogin = "desk-" + id[..8], BifrostIdentity = "desk-" + id, NormalizedBifrostIdentity = "desk-" + id };
|
||||
db.UserAccounts.Add(account);
|
||||
db.Memberships.Add(new Domain.Membership { UserAccountId = guid, Status = active ? MembershipStatus.Active : MembershipStatus.Suspended });
|
||||
}
|
||||
else
|
||||
{
|
||||
var membership = db.Memberships.Single(m => m.UserAccountId == guid);
|
||||
membership.Status = active ? MembershipStatus.Active : MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public string Token(string? subject, string? audience, params string[] scopes) =>
|
||||
FakeDeskCredential.Encode(subject, audience, scopes);
|
||||
|
||||
// What routing would see for a raw path. Kestrel refuses a NUL or a malformed escape before routing; the
|
||||
// placeholder keeps the request inside /api/desk so the handler's own judgment is what the test observes.
|
||||
private static PathString RoutedPath(string rawPath)
|
||||
{
|
||||
var schemeEnd = rawPath.IndexOf("://", StringComparison.Ordinal);
|
||||
if (schemeEnd > 0)
|
||||
{
|
||||
var pathStart = rawPath.IndexOf('/', schemeEnd + 3);
|
||||
rawPath = pathStart < 0 ? "/" : rawPath[pathStart..];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return PathString.FromUriComponent(rawPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new PathString("/api/desk/_");
|
||||
}
|
||||
}
|
||||
|
||||
// Sends one request with the RAW target set exactly as given (TestServer's HttpClient sets none). `path` is
|
||||
// what routing sees; production Kestrel would hand over the decoded path the same way.
|
||||
public async Task<DeskReply> SendAsync(
|
||||
string method, string rawTarget, byte[]? body = null, string? token = null, string? origin = null,
|
||||
Action<HttpContext>? configure = null)
|
||||
{
|
||||
var context = await Server.SendAsync(http =>
|
||||
{
|
||||
http.Request.Method = method;
|
||||
http.Request.Scheme = "https";
|
||||
http.Request.Host = new HostString("localhost");
|
||||
var query = rawTarget.IndexOf('?');
|
||||
var rawPath = query >= 0 ? rawTarget[..query] : rawTarget;
|
||||
http.Request.Path = RoutedPath(rawPath);
|
||||
http.Request.QueryString = query >= 0 ? new QueryString(rawTarget[query..]) : QueryString.Empty;
|
||||
http.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget = rawTarget;
|
||||
if (token is not null)
|
||||
{
|
||||
http.Request.Headers[FakeDeskCredential.Header] = token;
|
||||
}
|
||||
|
||||
if (origin is not null)
|
||||
{
|
||||
http.Request.Headers.Origin = origin;
|
||||
}
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
http.Request.Body = new MemoryStream(body);
|
||||
http.Request.ContentLength = body.Length;
|
||||
http.Request.ContentType = "application/json";
|
||||
}
|
||||
|
||||
configure?.Invoke(http);
|
||||
});
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await context.Response.Body.CopyToAsync(buffer);
|
||||
return new DeskReply(context.Response.StatusCode, context.Response.ContentType ?? string.Empty, buffer.ToArray(), context.Response.Headers);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
// A header-driven stand-in for the bearer validation port. X-Test-Desk-Token carries {sub, aud, scopes}; no header
|
||||
// means no valid token. It validates nothing: the desk's own judgments (audience, scope, membership) are the subject.
|
||||
public sealed class FakeDeskCredential : IDeskCredential
|
||||
{
|
||||
public const string Header = "X-Test-Desk-Token";
|
||||
|
||||
public static string Encode(string? subject, string? audience, IEnumerable<string> scopes) =>
|
||||
JsonSerializer.Serialize(new { sub = subject, aud = audience, scopes });
|
||||
|
||||
// The port fails (its key source is down), instead of answering.
|
||||
public bool Throws { get; set; }
|
||||
|
||||
// Runs when a request reaches the port, after the host's own middleware: a test breaks what comes next.
|
||||
public Action<HttpContext>? OnAuthenticate { get; set; }
|
||||
|
||||
public ValueTask<DeskToken?> AuthenticateAsync(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Throws)
|
||||
{
|
||||
throw new InvalidOperationException("the credential port is down");
|
||||
}
|
||||
|
||||
OnAuthenticate?.Invoke(context);
|
||||
if (!context.Request.Headers.TryGetValue(Header, out var raw))
|
||||
{
|
||||
return ValueTask.FromResult<DeskToken?>(null);
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(raw.ToString());
|
||||
var root = document.RootElement;
|
||||
return ValueTask.FromResult<DeskToken?>(new DeskToken(
|
||||
root.GetProperty("sub").GetString(),
|
||||
root.GetProperty("aud").GetString(),
|
||||
root.GetProperty("scopes").EnumerateArray().Select(s => s.GetString()!).ToArray()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
using System.Text.Json.Nodes;
|
||||
using Bifrost.Web.Features.Desk;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
public sealed record DeskDoorCall(string Kind, string Socket, DeskDoorContext Context, byte[]? Body, string? ForkId, DeskListRequest? List = null);
|
||||
|
||||
// The door as a test double: it records every call, holds the forks each socket's Mind holds, and by default stores
|
||||
// an answer to a held fork as Counted. A test may script the outcome or make the door fail before or after storing.
|
||||
public sealed class FakeDeskDoor : IDeskDoor
|
||||
{
|
||||
private readonly object gate = new();
|
||||
|
||||
public List<DeskDoorCall> Calls { get; } = [];
|
||||
|
||||
public List<string> StoredIn { get; } = [];
|
||||
|
||||
public Dictionary<(string Socket, string ForkId), ForkEntry> Forks { get; } = [];
|
||||
|
||||
public Func<string, DeskDoorContext, byte[], DeskAnswerOutcome>? AnswerScript { get; set; }
|
||||
|
||||
// Every call throws (Huginn is down).
|
||||
public bool Down { get; set; }
|
||||
|
||||
// The answer is stored, then the door throws (a timeout after send).
|
||||
public bool ThrowAfterStore { get; set; }
|
||||
|
||||
public bool ThrowOnRead { get; set; }
|
||||
|
||||
public Func<string, string, DeskReadOutcome>? ReadScript { get; set; }
|
||||
|
||||
public Func<string, DeskListRequest, DeskListOutcome>? ListScript { get; set; }
|
||||
|
||||
public Task<DeskListOutcome> ListAsync(
|
||||
string socket, DeskDoorContext context, DeskListRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Calls.Add(new DeskDoorCall("list", socket, context, null, null, request));
|
||||
}
|
||||
|
||||
if (Down)
|
||||
{
|
||||
throw new IOException("huginn is down");
|
||||
}
|
||||
|
||||
return Task.FromResult(ListScript?.Invoke(socket, request)
|
||||
?? new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", request.Mind, "s1", 0, 0, [], null)));
|
||||
}
|
||||
|
||||
public Task<DeskAnswerOutcome> AnswerAsync(
|
||||
string socket, DeskDoorContext context, ReadOnlyMemory<byte> body, CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = body.ToArray();
|
||||
lock (gate)
|
||||
{
|
||||
Calls.Add(new DeskDoorCall("answer", socket, context, bytes, null));
|
||||
}
|
||||
|
||||
if (Down)
|
||||
{
|
||||
throw new IOException("huginn is down");
|
||||
}
|
||||
|
||||
var outcome = AnswerScript?.Invoke(socket, context, bytes) ?? HeldForkOutcome(socket, context, bytes);
|
||||
if (outcome is DeskAnswerOutcome.Answered)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
StoredIn.Add(socket);
|
||||
}
|
||||
}
|
||||
|
||||
if (ThrowAfterStore)
|
||||
{
|
||||
throw new TimeoutException("the door timed out after send");
|
||||
}
|
||||
|
||||
return Task.FromResult(outcome);
|
||||
}
|
||||
|
||||
public Task<DeskReadOutcome> ReadAsync(
|
||||
string socket, DeskDoorContext context, string forkId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Calls.Add(new DeskDoorCall("read", socket, context, null, forkId));
|
||||
}
|
||||
|
||||
if (Down || ThrowOnRead)
|
||||
{
|
||||
throw new IOException("huginn is down");
|
||||
}
|
||||
|
||||
if (ReadScript is not null)
|
||||
{
|
||||
return Task.FromResult(ReadScript(socket, forkId));
|
||||
}
|
||||
|
||||
return Task.FromResult<DeskReadOutcome>(Forks.TryGetValue((socket, forkId), out var entry)
|
||||
? new DeskReadOutcome.Found(entry)
|
||||
: new DeskReadOutcome.NotFound(DeskRefusals.ForkUnknown));
|
||||
}
|
||||
|
||||
private DeskAnswerOutcome HeldForkOutcome(string socket, DeskDoorContext context, byte[] body)
|
||||
{
|
||||
var request = JsonNode.Parse(body)!.AsObject();
|
||||
var forkId = (string)request["fork_id"]!;
|
||||
if (!Forks.ContainsKey((socket, forkId)))
|
||||
{
|
||||
return new DeskAnswerOutcome.NotStored(DeskRefusals.ForkUnknown);
|
||||
}
|
||||
|
||||
var answer = request["answer"]?.AsObject();
|
||||
return new DeskAnswerOutcome.Answered(new AnswerResult(
|
||||
DeskSchemas.AnswerResult,
|
||||
new DeskAnswerDocument(
|
||||
DeskSchemas.DeskAnswer, "n1", forkId, "Counted", null, null, null,
|
||||
(string)request["options_hash"]!, (string?)answer?["choice"], (string?)answer?["note"],
|
||||
(string)request["answered_at"]!, "2026-11-12T19:02:05Z", false,
|
||||
context.Subject, context.Authority, (string)request["request_id"]!),
|
||||
null,
|
||||
"n1"));
|
||||
}
|
||||
}
|
||||
|
|
@ -403,7 +403,7 @@ public sealed class OidcLogoutFactory : OidcWebApplicationFactory
|
|||
{
|
||||
using var client = CreateClient(HostClientOptions());
|
||||
var identity = $"member-{Guid.NewGuid():N}";
|
||||
using (var register = await RegistrationKit.RegisterAsync(client, identity))
|
||||
using (var register = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = identity }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, register.StatusCode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using Bifrost.Web.Features.Shared;
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
|
@ -93,9 +92,7 @@ public sealed class OidcTestKit : IDisposable
|
|||
["Bifrost:Oidc:EncryptionKeyPath"] = EncryptionKeyPath,
|
||||
["Bifrost:Oidc:Clients:Mas:ClientId"] = ClientId,
|
||||
["Bifrost:Oidc:Clients:Mas:ClientSecretPath"] = ClientSecretPath,
|
||||
["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri,
|
||||
// The shared test client has no remote address, so every request is one caller: the budgets are out of the way unless a test sets them.
|
||||
["Bifrost:Oidc:Limits:PerAddressPermits"] = "100000",
|
||||
["Bifrost:Oidc:Clients:Mas:RedirectUri"] = RedirectUri
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -117,11 +114,6 @@ public sealed class OidcTestClock : TimeProvider
|
|||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
// The monotonic reading follows the moved time, so a budget's window can be aged the same way as a parked request.
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public override long GetTimestamp() => _now.UtcTicks;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
|
||||
|
|
@ -179,24 +171,6 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<MembershipStatus?> MembershipStatusAsync(string identity)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
|
||||
return (await db.Memberships.AsNoTracking().SingleOrDefaultAsync(x => x.UserAccountId == account.Id))?.Status;
|
||||
}
|
||||
|
||||
public sealed record RowCounts(int Accounts, int Memberships, int Votes, int RoleAssignments);
|
||||
|
||||
/// <summary>What the issuer must leave alone: accounts, memberships, role grants and votes.</summary>
|
||||
public async Task<RowCounts> RowCountsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return new RowCounts(await db.UserAccounts.CountAsync(), await db.Memberships.CountAsync(), await db.Votes.CountAsync(), await db.RoleAssignments.CountAsync());
|
||||
}
|
||||
|
||||
/// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary>
|
||||
public async Task SetMembershipAsync(string identity, MembershipStatus status)
|
||||
{
|
||||
|
|
@ -257,91 +231,6 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An OIDC host whose actor accessor reports an Active account holding the given Bifrost identity: how a row minted before
|
||||
/// reserved names were refused at creation (or any identity the test needs) reaches the authorize endpoint, since
|
||||
/// registration itself now refuses a reserved name.
|
||||
/// </summary>
|
||||
public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureActors(IServiceCollection services)
|
||||
{
|
||||
services.RemoveAll<ICurrentBifrostActorAccessor>();
|
||||
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(identity, status));
|
||||
}
|
||||
|
||||
private sealed class FixedIdentity(string identity, MembershipStatus status) : ICurrentBifrostActorAccessor
|
||||
{
|
||||
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new CurrentBifrostActor(true, new UserAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
BifrostIdentity = identity,
|
||||
NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case
|
||||
DisplayName = "Fixed Identity",
|
||||
Membership = new Bifrost.Web.Domain.Membership { Status = status }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An OIDC host with extra settings (limits, a brake path) applied after the kit's own.</summary>
|
||||
public sealed class OidcSettingsFactory(IReadOnlyDictionary<string, string?> settings) : OidcWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
base.ConfigureWebHost(builder);
|
||||
foreach (var (key, value) in settings)
|
||||
{
|
||||
builder.UseSetting(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
public Task PruneAsync() => Services.GetRequiredService<Bifrost.Web.Features.Oidc.OidcPruner>().PruneAsync(CancellationToken.None);
|
||||
|
||||
public async Task<List<string?>> TokenTypesAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>();
|
||||
var types = new List<string?>();
|
||||
await foreach (var token in manager.ListAsync())
|
||||
{
|
||||
types.Add(await manager.GetTypeAsync(token));
|
||||
}
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
public async Task<int> AuthorizationRowsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>().CountAsync();
|
||||
}
|
||||
|
||||
/// <summary>Tokens that belong to some authorization (the request and code rows carry none once spent).</summary>
|
||||
public async Task<int> TokensOfAuthorizationsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return await db.Set<OpenIddictEntityFrameworkCoreToken>().CountAsync(token => token.Authorization != null);
|
||||
}
|
||||
|
||||
public async Task RevokeFirstAuthorizationAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var manager = scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictAuthorizationManager>();
|
||||
await foreach (var authorization in manager.ListAsync(count: 1, offset: 0))
|
||||
{
|
||||
Assert.True(await manager.TryRevokeAsync(authorization));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> TokenRowsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
return (int)await scope.ServiceProvider.GetRequiredService<OpenIddict.Abstractions.IOpenIddictTokenManager>().CountAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An OIDC host whose public base URL differs from the origin its requests arrive on, so the issuer cannot be request-derived.</summary>
|
||||
public sealed class OidcForeignIssuerFactory : OidcWebApplicationFactory
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bifrost.Web.Features.Registration;
|
||||
|
||||
namespace Bifrost.Web.Tests.Support;
|
||||
|
||||
/// <summary>Registers through the real gate: fetch a challenge, solve it, post with the notice version.</summary>
|
||||
public static class RegistrationKit
|
||||
{
|
||||
public static async Task<(string Challenge, string Nonce)> SolveAsync(HttpClient client)
|
||||
{
|
||||
var issued = await client.GetFromJsonAsync<JsonElement>("/auth/bifrost/challenge");
|
||||
var challenge = issued.GetProperty("challenge").GetString()!;
|
||||
return (challenge, Solve(challenge, issued.GetProperty("difficulty").GetInt32()));
|
||||
}
|
||||
|
||||
public static string Solve(string challenge, int difficulty)
|
||||
{
|
||||
for (long nonce = 0; ; nonce++)
|
||||
{
|
||||
var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (RegistrationChallengeService.LeadingZeroBits(SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + text))) >= difficulty)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> RegisterAsync(
|
||||
HttpClient client,
|
||||
string identity,
|
||||
string? displayName = null,
|
||||
string? returnUrl = null,
|
||||
bool anonymous = false)
|
||||
{
|
||||
var (challenge, nonce) = await SolveAsync(client);
|
||||
return await PostAsync(client, new
|
||||
{
|
||||
identity,
|
||||
displayName = displayName ?? identity,
|
||||
returnUrl,
|
||||
challenge,
|
||||
nonce,
|
||||
acceptedNoticeVersion = TrainingNotice.Version
|
||||
}, anonymous);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostAsync(HttpClient client, object body, bool anonymous = false)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register")
|
||||
{
|
||||
Content = JsonContent.Create(body)
|
||||
};
|
||||
if (anonymous)
|
||||
{
|
||||
request.Headers.Add("X-Test-Anonymous", "true");
|
||||
}
|
||||
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
}
|
||||
|
|
@ -21,16 +21,6 @@ public sealed class TestAuthHandler(
|
|||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
}
|
||||
|
||||
// A test may sign in as a native account by naming its Bifrost identity; only that claim is sent, so no GitHub claim also matches.
|
||||
if (Request.Headers.TryGetValue("X-Test-Bifrost-Identity", out var nativeIdentity))
|
||||
{
|
||||
var native = new ClaimsIdentity(
|
||||
[new Claim(BifrostClaimTypes.BifrostIdentity, nativeIdentity.ToString())],
|
||||
SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(
|
||||
new AuthenticationTicket(new ClaimsPrincipal(native), SchemeName)));
|
||||
}
|
||||
|
||||
// A test may sign in as another account by naming its GitHub id and login.
|
||||
var gitHubId = Request.Headers["X-Test-GitHub-Id"].FirstOrDefault() ?? "12345";
|
||||
var login = Request.Headers["X-Test-GitHub-Login"].FirstOrDefault() ?? "test-admin";
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
|
|||
["ConceptVotes:CastPermits"] = "100000",
|
||||
["ConceptVotes:MintPermits"] = "100000",
|
||||
["MindIntake:Permits"] = "100000",
|
||||
["Bifrost:Registration:Captcha:Difficulty"] = "4",
|
||||
["Bifrost:Registration:Limits:PerAddressPermits"] = "100000",
|
||||
["Host:PublicBaseUrl"] = "https://localhost",
|
||||
["Host:ExpectedHost"] = "localhost",
|
||||
["Host:RequireStrictHostValidation"] = "false",
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
// The CultLib checkout the tests load at run time, as the tools resolve it:
|
||||
// $VOIDBOT_CULTLIB_ROOT, else the sibling ../CultLib. Its dist/ is gitignored; build it there first.
|
||||
export const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(import.meta.dirname, "..", "..", "CultLib"));
|
||||
|
|
@ -1 +0,0 @@
|
|||
”¥req-3¨DeclinedÀÀ
|
||||
|
|
@ -1 +0,0 @@
|
|||
”¥req-2¨Existing±!dm1:gamecult.org¦$line2
|
||||
|
|
@ -1 +0,0 @@
|
|||
”¥req-1¦Opened±!dm1:gamecult.org¦$line1
|
||||
|
|
@ -1 +0,0 @@
|
|||
”¥req-1¦eureka²@meta:gamecult.orgªhello Meta
|
||||
|
|
@ -1 +0,0 @@
|
|||
’¦eureka«UnknownKind
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
’¦eureka
|
||||
|
|
@ -9,8 +9,7 @@ import { resolve } from "node:path";
|
|||
import { buildFeedbackDefinitions } from "../tools/persona-feedback-documents.mjs";
|
||||
import { conversationFromDiscordMessage, discordGatewayReady } from "../tools/bifrost-discord-persona-ingress.mjs";
|
||||
|
||||
import { cultLibRoot } from "./cultlib-root.mjs";
|
||||
const root=resolve(import.meta.dirname,".."), cult=cultLibRoot;
|
||||
const root=resolve(import.meta.dirname,".."), cult=resolve(root,"..","CultLib");
|
||||
const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json"));
|
||||
const mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json"));
|
||||
const {defineDocumentType}=cr("@gamecult/cultcache-ts"), {CultMesh}=mr("@gamecult/cultmesh-ts"), defs=buildFeedbackDefinitions(defineDocumentType);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ import { resolve } from "node:path";
|
|||
import { enrollBifrostFeedbackIdentity, providerHealthIdentity, signFeedbackAdmission } from "../tools/bifrost-feedback-identity.mjs";
|
||||
import { createPersonaFeedbackHealthPublisher, createSignedHealthRawPut, createSignedPersonaFeedbackHealth, signedHealthSourceRole } from "../tools/persona-feedback-idunn-health.mjs";
|
||||
|
||||
import { cultLibRoot } from "./cultlib-root.mjs";
|
||||
const root=resolve(import.meta.dirname,".."),cult=cultLibRoot,require=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),{encode,decode}=require("@msgpack/msgpack");
|
||||
const root=resolve(import.meta.dirname,".."),cult=resolve(root,"..","CultLib"),require=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),{encode,decode}=require("@msgpack/msgpack");
|
||||
const idDomain=Buffer.from("gamecult.provider-health.identity.v1\0"),signatureDomain=Buffer.from("gamecult.provider-health.signature.v1\0"),purpose=Buffer.from("idunn.signed_daemon_health.v1");
|
||||
|
||||
test("Persona-feedback emits the exact canonical signed daemon-health tuple",async()=>{
|
||||
|
|
|
|||
|
|
@ -1,344 +0,0 @@
|
|||
// A Persona asks her mouth for a direct room with one person. The homeserver here keeps room state the way Synapse does
|
||||
// (create event with its content, one member event per account, encryption event when set), so the mouth's lookup,
|
||||
// the consent that lives in the person's own membership, and the order of its writes are exercised, not mocked.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { createMouth } from "../tools/persona-matrix-mouth.mjs";
|
||||
|
||||
const HS = "gamecult.org";
|
||||
const ME = `@eureka:${HS}`;
|
||||
const META = `@meta:${HS}`;
|
||||
const PROJECT = "!project:gamecult.org";
|
||||
const ALIAS = `#swarm:${HS}`;
|
||||
|
||||
async function homeserver({ people = [META, `@nova:${HS}`], now = () => 1000, failInvite: initialFailInvite = false } = {}) {
|
||||
const config = { failInvite: initialFailInvite };
|
||||
const rooms = new Map(); // room id -> { events }
|
||||
const stateReads = [];
|
||||
const log = []; // every write, in order, as "METHOD path"
|
||||
let counter = 0;
|
||||
const direct = { value: null };
|
||||
const member = (room, user, membership) => {
|
||||
const events = rooms.get(room).events.filter(e => !(e.type === "m.room.member" && e.state_key === user));
|
||||
events.push({ type: "m.room.member", state_key: user, origin_server_ts: now(), content: { membership } });
|
||||
rooms.get(room).events = events;
|
||||
};
|
||||
const server = createServer((req, res) => {
|
||||
let raw = "";
|
||||
req.on("data", c => (raw += c));
|
||||
req.on("end", () => {
|
||||
const url = new URL(req.url, "http://x");
|
||||
const path = decodeURIComponent(url.pathname).replace("/_matrix/client/v3", "");
|
||||
const body = raw ? JSON.parse(raw) : null;
|
||||
const reply = (status, json) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(json)); };
|
||||
const write = () => { if (!path.startsWith("/presence/") && !path.includes("/account_data/")) log.push(`${req.method} ${path}`); };
|
||||
if (path === "/account/whoami") return reply(200, { user_id: ME });
|
||||
if (path === "/joined_rooms") return reply(200, { joined_rooms: [PROJECT, ...[...rooms].filter(([, r]) => r.events.some(e => e.type === "m.room.member" && e.state_key === ME && e.content.membership === "join")).map(([id]) => id)] });
|
||||
if (path.includes("/account_data/m.direct")) {
|
||||
if (req.method === "PUT") { direct.value = body; return reply(200, {}); }
|
||||
return direct.value ? reply(200, direct.value) : reply(404, { errcode: "M_NOT_FOUND" });
|
||||
}
|
||||
if (path.startsWith("/directory/room/")) return reply(200, { room_id: PROJECT });
|
||||
if (path.startsWith("/profile/")) return people.includes(path.slice("/profile/".length)) ? reply(200, { displayname: "x" }) : reply(404, { errcode: "M_NOT_FOUND" });
|
||||
const state = path.match(/^\/rooms\/(.+)\/state$/);
|
||||
if (state) stateReads.push(state[1]);
|
||||
if (state) return rooms.has(state[1]) ? reply(200, rooms.get(state[1]).events) : reply(403, {});
|
||||
if (req.method === "POST" && path === "/createRoom") {
|
||||
write();
|
||||
const id = `!dm${++counter}:${HS}`;
|
||||
rooms.set(id, { events: [{ type: "m.room.create", state_key: "", origin_server_ts: now() + counter, content: { creator: ME, ...body.creation_content } }] });
|
||||
if (body.preset !== "private_chat") return reply(400, {});
|
||||
member(id, ME, "join");
|
||||
rooms.get(id).body = body;
|
||||
if (body.initial_state?.some(e => e.type === "m.room.encryption")) rooms.get(id).events.push({ type: "m.room.encryption", state_key: "", content: {} });
|
||||
return reply(200, { room_id: id });
|
||||
}
|
||||
const invite = path.match(/^\/rooms\/(.+)\/invite$/);
|
||||
if (invite) { write(); if (config.failInvite) return reply(403, { errcode: "M_FORBIDDEN" }); member(invite[1], body.user_id, "invite"); rooms.get(invite[1]).invited = body; return reply(200, {}); }
|
||||
const put = path.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/);
|
||||
if (put) {
|
||||
write();
|
||||
const r = rooms.get(put[1]);
|
||||
r.sent ??= new Map();
|
||||
if (!r.sent.has(put[2])) r.sent.set(put[2], { id: `$e${counter}-${r.sent.size + 1}`, body });
|
||||
return reply(200, { event_id: r.sent.get(put[2]).id });
|
||||
}
|
||||
reply(404, { errcode: "M_NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
await new Promise(ok => server.listen(0, "127.0.0.1", ok));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}`, rooms, log, direct, stateReads,
|
||||
set failInvite(v) { config.failInvite = v; },
|
||||
seedDm(id, user, membership, { encrypted = false, created = 5, marker } = {}) {
|
||||
rooms.set(id, { events: [{ type: "m.room.create", state_key: "", origin_server_ts: created, content: { creator: ME, ...(marker ? { "org.gamecult.open_request": marker } : {}) } }] });
|
||||
member(id, ME, "join"); member(id, user, membership);
|
||||
if (encrypted) rooms.get(id).events.push({ type: "m.room.encryption", state_key: "", content: {} });
|
||||
},
|
||||
set: member,
|
||||
writes: pattern => log.filter(l => pattern.test(l)),
|
||||
close: () => { server.closeAllConnections(); return new Promise(ok => server.close(ok)); },
|
||||
};
|
||||
}
|
||||
|
||||
const memoryState = () => { const box = { value: null }; return { load: async () => box.value, save: async v => { box.value = v; } }; };
|
||||
const mouthFor = (hs, extra = {}) => createMouth({ persona: "eureka", homeserver: hs.url, serverName: HS, rooms: [ALIAS], token: "tok", state: memoryState(), brake: async () => true, syncTimeoutMs: 1, ...extra });
|
||||
const ask = (m, requestId, user = META, text = "hello Meta, may I ask you something?", persona = "eureka") => m.openDirect({ requestId, persona, userId: user, text });
|
||||
const outcome = r => r.outcome;
|
||||
|
||||
test("a new person: creates an unencrypted private direct room, invites with the line as the reason, says the line with the not-E2EE notice, and answers Opened", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs);
|
||||
const r = await ask(m, "req-1");
|
||||
assert.equal(r.outcome, "Opened");
|
||||
const [roomId] = [...hs.rooms.keys()];
|
||||
assert.equal(r.roomId, roomId);
|
||||
const room = hs.rooms.get(roomId);
|
||||
assert.equal(room.body.preset, "private_chat");
|
||||
assert.equal(room.body.is_direct, true);
|
||||
assert.equal(room.body.creation_content["m.federate"], false);
|
||||
assert.equal(room.body.creation_content["org.gamecult.open_request"], "req-1");
|
||||
assert.ok(!JSON.stringify(room.body).includes("m.room.encryption"), "the room is created without encryption");
|
||||
assert.ok(!room.events.some(e => e.type === "m.room.encryption"));
|
||||
assert.deepEqual(room.invited, { user_id: META, reason: "hello Meta, may I ask you something?" });
|
||||
const line = room.sent.get("req-1");
|
||||
assert.equal(r.eventId, line.id);
|
||||
assert.match(line.body.body, /not end-to-end encrypted/, "the first message says so");
|
||||
assert.ok(line.body.body.endsWith("hello Meta, may I ask you something?"));
|
||||
assert.deepEqual(hs.direct.value, { [META]: [roomId] }, "the room id is stored in m.direct");
|
||||
assert.deepEqual(hs.log.map(l => l.split(" ")[0] + " " + l.split("/").pop()), ["POST createRoom", "POST invite", "PUT req-1"], "order of writes");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("the invite reason is the line cut to 300 bytes", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
await ask(mouthFor(hs), "req-1", META, "x".repeat(900));
|
||||
assert.equal(Buffer.byteLength([...hs.rooms.values()][0].invited.reason), 300);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("only her own request is served, and only for a person: mismatch, empty text, a foreign server, herself, another Mind, an unknown account", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const nova = `@nova:${HS}`;
|
||||
const m = mouthFor(hs, { isServed: id => id === nova });
|
||||
assert.equal(outcome(await ask(m, "r1", META, "hi", "nova")), "PersonaMismatch");
|
||||
assert.equal(outcome(await ask(m, "r2", META, " ")), "EmptyText");
|
||||
assert.equal(outcome(await ask(m, "r3", "@meta:matrix.org")), "ForeignUser");
|
||||
assert.equal(outcome(await ask(m, "r3b", "@meta:gamecult.org.evil.example")), "ForeignUser");
|
||||
assert.equal(outcome(await ask(m, "r4", ME)), "NotAPerson");
|
||||
assert.equal(outcome(await ask(m, "r5", nova)), "NotAPerson");
|
||||
assert.equal(outcome(await ask(m, "r6", `@nobody:${HS}`)), "NoSuchUser");
|
||||
assert.deepEqual(hs.log, [], "no write for any refusal");
|
||||
assert.equal(hs.rooms.size, 0);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("a held brake refuses before any Matrix call", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs, { brake: async () => false });
|
||||
assert.equal(outcome(await ask(m, "r1")), "Brake");
|
||||
assert.deepEqual(hs.log, []);
|
||||
assert.equal(hs.rooms.size, 0);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("the brake is read fresh before createRoom, before the invite and before the line: flipping it at any point stops every later write", async () => {
|
||||
for (let free = 1; free <= 5; free++) {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
let reads = 0;
|
||||
const events = [];
|
||||
const m = mouthFor(hs, { brake: async () => { const ok = ++reads <= free; events.push(ok ? "free" : "held"); return ok; } });
|
||||
const originalLog = hs.log;
|
||||
const push = originalLog.push.bind(originalLog);
|
||||
originalLog.push = (...x) => { events.push(`write ${x[0]}`); return push(...x); };
|
||||
await ask(m, "r1");
|
||||
let held = false;
|
||||
for (const e of events) {
|
||||
if (e === "held") held = true;
|
||||
else if (held && e.startsWith("write")) assert.fail(`free=${free}: a write after the brake was held: ${e}`);
|
||||
}
|
||||
const writes = events.filter(e => e.startsWith("write"));
|
||||
for (let i = 0; i < events.length; i++) if (events[i].startsWith("write")) assert.equal(events[i - 1], "free", `free=${free}: ${events[i]} was not immediately preceded by a released brake read`);
|
||||
assert.equal(writes.length, Math.min(3, free - 1), `free=${free}: reads 2, 3 and 4 each gate exactly the createRoom, the invite and the line`);
|
||||
} finally { await hs.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("a second request for a person who accepted says the line in the same room and answers Existing, with no new room", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs);
|
||||
const first = await ask(m, "r1");
|
||||
hs.set(first.roomId, META, "join"); // Meta accepts
|
||||
const again = await ask(m, "r2", META, "second line");
|
||||
assert.deepEqual([again.outcome, again.roomId], ["Existing", first.roomId]);
|
||||
assert.equal(hs.rooms.size, 1);
|
||||
const sent = hs.rooms.get(first.roomId).sent.get("r2");
|
||||
assert.equal(again.eventId, sent.id);
|
||||
assert.equal(sent.body.body, "second line", "no notice again after the first message");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("a repeat of the same request id after a crash resumes in the room it made: one room, one line, Opened again", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const first = await ask(mouthFor(hs), "r1");
|
||||
const again = await ask(mouthFor(hs), "r1"); // a restarted process: no memory of the first
|
||||
assert.deepEqual([again.outcome, again.roomId, again.eventId], ["Opened", first.roomId, first.eventId]);
|
||||
assert.equal(hs.rooms.size, 1);
|
||||
assert.equal(hs.writes(/createRoom/).length, 1);
|
||||
assert.equal(hs.writes(/\/invite/).length, 1);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("an invite the person has not answered is InvitePending: nothing new is written", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs);
|
||||
await ask(m, "r1");
|
||||
const before = hs.log.length;
|
||||
const again = await ask(m, "r2");
|
||||
assert.equal(again.outcome, "InvitePending");
|
||||
assert.equal(hs.log.length, before);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("a decline stands: leaving or banning is Declined, for a restarted mouth too, until the person invites her", async () => {
|
||||
for (const gone of ["leave", "ban"]) {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const first = await ask(mouthFor(hs), "r1");
|
||||
hs.set(first.roomId, META, gone);
|
||||
const before = hs.log.length;
|
||||
for (const m of [mouthFor(hs), mouthFor(hs)]) {
|
||||
assert.equal(outcome(await ask(m, `again-${Math.random()}`)), "Declined", gone);
|
||||
}
|
||||
assert.equal(hs.log.length, before, "no room, invite or line");
|
||||
// the person writes first: she is invited into a new room and joins; now there is a live DM
|
||||
hs.seedDm("!theirs:gamecult.org", META, "join", { created: 99999 });
|
||||
const answer = await ask(mouthFor(hs), "r-after");
|
||||
assert.deepEqual([answer.outcome, answer.roomId], ["Existing", "!theirs:gamecult.org"]);
|
||||
} finally { await hs.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("an invite the homeserver refuses leaves the room made; the retry invites into that same room instead of making another", async () => {
|
||||
const hs = await homeserver({ failInvite: true });
|
||||
try {
|
||||
assert.equal(outcome(await ask(mouthFor(hs), "r1")), "SendFailed");
|
||||
assert.equal(hs.rooms.size, 1);
|
||||
hs.failInvite = false;
|
||||
const retry = await ask(mouthFor(hs), "r2");
|
||||
assert.equal(retry.outcome, "Opened");
|
||||
assert.equal(hs.rooms.size, 1, "no second room");
|
||||
assert.equal(retry.roomId, [...hs.rooms.keys()][0]);
|
||||
assert.equal(hs.rooms.get(retry.roomId).invited.user_id, META);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("one creation per person per day: a second creation the same UTC day is DailyLimit, the next UTC day it is allowed", async () => {
|
||||
let t = Date.parse("2026-11-10T10:00:00Z");
|
||||
const hs = await homeserver({ now: () => t });
|
||||
try {
|
||||
const m = mouthFor(hs, { now: () => t });
|
||||
assert.equal(outcome(await ask(m, "r1")), "Opened");
|
||||
hs.rooms.clear(); // she left the room: no DM with them is found now, only the day's record stands
|
||||
assert.equal(outcome(await ask(m, "r2")), "DailyLimit");
|
||||
assert.equal(hs.writes(/createRoom/).length, 1);
|
||||
t += 24 * 3600 * 1000;
|
||||
assert.equal(outcome(await ask(m, "r3")), "Opened");
|
||||
assert.equal(outcome(await ask(m, "r4", `@nova:${HS}`)), "Opened", "the limit is per person");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("an encrypted direct room is not used: the line is never sent into it and a new unencrypted room is made", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
hs.seedDm("!old:gamecult.org", META, "join", { encrypted: true });
|
||||
const r = await ask(mouthFor(hs), "r1");
|
||||
assert.equal(r.outcome, "Opened");
|
||||
assert.notEqual(r.roomId, "!old:gamecult.org");
|
||||
assert.equal(hs.rooms.get("!old:gamecult.org").sent, undefined);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("a homeserver failure answers SendFailed and writes nothing the brake or a refusal should have stopped", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs, { homeserver: "http://127.0.0.1:9" });
|
||||
assert.equal(outcome(await ask(m, "r1")), "SendFailed");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("two requests for one person at once make one room: opens for a person run one at a time", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const m = mouthFor(hs);
|
||||
const answers = await Promise.all([ask(m, "a"), ask(m, "b")]);
|
||||
assert.equal(hs.writes(/createRoom/).length, 1);
|
||||
assert.deepEqual(answers.map(outcome).sort(), ["InvitePending", "Opened"]);
|
||||
const other = await Promise.all([ask(m, "c", `@nova:${HS}`), ask(m, "d", META)]);
|
||||
assert.equal(other[0].outcome, "Opened", "another person is not held behind");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("a decline stands in an encrypted room too, and a pending invite there is still pending: no new room, no line", async () => {
|
||||
for (const [membership, expected] of [["leave", "Declined"], ["invite", "InvitePending"]]) {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
hs.seedDm("!enc:gamecult.org", META, membership, { encrypted: true });
|
||||
assert.equal(outcome(await ask(mouthFor(hs), "r1")), expected);
|
||||
assert.deepEqual(hs.log, []);
|
||||
} finally { await hs.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("the not-E2EE notice reaches the person even when the first line was held back: a later line into the room this path made carries it once", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
let reads = 0;
|
||||
const m = mouthFor(hs, { brake: async () => ++reads <= 3 }); // top, createRoom, invite: then the brake is held before the line
|
||||
assert.equal(outcome(await ask(m, "r1")), "Brake");
|
||||
const roomId = [...hs.rooms.keys()][0];
|
||||
assert.equal(hs.rooms.get(roomId).sent, undefined, "the invite went out, the line did not");
|
||||
hs.set(roomId, META, "join");
|
||||
const answer = await ask(mouthFor(hs), "r2", META, "second");
|
||||
assert.deepEqual([answer.outcome, answer.roomId], ["Existing", roomId]);
|
||||
assert.match(hs.rooms.get(roomId).sent.get("r2").body.body, /^This room is not end-to-end encrypted.*\n\nsecond$/s);
|
||||
await ask(mouthFor(hs), "r3", META, "third"); // another process: the notice is repeated at worst once, never lost
|
||||
const sameProcess = mouthFor(hs);
|
||||
await ask(sameProcess, "r4", META, "fourth");
|
||||
await ask(sameProcess, "r5", META, "fifth");
|
||||
assert.equal(hs.rooms.get(roomId).sent.get("r5").body.body, "fifth", "once the notice went out in this process it is not repeated");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("the lookup finds a DM only: a group room with the person is not one, and project rooms are never scanned", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
hs.seedDm("!group:gamecult.org", META, "join");
|
||||
hs.set("!group:gamecult.org", `@third:${HS}`, "join"); // three members
|
||||
const r = await ask(mouthFor(hs), "r1");
|
||||
assert.equal(r.outcome, "Opened");
|
||||
assert.notEqual(r.roomId, "!group:gamecult.org");
|
||||
assert.equal(hs.rooms.get("!group:gamecult.org").sent, undefined);
|
||||
assert.ok(!hs.stateReads.includes("!project:gamecult.org"), "a configured project room is not read as a DM candidate");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("another Mind this process serves is not a person, even before that Mind's own mouth has started", async () => {
|
||||
const hs = await homeserver();
|
||||
try {
|
||||
const nova = `@nova:${HS}`;
|
||||
let asked = null;
|
||||
const m = mouthFor(hs, { isServed: async id => { asked = id; return id === nova; } });
|
||||
assert.equal(outcome(await ask(m, "r1", nova)), "NotAPerson");
|
||||
assert.equal(asked, nova);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
// The parts of the Matrix mouth that sit on CultLib: the chat_* wire documents, the
|
||||
// CultCache cursor and brake stores, and the CultNet RUDP endpoint. Needs the built
|
||||
// CultLib TypeScript packages (VOIDBOT_CULTLIB_ROOT, else ../CultLib), like the other Bifrost tests. CultLib's dist/ is gitignored:
|
||||
// CultLib TypeScript packages (CULTLIB_ROOT, else ../CultLib), like the other Bifrost tests. CultLib's dist/ is gitignored:
|
||||
// run `npm ci && npm run build:ts` there first. Since CultLib 286a9af the packages are @gamecult/cultcache-ts,
|
||||
// @gamecult/cultnet-ts and @gamecult/cultmesh-ts; the directories under packages/ keep their short names.
|
||||
import test from "node:test";
|
||||
import { cultLibRoot } from "./cultlib-root.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import dgram from "node:dgram";
|
||||
import { createServer } from "node:http";
|
||||
|
|
@ -16,11 +15,10 @@ import * as docs from "../tools/persona-chat-documents.mjs";
|
|||
import { createMouth, cultcacheState, connectionIdFor, createBrakeReader, loadRuntime, pageOf, startChatEndpoint } from "../tools/persona-matrix-mouth.mjs";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const runtime = loadRuntime(cultLibRoot);
|
||||
const runtime = loadRuntime(resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib")));
|
||||
const { msgpack } = runtime;
|
||||
const fixtureDir = resolve(import.meta.dirname, "fixtures", "persona-chat-v1");
|
||||
|
||||
const tailLine = { roomAlias: "#operator:gamecult.org", eventId: "$t1", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" };
|
||||
const delivery = {
|
||||
seq: 7, roomAlias: "#operator:gamecult.org", roomId: "!abc:gamecult.org", eventId: "$e1", sender: "@meta:gamecult.org", senderDisplay: "Meta",
|
||||
text: "hello eureka", atUnixMs: 1761700000000,
|
||||
|
|
@ -94,7 +92,6 @@ test("the brake reader reads a real CultCache control store", async () => {
|
|||
test("two clients served at once each get their own answer on their own session", async () => {
|
||||
const mouth = {
|
||||
pendingAfter: () => [],
|
||||
tail: async () => ({ lines: [], gaps: [], rooms: [] }),
|
||||
ack: async () => {},
|
||||
// Slow enough that the two requests are in flight together.
|
||||
send: async request => { await new Promise(r => setTimeout(r, 150)); return { requestId: request.requestId, eventId: `$${request.requestId}`, refusal: null }; },
|
||||
|
|
@ -140,8 +137,6 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
|
|||
const calls = { acks: [], sends: [], reads: [] };
|
||||
const mouth = {
|
||||
pendingAfter: after => (after < 7 ? [delivery] : []),
|
||||
tail: async () => { if (calls.tailFails) throw new Error("room read failed"); return { lines: [tailLine], gaps: ["#operator:gamecult.org"], rooms: ["#operator:gamecult.org", "#swarm:gamecult.org"] }; },
|
||||
speakableRooms: () => ["#operator:gamecult.org", "#swarm:gamecult.org"],
|
||||
ack: async through => { calls.acks.push(through); },
|
||||
read: async request => { calls.reads.push(request); return null; },
|
||||
send: async request => { calls.sends.push(request); return request.text === "muted" ? { requestId: request.requestId, eventId: null, refusal: "Brake" } : { requestId: request.requestId, eventId: "$sent", refusal: null }; },
|
||||
|
|
@ -176,26 +171,6 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a
|
|||
const pending = docs.parsePending(msgpack.decode(document.payload));
|
||||
assert.deepEqual(pending.deliveries.map(d => d.eventId), ["$e1"]);
|
||||
assert.deepEqual(pending.deliveries[0], delivery, "sender, mentions and the DM flag cross the wire exactly");
|
||||
assert.deepEqual(Buffer.from(document.payload), await readFile(resolve(root, "tests", "fixtures", "persona-chat-v3", "gamecult.persona.chat_pending.v2.no-image.msgpack")), "a v1 request is answered with the two-field v2, byte for byte the golden, whatever the tail gaps");
|
||||
|
||||
reply = nextReply();
|
||||
put(docs.kinds.pendingRequestV3, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 }));
|
||||
document = await reply;
|
||||
assert.equal(document.schemaId, docs.kinds.pendingV3, "the kind asked by names the answer");
|
||||
const v3 = docs.parsePendingV3(msgpack.decode(document.payload));
|
||||
assert.deepEqual(v3.deliveries[0], { ...delivery, basis: null, form: "message" });
|
||||
assert.deepEqual(v3.tail, [tailLine], "the v3 answer carries the room tail");
|
||||
assert.deepEqual(v3.tailGaps, ["#operator:gamecult.org"], "and the rooms whose tail is incomplete");
|
||||
|
||||
calls.tailFails = true;
|
||||
reply = nextReply();
|
||||
put(docs.kinds.pendingRequestV3, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 }));
|
||||
document = await reply;
|
||||
const degraded = docs.parsePendingV3(msgpack.decode(document.payload));
|
||||
assert.equal(degraded.deliveries.length, 1, "a tail that throws does not silence the deliveries");
|
||||
assert.deepEqual(degraded.tail, []);
|
||||
assert.deepEqual(degraded.tailGaps, ["#operator:gamecult.org", "#swarm:gamecult.org"], "a tail that throws leaves every speakable room a gap, not quiet");
|
||||
calls.tailFails = false;
|
||||
|
||||
put(docs.kinds.ack, "eureka:7", docs.ackTuple({ persona: "eureka", throughSeq: 7 }));
|
||||
reply = nextReply();
|
||||
|
|
@ -281,7 +256,7 @@ test("one process serves N Personas from systemd credentials: each token only in
|
|||
probe.close();
|
||||
const child = spawn(process.execPath, [resolve(root, "tools", "persona-matrix-mouth.mjs")], {
|
||||
env: {
|
||||
PATH: process.env.PATH, CULTLIB_ROOT: cultLibRoot, PERSONAS: "eureka,nova", CREDENTIALS_DIRECTORY: creds, CONTROL_ROOT: control,
|
||||
PATH: process.env.PATH, CULTLIB_ROOT: resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib")), PERSONAS: "eureka,nova", CREDENTIALS_DIRECTORY: creds, CONTROL_ROOT: control,
|
||||
STATE_DIRECTORY: stateDir, MATRIX_HOMESERVER: `http://127.0.0.1:${hs.address().port}`, MATRIX_ROOMS: "#swarm:t", MATRIX_SERVER_NAME: "t", CHAT_RUDP_LISTEN: `127.0.0.1:${port}`,
|
||||
},
|
||||
});
|
||||
|
|
@ -335,34 +310,6 @@ for (const [name, { tuple, parse }] of Object.entries(v3Samples)) {
|
|||
});
|
||||
}
|
||||
|
||||
// The pending answer v3 (ChatDelivery v4, basis and form): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v5/.
|
||||
const v5Dir = resolve(root, "tests", "fixtures", "persona-chat-v5");
|
||||
const v5Samples = {
|
||||
"gamecult.persona.chat_pending_request.v2": { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest },
|
||||
"gamecult.persona.chat_pending.v3.tag": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, basis: "tag", form: "message" }] }), parse: docs.parsePendingV3 },
|
||||
"gamecult.persona.chat_pending.v3.tail": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [], tail: [
|
||||
{ roomAlias: "#operator:gamecult.org", eventId: "$e0", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" },
|
||||
{ roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 1_700_000_001_000, sender: "@meta:gamecult.org", form: "notice", basis: "tag", text: "hello eureka" },
|
||||
] }), parse: docs.parsePendingV3 },
|
||||
"gamecult.persona.chat_pending.v3.tail-gaps": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [], tail: [
|
||||
{ roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 1_700_000_001_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "hello eureka" },
|
||||
], tailGaps: ["#swarm:gamecult.org", "!dm1:gamecult.org"] }), parse: docs.parsePendingV3 },
|
||||
"gamecult.persona.chat_pending.v3.unproven-notice": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, mentions: [], basis: null, form: "notice" }] }), parse: docs.parsePendingV3 },
|
||||
};
|
||||
for (const [name, { tuple, parse }] of Object.entries(v5Samples)) {
|
||||
test(`golden fixture: ${name} is byte-stable and parses back`, async () => {
|
||||
const bytes = Buffer.from(msgpack.encode(tuple));
|
||||
const file = resolve(v5Dir, `${name}.msgpack`);
|
||||
if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v5Dir, { recursive: true }); await writeFile(file, bytes); }
|
||||
assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures");
|
||||
assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple));
|
||||
if (name.includes("pending.v3")) assert.equal(msgpack.decode(bytes).length, 4, "persona, deliveries, tail, tail_gaps");
|
||||
if (name.endsWith("v3.tag") || name.endsWith("unproven-notice")) assert.equal(msgpack.decode(bytes)[1][0].length, 14);
|
||||
if (name.endsWith("v3.tail")) { assert.equal(msgpack.decode(bytes)[2][0].length, 7); assert.deepEqual(msgpack.decode(bytes)[3], [], "no gap: every room read fully"); }
|
||||
if (name.endsWith("v3.tail-gaps")) assert.deepEqual(msgpack.decode(bytes)[3], ["#swarm:gamecult.org", "!dm1:gamecult.org"]);
|
||||
});
|
||||
}
|
||||
|
||||
// The send pair v2: the bytes the Huginn organ pins from its side.
|
||||
const v2Dir = resolve(root, "tests", "fixtures", "persona-chat-v2");
|
||||
const v2Request = basis => docs.sendRequestV2Tuple({ requestId: "req-1", persona: "eureka", roomAlias: "#swarm:gamecult.org", text: "hi", basis });
|
||||
|
|
@ -398,7 +345,7 @@ test("RoomMoved is v2 only, and the v2 documents are strict about arity and newe
|
|||
|
||||
test("the RUDP endpoint answers a v1 send with the three-field receipt and a v2 send with the four-field one", async () => {
|
||||
const seen = [];
|
||||
const mouth = { pendingAfter: () => [], tail: async () => ({ lines: [], gaps: [], rooms: [] }), ack: async () => {}, send: async request => {
|
||||
const mouth = { pendingAfter: () => [], ack: async () => {}, send: async request => {
|
||||
seen.push(request);
|
||||
return request.basis ? { requestId: request.requestId, eventId: null, refusal: "RoomMoved", newer: ["$a"] } : { requestId: request.requestId, eventId: "$sent", refusal: null, newer: [] };
|
||||
} };
|
||||
|
|
@ -491,99 +438,3 @@ test("over the RUDP endpoint: pending and an ack post no receipt, a read report
|
|||
try { client.close(); } catch { /* already closed */ }
|
||||
}
|
||||
});
|
||||
|
||||
// The open-direct pair (v4): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v4/.
|
||||
const v4Dir = resolve(root, "tests", "fixtures", "persona-chat-v4");
|
||||
const v4Samples = {
|
||||
"gamecult.persona.chat_open_direct_request.v1": { tuple: docs.openDirectRequestTuple({ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello Meta" }), parse: docs.parseOpenDirectRequest },
|
||||
"gamecult.persona.chat_open_direct_receipt.v1.opened": { tuple: docs.openDirectReceiptTuple({ requestId: "req-1", outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" }), parse: docs.parseOpenDirectReceipt },
|
||||
"gamecult.persona.chat_open_direct_receipt.v1.existing": { tuple: docs.openDirectReceiptTuple({ requestId: "req-2", outcome: "Existing", roomId: "!dm1:gamecult.org", eventId: "$line2" }), parse: docs.parseOpenDirectReceipt },
|
||||
"gamecult.persona.chat_open_direct_receipt.v1.declined": { tuple: docs.openDirectReceiptTuple({ requestId: "req-3", outcome: "Declined" }), parse: docs.parseOpenDirectReceipt },
|
||||
"gamecult.persona.chat_refusal.v1.unknown-kind": { tuple: docs.refusalTuple({ persona: "eureka", refusal: "UnknownKind" }), parse: docs.parseRefusal },
|
||||
};
|
||||
for (const [name, { tuple, parse }] of Object.entries(v4Samples)) {
|
||||
test(`golden fixture: ${name} is byte-stable and parses back`, async () => {
|
||||
const bytes = Buffer.from(msgpack.encode(tuple));
|
||||
const file = resolve(v4Dir, `${name}.msgpack`);
|
||||
if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v4Dir, { recursive: true }); await writeFile(file, bytes); }
|
||||
assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures");
|
||||
assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple));
|
||||
});
|
||||
}
|
||||
|
||||
test("the open-direct documents are strict: room and event ids exactly for Opened and Existing, a known outcome, a bounded line", () => {
|
||||
for (const outcome of docs.directOutcomes) {
|
||||
const placed = outcome === "Opened" || outcome === "Existing";
|
||||
assert.doesNotThrow(() => docs.openDirectReceiptTuple(placed ? { requestId: "r", outcome, roomId: "!a:b", eventId: "$e" } : { requestId: "r", outcome }), outcome);
|
||||
assert.throws(() => docs.openDirectReceiptTuple(placed ? { requestId: "r", outcome } : { requestId: "r", outcome, roomId: "!a:b", eventId: "$e" }), /exactly for Opened and Existing/, outcome);
|
||||
assert.throws(() => docs.parseOpenDirectReceipt(["r", outcome, placed ? "!a:b" : null, placed ? null : "$e"]), /exactly for/, `${outcome}: event id alone is half a placement`);
|
||||
}
|
||||
assert.throws(() => docs.openDirectReceiptTuple({ requestId: "r", outcome: "Invented" }), /unknown open-direct outcome/);
|
||||
assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "@a:b", "x".repeat(4001)]), /byte bound/);
|
||||
assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "", "x"]), /user_id/);
|
||||
assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "@a:b"]), /4-field/);
|
||||
assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "UnknownKind" }), /unknown refusal/, "UnknownKind exists only in the typed refusal document");
|
||||
});
|
||||
|
||||
test("the RUDP endpoint answers an open-direct request with a receipt for its Persona, refuses another Persona's and answers a request of an unknown kind loudly", async () => {
|
||||
const opened = [];
|
||||
const mouth = { openDirect: async request => { opened.push(request); return { requestId: request.requestId, outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" }; } };
|
||||
const key = "connection-key-for-test";
|
||||
const logged = [];
|
||||
const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => logged.push(line) });
|
||||
const client = dgram.createSocket("udp4");
|
||||
await new Promise(ok => client.bind(0, "127.0.0.1", ok));
|
||||
const connection = new runtime.cultnet.CultNetRudpSocketTransportConnection({
|
||||
runtimeId: "huginn-test", socket: client, mode: "client", remoteHost: "127.0.0.1", remotePort: server.address.port,
|
||||
connectionId: connectionIdFor(key), initialSequence: 1, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200,
|
||||
});
|
||||
const replies = [];
|
||||
const waiting = [];
|
||||
connection.on("frame", value => {
|
||||
replies.push(runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0").document);
|
||||
waiting.splice(0).forEach(w => w());
|
||||
});
|
||||
const nextReply = () => new Promise((ok, fail) => { const t = setTimeout(() => fail(new Error(`no reply; the mouth logged: ${logged.join(" | ")}`)), 5000); waiting.push(() => { clearTimeout(t); ok(replies.at(-1)); }); });
|
||||
const put = (kind, recordKey, tuple, agent = "eureka") => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(
|
||||
docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: agent, sourceRole: "persona-organ" }), "cultnet.schema.v0")));
|
||||
try {
|
||||
connection.connect();
|
||||
for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20));
|
||||
assert.ok(connection.connected);
|
||||
let reply = nextReply();
|
||||
put(docs.kinds.openDirectRequest, "req-1", docs.openDirectRequestTuple({ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello" }));
|
||||
let document = await reply;
|
||||
assert.equal(document.schemaId, docs.kinds.openDirectReceipt);
|
||||
assert.deepEqual(docs.parseOpenDirectReceipt(msgpack.decode(document.payload)), { requestId: "req-1", outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" });
|
||||
assert.deepEqual(opened, [{ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello" }]);
|
||||
|
||||
reply = nextReply();
|
||||
put(docs.kinds.openDirectRequest, "req-2", docs.openDirectRequestTuple({ requestId: "req-2", persona: "other", userId: "@meta:gamecult.org", text: "hello" }), "other");
|
||||
document = await reply;
|
||||
assert.deepEqual(docs.parseOpenDirectReceipt(msgpack.decode(document.payload)), { requestId: "req-2", outcome: "UnknownPersona", roomId: null, eventId: null });
|
||||
assert.equal(opened.length, 1, "nothing reached a mouth for the unlisted Persona");
|
||||
|
||||
reply = nextReply();
|
||||
put("gamecult.persona.chat_invented_request.v9", "req-3", ["x"]);
|
||||
document = await reply;
|
||||
assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "eureka", refusal: "UnknownKind" }], "an unknown kind is answered, typed");
|
||||
assert.ok(logged.some(l => /unknown kind: gamecult\.persona\.chat_invented_request\.v9/.test(l)), "and logged");
|
||||
} finally {
|
||||
server.close();
|
||||
connection.close();
|
||||
try { client.close(); } catch { /* the connection already closed its socket */ }
|
||||
}
|
||||
});
|
||||
|
||||
test("tail_gaps is additive: a three-field reader finds persona, deliveries and tail unchanged in the first three elements of the new bytes", async () => {
|
||||
const sample = { persona: "eureka", deliveries: [{ ...delivery, isDirect: false, basis: "tag", form: "message" }], tail: [{ roomAlias: "#operator:gamecult.org", eventId: "$e0", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" }] };
|
||||
const noGaps = msgpack.decode(msgpack.encode(docs.pendingV3Tuple(sample)));
|
||||
const after = msgpack.decode(await readFile(resolve(root, "tests", "fixtures", "persona-chat-v5", "gamecult.persona.chat_pending.v3.tail-gaps.msgpack")));
|
||||
const withGaps = msgpack.decode(msgpack.encode(docs.pendingV3Tuple({ ...sample, tailGaps: ["#swarm:gamecult.org"] })));
|
||||
assert.equal(withGaps.length, 4);
|
||||
assert.deepEqual(withGaps.slice(0, 3), noGaps.slice(0, 3), "persona, deliveries and tail are byte-for-meaning the same with or without gaps");
|
||||
assert.deepEqual(docs.parsePendingV3([...withGaps.slice(0, 3), []]).tail, docs.parsePendingV3(noGaps).tail);
|
||||
assert.equal(after.length, 4);
|
||||
// Not claimed: a strict three-field decoder (rmp-serde into a three-field tuple) accepts it. It fails on the length, as it did for the tail, so Huginn defaults tail_gaps before this mouth deploys.
|
||||
assert.notEqual(withGaps.length, 3);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { createImageStore, RETAIN_MS } from "../tools/persona-chat-images.mjs";
|
|||
import { personaStoreRoot } from "./image-bytes.mjs";
|
||||
import * as docs from "../tools/persona-chat-documents.mjs";
|
||||
import { gif, png, svg } from "./image-bytes.mjs";
|
||||
import { createMouth, pageOf, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs";
|
||||
import { createMouth, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs";
|
||||
|
||||
const TOKEN = "syt_SECRET_TOKEN_9f3";
|
||||
const HS_NAME = "gamecult.test";
|
||||
|
|
@ -1303,421 +1303,3 @@ test("a read report that throws does not wedge the room: the next report still p
|
|||
assert.deepEqual(unhandled, [], "no rejection escapes the tail's cleanup");
|
||||
assert.equal(hs.receipts().length, 1);
|
||||
});
|
||||
|
||||
// Addressing facts (docs/bridge.md, "Addressing facts"): why a delivery is for this Persona, as the heartbeat reads it.
|
||||
// Each case is one message as a room member sends it, with the basis and form the mouth hands the consumer.
|
||||
const META = `@meta:${HS_NAME}`;
|
||||
const withContent = (id, sender, content, ts = 3000) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", ...content } });
|
||||
async function deliver(events, { persona = {}, preload } = {}) {
|
||||
const hs = await fakeHomeserver({
|
||||
preload,
|
||||
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) },
|
||||
});
|
||||
try {
|
||||
const mouth = mouthFor(hs, persona);
|
||||
await mouth.syncOnce();
|
||||
return { made: await mouth.syncOnce(), hs, mouth };
|
||||
} finally { await hs.close(); }
|
||||
}
|
||||
const facts = made => made.map(d => [d.eventId, d.basis, d.form]);
|
||||
|
||||
test("addressing facts: the operator speaking untagged proves nothing, tagged proves a tag, and the tag outranks @room", async () => {
|
||||
const { made } = await deliver([
|
||||
withContent("$plain", META, { body: "everyone, status?" }),
|
||||
withContent("$tag", META, { body: "eureka, status?", "m.mentions": { user_ids: [SELF] } }),
|
||||
withContent("$both", META, { body: "all hands", "m.mentions": { user_ids: [SELF], room: true } }),
|
||||
withContent("$room", META, { body: "@room standup", "m.mentions": { room: true } }),
|
||||
]);
|
||||
assert.deepEqual(facts(made), [["$plain", null, "message"], ["$tag", "tag", "message"], ["$both", "tag", "message"], ["$room", "room", "message"]]);
|
||||
});
|
||||
|
||||
test("addressing facts: a tag of another Mind is no basis for this one, and is one for that one", async () => {
|
||||
const hs = await fakeHomeserver({
|
||||
users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA },
|
||||
sync: since => since === null ? { next_batch: "b0", ...joined({ [ROOM]: { timeline: { events: [] } } }) } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [withContent("$n", META, { body: "nova?", "m.mentions": { user_ids: [NOVA] } })] } } }) },
|
||||
});
|
||||
try {
|
||||
const eureka = mouthFor(hs);
|
||||
const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA });
|
||||
for (const m of [eureka, nova]) await m.syncOnce();
|
||||
assert.deepEqual([facts(await eureka.syncOnce()), facts(await nova.syncOnce())], [[["$n", null, "message"]], [["$n", "tag", "message"]]]);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("addressing facts: a name in the text is not a basis; the mouth does not know names, so every casing and alias reaches the heartbeat with null", async () => {
|
||||
const bodies = ["Eureka, look", "EUREKA", "eureka's PR", "@eureka:gamecult.test please", "Bifrost needs a look"];
|
||||
const { made } = await deliver(bodies.map((body, i) => withContent(`$n${i}`, META, { body })));
|
||||
assert.deepEqual(made.map(d => d.basis), [null, null, null, null, null]);
|
||||
assert.deepEqual(made.map(d => d.text), bodies, "the text is delivered whole for the heartbeat's own name match");
|
||||
});
|
||||
|
||||
test("addressing facts: a DM is direct whatever it says or carries, a notice in it included", async () => {
|
||||
const DM = "!dm:gamecult.test";
|
||||
const hs = await fakeHomeserver({
|
||||
direct: { [META]: [DM] },
|
||||
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [DM]: { timeline: { events: [withContent("$dm", META, { body: "hi" }), withContent("$dmn", META, { msgtype: "m.notice", body: "automated" })] } } }) },
|
||||
});
|
||||
try {
|
||||
const mouth = mouthFor(hs);
|
||||
await mouth.syncOnce();
|
||||
assert.deepEqual(facts(await mouth.syncOnce()), [["$dm", "direct", "message"], ["$dmn", "direct", "notice"]]);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("addressing facts: a notice that tags her carries the tag and the notice form, so the consumer can refuse to wake on it", async () => {
|
||||
const { made } = await deliver([withContent("$w", `@root-watch:${HS_NAME}`, { msgtype: "m.notice", body: "disk 91%", "m.mentions": { user_ids: [SELF] } })]);
|
||||
assert.deepEqual(facts(made), [["$w", "tag", "notice"]]);
|
||||
});
|
||||
|
||||
test("addressing facts: an edit is its own form and is never read as a new message", async () => {
|
||||
const { made } = await deliver([withContent("$e", META, { body: "* fixed", "m.new_content": { msgtype: "m.text", body: "fixed" }, "m.relates_to": { rel_type: "m.replace", event_id: "$orig" } })]);
|
||||
assert.deepEqual(facts(made), [["$e", null, "edit"]]);
|
||||
});
|
||||
|
||||
test("addressing facts: a reply to her own words is a reply, in a room and in a thread, and her Self run's words are hers", async () => {
|
||||
const mine = withContent("$mine", SELF, { body: "my earlier say" }, 1000);
|
||||
const theirs = withContent("$theirs", `@nibu:${HS_NAME}`, { body: "someone else's" }, 1001);
|
||||
const reply = to => ({ "m.relates_to": { "m.in_reply_to": { event_id: to } } });
|
||||
const thread = (root, fallback) => ({ "m.relates_to": { rel_type: "m.thread", event_id: root, "m.in_reply_to": { event_id: fallback }, is_falling_back: true } });
|
||||
const { made } = await deliver([
|
||||
withContent("$r1", META, { body: "yes", ...reply("$mine") }),
|
||||
withContent("$r2", META, { body: "no", ...reply("$theirs") }),
|
||||
withContent("$r3", META, { body: "in thread under mine", ...thread("$mine", "$theirs") }),
|
||||
withContent("$r4", META, { body: "in thread under theirs", ...thread("$theirs", "$theirs") }),
|
||||
withContent("$r5", META, { body: "reply, and tagged", ...reply("$mine"), "m.mentions": { user_ids: [SELF] } }),
|
||||
], { preload: { [ROOM]: [mine, theirs] } });
|
||||
assert.deepEqual(facts(made), [["$r1", "reply", "message"], ["$r2", null, "message"], ["$r3", "reply", "message"], ["$r4", null, "message"], ["$r5", "tag", "message"]]);
|
||||
});
|
||||
|
||||
test("addressing facts: a reply target the homeserver will not give is null and logged without the token, never a guess", async () => {
|
||||
const logs = [];
|
||||
const { made } = await deliver([withContent("$r", META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } })], { persona: { log: l => logs.push(l) } });
|
||||
assert.deepEqual(facts(made), [["$r", null, "message"]]);
|
||||
assert.ok(logs.some(l => l.includes("reply target $gone not read")) && logs.every(l => !l.includes(TOKEN)));
|
||||
});
|
||||
|
||||
test("addressing facts: a client that sends no m.mentions is a tag when its body links her matrix.to pill; one that sends m.mentions without her is not", async () => {
|
||||
const pill = id => `<a href="https://matrix.to/#/${id}">Eureka</a>: look`;
|
||||
const { made } = await deliver([
|
||||
withContent("$p1", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF) }),
|
||||
withContent("$p2", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(encodeURIComponent(SELF)) }),
|
||||
withContent("$p3", META, { body: "Nova: look", format: "org.matrix.custom.html", formatted_body: pill(NOVA) }),
|
||||
withContent("$p4", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF), "m.mentions": {} }),
|
||||
]);
|
||||
assert.deepEqual(made.map(d => d.basis), ["tag", "tag", null, null]);
|
||||
});
|
||||
|
||||
test("addressing facts: her own messages are no delivery at all, so no basis can wake her on her own words", async () => {
|
||||
const { made } = await deliver([withContent("$self", SELF, { body: "my own", "m.mentions": { user_ids: [SELF] } })]);
|
||||
assert.deepEqual(made, []);
|
||||
});
|
||||
|
||||
test("addressing facts: the v3 answer carries basis and form; the v2 answer a v1 request gets is the unchanged 12 fields", async () => {
|
||||
const { made } = await deliver([withContent("$t", META, { body: "x", "m.mentions": { user_ids: [SELF] } })]);
|
||||
const pack = { encode: v => Buffer.from(JSON.stringify(v)) };
|
||||
assert.equal(docs.parsePendingV3(pageOf("eureka", made, pack, () => {}, true)).deliveries[0].basis, "tag");
|
||||
const v2 = pageOf("eureka", made, pack);
|
||||
assert.equal(v2[1][0].length, 12);
|
||||
assert.equal(docs.parsePending(v2).deliveries[0].basis, undefined);
|
||||
assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "tag", "poem"]), /unknown delivery form/);
|
||||
assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "everyone", "message"]), /unknown delivery basis/);
|
||||
assert.throws(() => docs.parseDeliveryV3(docs.deliveryTuple(made[0])), /14-field/);
|
||||
});
|
||||
|
||||
test("addressing facts: a hit read for a reply in one room is not lent to a reply in another room, and a target the server places elsewhere is null", async () => {
|
||||
const mine = { ...withContent("$mine", SELF, { body: "mine" }, 1000), room_id: ROOM };
|
||||
const reply = id => withContent(id, META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$mine" } } });
|
||||
const hs = await fakeHomeserver({
|
||||
preload: { [ROOM]: [mine], "!operator:gamecult.test": [{ ...mine, room_id: "!elsewhere:gamecult.test" }] },
|
||||
sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [reply("$prime")] } }, "!operator:gamecult.test": { timeline: { events: [reply("$spoof")] } } }) },
|
||||
});
|
||||
try {
|
||||
const mouth = mouthFor(hs);
|
||||
await mouth.syncOnce();
|
||||
assert.deepEqual(facts(await mouth.syncOnce()), [["$prime", "reply", "message"], ["$spoof", null, "message"]]);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("addressing facts: a pill is her exact matrix.to anchor: a longer id, a non-link attribute and a quoted reply are no tag", async () => {
|
||||
const html = h => ({ body: "x", format: "org.matrix.custom.html", formatted_body: h });
|
||||
const { made } = await deliver([
|
||||
withContent("$a", META, html(`<a href="https://matrix.to/#/${SELF}.evil.org">x</a>`)),
|
||||
withContent("$b", META, html(`<span data-x="https://matrix.to/#/${SELF}">x</span>`)),
|
||||
withContent("$c", META, html(`<a class="x" href='https://matrix.to/#/${SELF}'>Eureka</a>`)),
|
||||
withContent("$d", META, html(`<mx-reply><blockquote><a href="https://matrix.to/#/${SELF}">her</a> said</blockquote></mx-reply>ok`)),
|
||||
]);
|
||||
assert.deepEqual(made.map(d => d.basis), [null, null, "tag", null]);
|
||||
});
|
||||
|
||||
test("addressing facts: an edit or a notice replying to her costs no target fetch", async () => {
|
||||
const to = id => ({ "m.relates_to": { "m.in_reply_to": { event_id: id } } });
|
||||
const { made, hs } = await deliver([
|
||||
withContent("$n", META, { msgtype: "m.notice", body: "n", ...to("$noticed") }),
|
||||
withContent("$e", META, { body: "* e", "m.relates_to": { rel_type: "m.replace", event_id: "$orig", "m.in_reply_to": { event_id: "$edited" } } }),
|
||||
]);
|
||||
assert.deepEqual(facts(made), [["$n", null, "notice"], ["$e", null, "edit"]]);
|
||||
assert.equal(hs.seen.filter(r => r.path.includes("/event/")).length, 0);
|
||||
});
|
||||
|
||||
test("addressing facts: a target that failed is not refetched within a minute, and is after it", async () => {
|
||||
let t = 1_000_000;
|
||||
const rel = { "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } };
|
||||
const round = id => joined({ [ROOM]: { timeline: { events: [withContent(id, META, { body: id, ...rel })] } } });
|
||||
const script = { null: { next_batch: "b0" }, b0: { next_batch: "b1", ...round("$r1") }, b1: { next_batch: "b2", ...round("$r2") }, b2: { next_batch: "b3", ...round("$r3") } };
|
||||
const hs = await fakeHomeserver({ sync: since => script[since] ?? { next_batch: since } });
|
||||
const fetched = () => hs.seen.filter(r => r.path.includes("/event/")).length;
|
||||
try {
|
||||
const mouth = mouthFor(hs, { now: () => t });
|
||||
await mouth.syncOnce(); await mouth.syncOnce();
|
||||
t += 59_000; await mouth.syncOnce();
|
||||
assert.equal(fetched(), 1, "inside the minute: not refetched");
|
||||
t += 2_000; await mouth.syncOnce();
|
||||
assert.equal(fetched(), 2, "past the minute: read again");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
// The room tail (docs/bridge.md, "Room tail"): her rooms' recent talk past her ack cursor, bounded, as data on the v3 answer.
|
||||
const OPERATOR = "!operator:gamecult.test";
|
||||
const lines = (room, n, from = 0, sender = META) => Array.from({ length: n }, (_, i) => message(`$${room.slice(1, 4)}${from + i}`, sender, `line ${from + i}`, 10 + from + i));
|
||||
async function tailed({ preload, direct, member, events = [], act = async () => {} } = {}) {
|
||||
const hs = await fakeHomeserver({ preload, direct, member, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined(events.length ? { [ROOM]: { timeline: { events } } } : {}) } });
|
||||
try {
|
||||
const mouth = mouthFor(hs);
|
||||
await mouth.syncOnce();
|
||||
await act(mouth);
|
||||
const read = await mouth.tail();
|
||||
return { tail: read.lines, gaps: read.gaps, rooms: read.rooms, mouth, hs };
|
||||
} finally { await hs.close(); }
|
||||
}
|
||||
|
||||
test("room tail: 600 lines in a room keep the newest 20, oldest first", async () => {
|
||||
const { tail } = await tailed({ preload: { [ROOM]: lines(ROOM, 600) } });
|
||||
assert.equal(tail.length, 20);
|
||||
assert.deepEqual(tail.map(l => l.text), Array.from({ length: 20 }, (_, i) => `line ${580 + i}`));
|
||||
assert.deepEqual(Object.keys(tail[0]).sort(), ["atUnixMs", "basis", "eventId", "form", "roomAlias", "sender", "text"]);
|
||||
assert.equal(tail[0].roomAlias, `#swarm:${HS_NAME}`);
|
||||
assert.equal(tail[0].sender, META);
|
||||
assert.equal(tail[0].form, "message");
|
||||
assert.equal(tail[0].basis, null, "unproven, not 'not for her'");
|
||||
});
|
||||
|
||||
test("room tail: a quiet room, with nothing delivered, still returns its recent lines", async () => {
|
||||
const { tail } = await tailed({ preload: { [OPERATOR]: lines(OPERATOR, 3) } });
|
||||
assert.deepEqual(tail.map(l => l.text), ["line 0", "line 1", "line 2"]);
|
||||
assert.ok(tail.every(l => l.roomAlias === `#operator:${HS_NAME}`));
|
||||
});
|
||||
|
||||
test("room tail: a room she is not in the speaking set of is absent, and a DM she holds is present with basis direct", async () => {
|
||||
const STRANGER = "!stranger:gamecult.test", DM = "!mydm:gamecult.test";
|
||||
const { tail } = await tailed({
|
||||
preload: { [STRANGER]: lines(STRANGER, 2), [DM]: lines(DM, 2), [ROOM]: lines(ROOM, 1) },
|
||||
member: [...Object.values(ROOMS), STRANGER, DM],
|
||||
direct: { [META]: [DM] },
|
||||
});
|
||||
assert.deepEqual([...new Set(tail.map(l => l.roomAlias))].sort(), [DM, `#swarm:${HS_NAME}`]);
|
||||
assert.ok(tail.filter(l => l.roomAlias === DM).every(l => l.basis === "direct"));
|
||||
assert.ok(!tail.some(l => l.eventId.startsWith("$str")));
|
||||
});
|
||||
|
||||
test("room tail: about 60 lines over all rooms, the newest, whatever the room count", async () => {
|
||||
const dms = ["!d1:gamecult.test", "!d2:gamecult.test", "!d3:gamecult.test", "!d4:gamecult.test"];
|
||||
const preload = { [ROOM]: lines(ROOM, 30, 0), [OPERATOR]: lines(OPERATOR, 30, 30) };
|
||||
dms.forEach((dm, i) => { preload[dm] = lines(dm, 30, 60 + 30 * i); });
|
||||
const { tail } = await tailed({ preload, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] });
|
||||
assert.equal(tail.length, 60);
|
||||
assert.deepEqual(tail.map(l => l.atUnixMs), tail.map(l => l.atUnixMs).sort((a, b) => a - b), "oldest first");
|
||||
assert.equal(Math.min(...tail.map(l => l.atUnixMs)), 110, "the oldest kept is the 60th newest of the 120 offered");
|
||||
});
|
||||
|
||||
test("room tail: acking a delivery does not hide it, so an unaddressed line she has acked is still found, and mention basis rides on the line", async () => {
|
||||
const events = [
|
||||
withContent("$a", META, { body: "first" }, 3000),
|
||||
withContent("$b", META, { body: "tag", "m.mentions": { user_ids: [SELF] } }, 3001),
|
||||
];
|
||||
const hs = await fakeHomeserver({ sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) } });
|
||||
try {
|
||||
const mouth = mouthFor(hs);
|
||||
await mouth.syncOnce();
|
||||
const made = await mouth.syncOnce();
|
||||
assert.deepEqual((await mouth.tail()).lines.map(l => [l.eventId, l.basis]), [["$a", null], ["$b", "tag"]]);
|
||||
await mouth.ack(made[1].seq);
|
||||
assert.equal(mouth.pendingAfter(0).length, 0);
|
||||
assert.deepEqual((await mouth.tail()).lines.map(l => l.eventId), ["$a", "$b"], "acked lines are the ones no delivery will bring again");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("room tail: a room that never answers is absent after the timeout and does not hold the others", async () => {
|
||||
const hs = await fakeHomeserver({ preload: { [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
const hang = (url, init) => String(url).includes(encodeURIComponent(ROOM)) && String(url).includes("/messages") ? new Promise(() => {}) : hs.fetch(url, init);
|
||||
try {
|
||||
const mouth = mouthFor(hs, { fetch: hang, sendTimeoutMs: 50 });
|
||||
await mouth.syncOnce();
|
||||
const read = await mouth.tail();
|
||||
assert.deepEqual([...new Set(read.lines.map(l => l.roomAlias))], [`#operator:${HS_NAME}`]);
|
||||
assert.deepEqual(read.gaps, [`#swarm:${HS_NAME}`], "the room that timed out is a gap; the one that answered is not");
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("room tail: a mouth that has not started reads no room", async () => {
|
||||
const hs = await fakeHomeserver({ preload: { [ROOM]: lines(ROOM, 2) }, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
try {
|
||||
assert.deepEqual(await mouthFor(hs).tail(), { lines: [], gaps: Object.keys(ROOMS), rooms: Object.keys(ROOMS) }, "no room is known before start, so every configured room is a gap");
|
||||
assert.equal(hs.seen.filter(r => r.path.includes("/messages")).length, 0);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("room tail: a body is clipped like a context line, and an unreadable room is absent without silencing the others", async () => {
|
||||
const { tail } = await tailed({ preload: { [ROOM]: [message("$big", META, "x".repeat(5000), 10)] } });
|
||||
assert.equal(Buffer.byteLength(tail[0].text), docs.limits.contextBytes);
|
||||
const hs = await fakeHomeserver({ failContext: true, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
try {
|
||||
const mouth = mouthFor(hs);
|
||||
await mouth.syncOnce();
|
||||
const read = await mouth.tail();
|
||||
assert.deepEqual(read.lines, []);
|
||||
assert.deepEqual(read.gaps, Object.keys(ROOMS).filter(a => read.rooms.includes(a)), "an unreadable room is a gap, not quiet");
|
||||
assert.ok(read.gaps.length > 0);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("room tail: only the v3 answer carries it; the v2 answer a v1 request gets is unchanged", async () => {
|
||||
const { tail, gaps, rooms } = await tailed({ preload: { [ROOM]: lines(ROOM, 2) } });
|
||||
const pack = { encode: v => Buffer.from(JSON.stringify(v)) };
|
||||
assert.deepEqual(docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, { lines: tail, gaps, rooms })).tail.map(l => l.text), ["line 0", "line 1"]);
|
||||
const v2 = pageOf("eureka", [], pack, () => {}, false, { lines: tail, gaps: rooms, rooms });
|
||||
assert.equal(v2.length, 2);
|
||||
assert.deepEqual(docs.parsePending(v2), { persona: "eureka", deliveries: [] });
|
||||
assert.throws(() => docs.parsePendingV3(["eureka", []]), /4-field/);
|
||||
});
|
||||
|
||||
test("room tail: the reply byte budget trims the oldest tail lines, never a delivery", async () => {
|
||||
const pack = { encode: v => Buffer.from(JSON.stringify(v)) };
|
||||
const big = Array.from({ length: 60 }, (_, i) => ({ roomAlias: "#r:x", eventId: `$${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text: "y".repeat(5000) }));
|
||||
const kept = docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, { lines: big, gaps: [], rooms: ["#r:x"] })).tail;
|
||||
assert.ok(kept.length > 0 && kept.length < 60);
|
||||
assert.equal(kept.at(-1).eventId, "$59", "the newest survive");
|
||||
});
|
||||
|
||||
// tail_gaps (docs/bridge.md, "Gaps in the tail"): the rooms whose lines are not all in the tail, so a reader says "unknown", not "quiet".
|
||||
// Each case goes through the composition the RUDP endpoint serves: mouth.tail() into pageOf into the strict v3 parser.
|
||||
const replied = (read, pack = { encode: v => Buffer.from(JSON.stringify(v)) }) => docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, read));
|
||||
const SWARM_ALIAS = `#swarm:${HS_NAME}`, OPERATOR_ALIAS = `#operator:${HS_NAME}`;
|
||||
|
||||
test("tail gaps: a room whose read times out is a gap; the room that answered, quiet or not, is not", async () => {
|
||||
const hs = await fakeHomeserver({ preload: { [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
const hang = (url, init) => String(url).includes(encodeURIComponent(ROOM)) && String(url).includes("/messages") ? new Promise(() => {}) : hs.fetch(url, init);
|
||||
try {
|
||||
const mouth = mouthFor(hs, { fetch: hang, sendTimeoutMs: 50 });
|
||||
await mouth.syncOnce();
|
||||
const got = replied(await mouth.tail());
|
||||
assert.deepEqual(got.tailGaps, [SWARM_ALIAS]);
|
||||
assert.deepEqual(got.tail.map(l => l.roomAlias), [OPERATOR_ALIAS, OPERATOR_ALIAS]);
|
||||
} finally { await hs.close(); }
|
||||
});
|
||||
|
||||
test("tail gaps: a failed read is a gap, a quiet room (no lines) and a normal room (a few lines) are not, and exactly the per-room cap is not a trim", async () => {
|
||||
const failing = await fakeHomeserver({ failContext: true, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
try {
|
||||
const mouth = mouthFor(failing);
|
||||
await mouth.syncOnce();
|
||||
assert.deepEqual(replied(await mouth.tail()).tailGaps, [SWARM_ALIAS, OPERATOR_ALIAS], "every read failed: both rooms unknown, in room order");
|
||||
} finally { await failing.close(); }
|
||||
|
||||
const { gaps } = await tailed({ preload: { [ROOM]: lines(ROOM, docs.limits.tailPerRoom) } });
|
||||
assert.deepEqual(gaps, [], "20 lines in one room and none in the other: nothing was left out");
|
||||
const some = await tailed({ preload: { [ROOM]: lines(ROOM, 3), [OPERATOR]: [] } });
|
||||
assert.deepEqual(replied({ lines: some.tail, gaps: some.gaps, rooms: some.rooms }).tailGaps, []);
|
||||
});
|
||||
|
||||
test("tail gaps: a room with more lines than the per-room cap is a gap, once, and only that room", async () => {
|
||||
const { tail, gaps } = await tailed({ preload: { [ROOM]: lines(ROOM, docs.limits.tailPerRoom + 1), [OPERATOR]: lines(OPERATOR, 3) } });
|
||||
assert.equal(tail.filter(l => l.roomAlias === SWARM_ALIAS).length, docs.limits.tailPerRoom);
|
||||
assert.deepEqual(gaps, [SWARM_ALIAS]);
|
||||
});
|
||||
|
||||
test("tail gaps: the total cap names the rooms it dropped lines from, in room order, never a room whose lines all fit", async () => {
|
||||
const dms = ["!d1:gamecult.test", "!d2:gamecult.test", "!d3:gamecult.test"];
|
||||
const preload = { [ROOM]: lines(ROOM, 25, 100), [OPERATOR]: lines(OPERATOR, 20, 0) };
|
||||
dms.forEach((dm, i) => { preload[dm] = lines(dm, 20, 200 + 20 * i); });
|
||||
const { tail, gaps } = await tailed({ preload, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] });
|
||||
assert.equal(tail.length, 60);
|
||||
assert.deepEqual(gaps, [SWARM_ALIAS, OPERATOR_ALIAS], "swarm is gapped by both caps and named once; the three DMs kept all 20 lines");
|
||||
});
|
||||
|
||||
test("tail gaps: the reply byte budget and a line that will not encode gap their rooms, and only those", () => {
|
||||
const pack = { encode: v => Buffer.from(JSON.stringify(v)) };
|
||||
const mk = (room, i, extra = {}) => ({ roomAlias: room, eventId: `$${room}${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text: "y".repeat(5000), ...extra });
|
||||
const old = Array.from({ length: 30 }, (_, i) => mk("#old:x", i)), fresh = Array.from({ length: 30 }, (_, i) => mk("#new:x", 100 + i));
|
||||
const got = replied({ lines: [...old, ...fresh], gaps: [], rooms: ["#old:x", "#new:x", "#empty:x"] }, pack);
|
||||
assert.ok(got.tail.length < 60);
|
||||
assert.deepEqual(got.tailGaps, ["#old:x"], "the oldest lines were trimmed; the newer room and the quiet room are whole");
|
||||
const bad = replied({ lines: [mk("#a:x", 1), mk("#b:x", 2, { sender: 123 })], gaps: [], rooms: ["#a:x", "#b:x"] }, pack);
|
||||
assert.deepEqual(bad.tailGaps, ["#b:x"]);
|
||||
});
|
||||
|
||||
test("tail gaps: the parser refuses a repeated room, and a pre-gap decoder is stopped by arity, as the tail was", () => {
|
||||
assert.throws(() => docs.parsePendingV3(["eureka", [], [], ["#a:x", "#a:x"]]), /repeats/);
|
||||
assert.deepEqual(docs.parsePendingV3(["eureka", [], [], ["#a:x", "#b:x"]]).tailGaps, ["#a:x", "#b:x"]);
|
||||
assert.throws(() => docs.parsePendingV3(["eureka", [], []]), /4-field/);
|
||||
assert.throws(() => docs.parsePendingV3(["eureka", [], [], "#a:x"]), /list/);
|
||||
});
|
||||
|
||||
test("tail gaps: a line the mouth drops as malformed (no event id) gaps its room; a clean room beside it does not", async () => {
|
||||
const { tail, gaps } = await tailed({ preload: { [ROOM]: [message("", META, "no id", 10), message("$ok", META, "fine", 11)], [OPERATOR]: lines(OPERATOR, 1) } });
|
||||
assert.deepEqual(tail.map(l => l.eventId).sort(), ["$ok", "$ope0"].sort());
|
||||
assert.deepEqual(gaps, [SWARM_ALIAS]);
|
||||
});
|
||||
|
||||
const edit = (id, ts) => ({ type: "m.room.message", event_id: id, sender: META, origin_server_ts: ts, content: { msgtype: "m.text", body: "* edited", "m.relates_to": { rel_type: "m.replace", event_id: "$x" } } });
|
||||
const threaded = (id, ts) => ({ type: "m.room.message", event_id: id, sender: META, origin_server_ts: ts, content: { msgtype: "m.text", body: "in thread", "m.relates_to": { rel_type: "m.thread", event_id: "$root" } } });
|
||||
const noise = (n, from) => Array.from({ length: n }, (_, i) => (i % 2 ? threaded : edit)(`$n${from + i}`, 1000 + from + i));
|
||||
|
||||
test("tail gaps: a full read window hides older real lines behind edits and thread replies, so the room is a gap; a short chunk is exhausted history, not a gap", async () => {
|
||||
const hidden = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(120, 0)] } });
|
||||
assert.deepEqual(hidden.tail, [], "the five real lines lie beyond the 100-event window");
|
||||
assert.deepEqual(hidden.gaps, [SWARM_ALIAS], "so the room is unknown, not quiet");
|
||||
const whole = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(60, 0)] } });
|
||||
assert.equal(whole.tail.length, 5);
|
||||
assert.deepEqual(whole.gaps, [], "a chunk shorter than the window reached the room's start");
|
||||
const at99 = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(94, 0)] } });
|
||||
assert.deepEqual(at99.gaps, [], "99 raw events: one short of the window, history exhausted");
|
||||
const at100 = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(95, 0)] } });
|
||||
assert.deepEqual(at100.gaps, [SWARM_ALIAS], "exactly 100 raw events fill the window, so older history cannot be ruled out");
|
||||
});
|
||||
|
||||
test("tail gaps: the total cap boundary: exactly 60 lines over all rooms is no gap, a 61st names the room that lost its oldest line", async () => {
|
||||
const dms = ["!d1:gamecult.test", "!d2:gamecult.test"];
|
||||
const rooms60 = { [ROOM]: lines(ROOM, 20, 0), [OPERATOR]: lines(OPERATOR, 20, 20), [dms[0]]: lines(dms[0], 20, 40) };
|
||||
const at60 = await tailed({ preload: rooms60, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] });
|
||||
assert.equal(at60.tail.length, 60);
|
||||
assert.deepEqual(at60.gaps, []);
|
||||
const at61 = await tailed({ preload: { ...rooms60, [dms[1]]: lines(dms[1], 1, 60) }, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] });
|
||||
assert.equal(at61.tail.length, 60);
|
||||
assert.deepEqual(at61.gaps, [SWARM_ALIAS]);
|
||||
});
|
||||
|
||||
test("tail gaps: a byte-budget cut is contiguous at the oldest end, so an older line that would still fit is cut and its room gapped", () => {
|
||||
const pack = { encode: v => Buffer.from(JSON.stringify(v)) };
|
||||
const mk = (room, i, text) => ({ roomAlias: room, eventId: `$${room}${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text });
|
||||
const tiny = mk("#tiny:x", 0, "t"), big = Array.from({ length: 70 }, (_, i) => mk("#big:x", 1 + i, "y".repeat(5000)));
|
||||
const got = replied({ lines: [tiny, ...big], gaps: [], rooms: ["#tiny:x", "#big:x"] }, pack);
|
||||
assert.ok(!got.tail.some(l => l.roomAlias === "#tiny:x"), "nothing older than the cut is kept");
|
||||
assert.deepEqual(got.tailGaps, ["#tiny:x", "#big:x"]);
|
||||
});
|
||||
|
||||
test("tail gaps: the read timer is cleared when the read answers, and a mouth that has not started names its configured rooms for an endpoint that cannot read", async () => {
|
||||
const delay = 4321, made = new Set(), cleared = new Set();
|
||||
const realSet = globalThis.setTimeout, realClear = globalThis.clearTimeout;
|
||||
globalThis.setTimeout = (fn, ms, ...rest) => { const t = realSet(fn, ms, ...rest); if (ms === delay) made.add(t); return t; };
|
||||
globalThis.clearTimeout = t => { cleared.add(t); return realClear(t); };
|
||||
const hs = await fakeHomeserver({ preload: { [ROOM]: lines(ROOM, 2), [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) });
|
||||
try {
|
||||
const mouth = mouthFor(hs, { sendTimeoutMs: delay });
|
||||
assert.deepEqual(mouth.speakableRooms(), Object.keys(ROOMS), "before start: the configured rooms");
|
||||
await mouth.syncOnce();
|
||||
assert.deepEqual(mouth.speakableRooms(), Object.keys(ROOMS), "after start: the rooms she can speak in");
|
||||
await mouth.tail();
|
||||
assert.ok(made.size >= 2);
|
||||
assert.ok([...made].every(t => cleared.has(t)), "no read timer is left running");
|
||||
} finally { globalThis.setTimeout = realSet; globalThis.clearTimeout = realClear; await hs.close(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
// Six document types once declared `indexes:` on grouping fields (status, repo, agent, topic...) that many rows share.
|
||||
// CultLib's `indexes:` means unique, so each store must still write, close and reopen with rows that share those values.
|
||||
// Real CLIs and store openers against temp files; CultLib comes from tests/cultlib-root.mjs.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { cultLibRoot } from "./cultlib-root.mjs";
|
||||
import { openCrossingReceiptStore } from "../tools/bifrost-crossing-documents.mjs";
|
||||
import { buildAuthorizedRelease, openRepositoryReleaseAuthorityStore } from "../tools/bifrost-repository-release-authority.mjs";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const dir = mkdtempSync(resolve(tmpdir(), "bifrost-shared-"));
|
||||
test.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
VOIDBOT_CULTLIB_ROOT: cultLibRoot,
|
||||
BIFROST_SKIP_LOCAL_ENV: "true",
|
||||
BIFROST_ROOT_VERSE: "test-verse",
|
||||
BIFROST_MACHINE_ID: "test-machine",
|
||||
BIFROST_CROSSING_RECEIPT_STORE: resolve(dir, "receipts.cc"),
|
||||
};
|
||||
for (const name of ["BIFROST_BRIDGE_BASE_URL", "BIFROST_BRIDGE_TOKEN", "BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"]) delete env[name];
|
||||
|
||||
function cli(tool, args) {
|
||||
const run = spawnSync(process.execPath, [resolve(root, "tools", tool), ...args], { env, encoding: "utf8", cwd: root });
|
||||
assert.equal(run.status, 0, `${tool} ${args[0]}\n${run.stderr}`);
|
||||
return run.stdout;
|
||||
}
|
||||
|
||||
test("agent-transport store holds requests that share repo, agent and status", () => {
|
||||
const store = resolve(dir, "agent-transport.cc");
|
||||
for (const id of ["req-1", "req-2", "req-3"]) {
|
||||
cli("agent-transport.mjs", ["enqueue", "--store", store, "--id", id, "--repo", "Bifrost", "--agent", "heimdall", "--title", id, "--request", "body", "--allow-unmirrored", "true"]);
|
||||
}
|
||||
const listed = JSON.parse(cli("agent-transport.mjs", ["list", "--store", store]));
|
||||
assert.deepEqual(listed.map((request) => request.id).sort(), ["req-1", "req-2", "req-3"]);
|
||||
});
|
||||
|
||||
test("governance-threads store holds topics and comments that share their grouping fields", () => {
|
||||
const store = resolve(dir, "governance.cc");
|
||||
for (const id of ["topic-1", "topic-2"]) {
|
||||
cli("governance-threads.mjs", ["open", "--store", store, "--id", id, "--title", id, "--repo", "Bifrost", "--agent", "heimdall", "--summary", "s", "--allow-unmirrored", "true"]);
|
||||
}
|
||||
for (const id of ["c-1", "c-2"]) {
|
||||
cli("governance-threads.mjs", ["comment", "--store", store, "--topic", "topic-1", "--id", id, "--author", "heimdall", "--stance", "support", "--body", "same", "--allow-unmirrored", "true"]);
|
||||
}
|
||||
const topics = JSON.parse(cli("governance-threads.mjs", ["list", "--store", store]));
|
||||
assert.deepEqual(topics.map((topic) => topic.id).sort(), ["topic-1", "topic-2"]);
|
||||
const shown = JSON.parse(cli("governance-threads.mjs", ["show", "--store", store, "--topic", "topic-1"]));
|
||||
assert.equal(JSON.stringify(shown).match(/"id": ?"c-\d"/g)?.length ?? 0, 2);
|
||||
});
|
||||
|
||||
test("crossing receipt store reopens with receipts that share status, kind and command", async () => {
|
||||
const receipts = await openCrossingReceiptStore(env.BIFROST_CROSSING_RECEIPT_STORE);
|
||||
const before = receipts.getAll().length;
|
||||
assert.ok(before >= 5, "the CLI runs above leave completed agent_transport.request receipts");
|
||||
const reopened = await openCrossingReceiptStore(env.BIFROST_CROSSING_RECEIPT_STORE);
|
||||
assert.equal(reopened.getAll().length, before);
|
||||
assert.ok(new Set(reopened.getAll().map((receipt) => `${receipt.status}/${receipt.crossingKind}`)).size < before);
|
||||
});
|
||||
|
||||
test("release authority store reopens with authorities that share repository, ref and status", async () => {
|
||||
const path = resolve(dir, "release-authority.cc");
|
||||
const authority = (commitSha) => buildAuthorizedRelease({
|
||||
repositoryFullName: "GameCult/Bifrost", upstreamRef: "refs/heads/main", commitSha,
|
||||
commandId: `cmd-${commitSha[0]}`, crossingReceiptId: `crossing-${commitSha[0]}`, policyDecisionId: "policy", authorityReference: "ref",
|
||||
actorIdentity: "heimdall", sourceKind: "test", sourceId: "src", epiphanyRunId: "run", epiphanyLaneId: "lane", epiphanyAgentIdentity: "heimdall",
|
||||
externalReceiptUrl: "https://example.test/r", externalReceiptId: "r", authorizedAt: "2026-11-11T00:00:00.000Z",
|
||||
});
|
||||
const store = await openRepositoryReleaseAuthorityStore(path);
|
||||
await store.put(authority("a".repeat(40)));
|
||||
await store.put(authority("b".repeat(40)));
|
||||
const reopened = await openRepositoryReleaseAuthorityStore(path);
|
||||
assert.equal(reopened.getAll().length, 2);
|
||||
});
|
||||
|
||||
test("provider advertisement store exports and re-exports beside other documents", () => {
|
||||
const out = resolve(dir, "provider.cc");
|
||||
cli("provider-advertisement.mjs", ["export", "--out", out]);
|
||||
const second = JSON.parse(cli("provider-advertisement.mjs", ["export", "--out", out]));
|
||||
assert.equal(second.ok, true);
|
||||
});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { cultLibRoot } from "./cultlib-root.mjs";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
|
|
@ -27,7 +26,8 @@ const T_OPEN = "2023-11-14T22:13:20.000Z";
|
|||
const T_CLOSE = "2023-11-14T22:15:00.000Z";
|
||||
const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on \`closed-target\`, opened ${T_OPEN}, closed ${T_CLOSE}`;
|
||||
|
||||
const cult = cultLibRoot;
|
||||
// CultLib is the sibling ../CultLib, like the other tests.
|
||||
const cult = resolve(import.meta.dirname, "..", "..", "CultLib");
|
||||
const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json"))("@gamecult/cultcache-ts");
|
||||
const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack");
|
||||
|
||||
|
|
|
|||
|
|
@ -21,15 +21,13 @@ const defaultPersonaName = "Bifrost";
|
|||
const defaultPersonaAvatarUrl =
|
||||
"https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png";
|
||||
|
||||
const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib"));
|
||||
|
||||
const cultCachePackagePath = resolveFirstExisting("CultCache TypeScript runtime", [
|
||||
resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"),
|
||||
resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"),
|
||||
resolve(projectsRoot, "CultCacheTS", "package.json"),
|
||||
resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"),
|
||||
]);
|
||||
const cultNetPackagePath = resolveFirstExisting("CultNet TypeScript runtime", [
|
||||
resolve(cultLibRoot, "packages", "cultnet-ts", "package.json"),
|
||||
resolve(projectsRoot, "CultLib", "packages", "cultnet-ts", "package.json"),
|
||||
resolve(projectsRoot, "CultNetTS", "package.json"),
|
||||
]);
|
||||
const cultCacheRequire = createRequire(cultCachePackagePath);
|
||||
|
|
@ -70,6 +68,11 @@ const updateRequestDefinition = defineDocumentType({
|
|||
parse: parseUpdateRequest,
|
||||
},
|
||||
name: "id",
|
||||
indexes: {
|
||||
targetRepoName: "targetRepoName",
|
||||
targetAgentIdentity: "targetAgentIdentity",
|
||||
status: "status",
|
||||
},
|
||||
members: [
|
||||
{ slot: 1, memberName: "id", typeName: "string", isName: true },
|
||||
{ slot: 2, memberName: "targetRepoName", typeName: "string", indexAlias: "targetRepoName" },
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ export function crossingReceiptDefinition(defineDocumentTypeInput) {
|
|||
contentHash: crossingReceiptSchemaId,
|
||||
global: false,
|
||||
name: "receiptId",
|
||||
indexes: {
|
||||
commandId: "commandId",
|
||||
crossingKind: "crossingKind",
|
||||
status: "status",
|
||||
},
|
||||
schema: { parse: parseCrossingReceipt },
|
||||
members: [
|
||||
{ slot: 1, memberName: "receiptId", typeName: "string", isName: true },
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export function repositoryReleaseAuthorityDefinition(defineDocumentTypeInput) {
|
|||
contentHash: repositoryReleaseAuthoritySchemaId,
|
||||
global: false,
|
||||
name: "authorityId",
|
||||
indexes: { repositoryFullName: "repositoryFullName", upstreamRef: "upstreamRef", commitSha: "commitSha", status: "status" },
|
||||
schema: { parse: parseRepositoryReleaseAuthority },
|
||||
members: [
|
||||
{ slot: 1, memberName: "authorityId", typeName: "string", isName: true },
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue