Run the application-authority fixture¶
The application-authority fixture (milestone M0a of the
application authority transition)
is an isolated, native, two-replica stack for proving authorization behaviour with real sessions and
tokens. It is test-only and opt-in: no CI workflow runs it. Since M1a it can also run both API
replicas in any application-authority mode (--application-roles-mode).
Since M1b the lane runs enforced by default and proves role grant and revoke;
since M1c it also proves the lifecycle-wide last-administrator guard;
since M2a it also proves that every other application capability follows the same mode contract
(see the claim-consumer ledger).
Run it from any worktree root. The command builds the services it needs, starts the stack, seeds
synthetic accounts, runs the specs and tears the stack down again. Expect about 3 minutes on a cold
build and under a minute with --skip-build.
| Option | Effect |
|---|---|
--skip-build |
Reuse this worktree's existing Release binaries of API, PM, Identity and the seeder. |
--keep |
Leave the stack running afterwards (for debugging). Stop it with bash e2e/authority/teardown.sh. |
--application-roles-mode enforced\|shadow\|off |
Mode for both API replicas. The default is enforced since M1b, because the lane proves the target contract; off is the deployed default and proves it is unchanged. Sets FeatureFlags__SyrfOwnedApplicationRoles and FeatureFlags__SyrfOwnedApplicationRolesEnforced, and (M1c) the same pair for Identity. After the specs, the runner prints each replica's count of logged authority decisions and disagreements. |
-- <args> |
Pass the remaining arguments to Playwright, for example -- --grep refresh. |
What it runs¶
| Component | Where | Notes |
|---|---|---|
| Front door | https://localhost:<front> |
The single public origin, like the deployed ingress. Identity registers its BFF callback and Swagger redirect here. Routes each request to replica A or B named by the x-authority-replica header or authority-replica cookie; a request naming neither gets 421. Every response carries x-authority-served-by. |
| API replica A and B | http://127.0.0.1:<api>, <api-second> |
Identical configuration: BFF enabled on provider openiddict, sessions in the shared Valkey, per-request native introspection. The /api/e2e-auth/login bypass and the development picker are off. |
| Identity | https://localhost:<identity> |
The real Identity host outside Development, so its full production configuration contract applies. Throwaway CA, TLS, signing and data-protection material per run. |
| Project Management | http://127.0.0.1:<pm> |
Started first: Identity's readiness requires the PM collections. |
| API Mongo relay | 127.0.0.1:<quartz> (e2e/authority/mongo-gate.mjs) |
The API replicas, and only they, reach MongoDB through this loopback relay, on the slot's otherwise unused Quartz port (#3859). A spec pauses it (setApiMongoGate('paused'), a SIGUSR1 to its pid) to make the PM store stop answering the API mid-request while Identity, and so token introspection, keeps working; SIGUSR2 resumes it. Paused bytes are held, never dropped. |
MongoDB rs0, RabbitMQ, Valkey |
containers | Single-member replica set (mongo:8, as the ordinary E2E stack), rabbitmq:3.13-management, and the digest-pinned Valkey image of the syrf-valkey chart. No Docker volumes: data lives in tmpfs. |
The synthetic accounts (admin, member, suspended, pm-admin, m1a-admin, m1b-root, m1b-target, m1c-a, m1c-b, all @authority-e2e.invalid)
are created through Identity's own UserManager by e2e/authority/tools/AuthoritySeeder, with
passwords generated for each run. A first BFF sign-in creates each PM investigator record, exactly as
in production.
Isolation and safety¶
- Own stack identity. The lane runs as a run-scoped stack,
E2E_STACK_ID=authority-pr<N>(orauthority-<hash>outside apr<N>.<slug>worktree), throughe2e/scripts/ports.sh. It therefore gets its own port slot and reservation, Compose projectsyrf-e2e-authority-…, network, databases, cookie names and state directory/tmp/syrf-e2e-authority-…, and never adopts this worktree's ordinary E2E stack. See the local multi-stack runbook. - Loopback only. Every endpoint is checked for loopback before any process starts, the seeder
refuses a non-loopback MongoDB, and published container ports bind to
127.0.0.1. - No developer settings. Each service starts under
env -iwith an explicit variable list and an empty privateHOME, so shell variables, the .NET user-secrets store and~/.config/syrfcannot reach it. Every offset-0 default inappsettings.e2etest.jsonthat belongs to the ordinary E2E stack (Mongo, RabbitMQ, LocalStack, mock OIDC) is overridden to this stack's slot. - Verified before mutation. The first spec step checks that MongoDB, RabbitMQ and Valkey are
published by this stack's own labelled containers on the manifest ports, that MongoDB is the
rs0primary, that Identity is the local issuer, and that both replicas' BFF login redirects to it. Nothing is written before those checks pass. - TLS stays verified. Node trusts only the run's throwaway CA (
NODE_EXTRA_CA_CERTS); Chromium accepts the fixture's certificate by its SPKI pin rather than ignoring certificate errors. - Exact teardown.
teardown.shstops the lane's processes by pid file (as process groups), removes containers of thesyrf-e2e-authority-…project only after each one'sorg.syrf.e2e.worktreelabel proves ownership, removes the network, releases the reservation and deletes the state directory. It then verifies that no process, container, volume, network or listening port of the stack remains, and fails otherwise. It never touches the sharedsyrf-quartz-devcontainer, the ordinary E2E stack or another worktree's stack, and it is idempotent.run.shcalls it on every exit unless you pass--keep. - No secrets in output. Passwords and tokens stay in the 0600 state directory; Playwright traces, screenshots and video are off; the front door never logs query strings.
Reading the results¶
The run passes when every baseline test passes and every characterization fails as expected.
| Spec | Tests | Expected today |
|---|---|---|
authority-baseline.spec.ts |
Local-stack guard; native BFF sign-in on A and on B, each honoured on both replicas; BFF refresh on A and on B; direct native bearer (Identity-issued PKCE token, introspected per request) on A and B; SignalR negotiate + JSON handshake on A and B with the BFF session and with a bearer token | Pass (9) |
authority-m1a-application-roles.spec.ts |
ListUsers for an Identity-claim administrator with no PM role, and for a deactivated PM administrator, on A and B with sessions and a bearer token. M2a (same file, sharing its two sign-ins): the email-template, batch-administration and flag-management reads, a flag mutation and a no-recipient administrative send, on A and B with the session and a bearer token, for the claim without the PM role, for the PM role, and after revoking it; the self-capabilities response agrees with every answer. M3a (same sign-ins): the caller, holding the PM Administrator role and the administrator claim, creates a private project and reads its members on A and B; removing their membership by a majority write denies the next request on A and B (enforced only). #3859 outage (same sign-ins): with the PM Administrator role and the claim, ListUsers bearer requests to A and B are sent while the API's Mongo relay is paused for 12 s | Pass in every mode. Outage: enforced answers 503 authority-unavailable, Retry-After: 5, within 8 s, before the store returns; off/shadow answer 200 once it returns (the decision is unchanged); all answer 200 afterwards. (M1a 2; M2a 2 in off/shadow, 3 in enforced): the claim decides in off/shadow (the capabilities read is 404); in enforced the claim alone gets 403 everywhere, the PM role is authorized (the mutation reaches validation, 400, and is never applied), and the revoke denies the next request on A and B |
authority-m1b-role-administration.spec.ts |
M1b acceptance through PUT /api/investigators/{id}/application-roles only. Bootstrap administrator through the real operator tool (SyRF.ProjectManagement.ApplicationRoleBootstrap): dry run, apply refused without --confirm-database, apply, idempotent re-run. Grant, then revoke, observed on A and B with old BFF sessions, a direct token and a refreshed session; the revoked actor loses role administration at once; audit records committed. No positive cache (alternating grants and revokes). Revision conflict and idempotent retry. A2: an administrator who is not a project member gets 403 on /api/projects/{id}/investigators. Four rounds of concurrent removal of the last two administrators, by each other and by themselves |
Pass (5) in enforced (1 skipped). In off/shadow: pass (1): the endpoints answer 404 on A and B and no audit record exists (5 skipped) |
authority-m1c-lifecycle-guard.spec.ts |
M1c acceptance with the real Identity. The last active administrator cannot be removed by DELETE /api/account/profile (A and B: 409 last-active-administrator), by Identity's admin delete without a reservation or with a forged operation ID (409 deactivation-not-reserved), or by a self-revoke; nothing is revoked or reserved. Concurrent deletions of the last two administrators: one 204 (reserved, finalized, Identity account gone, old token refused), the other 409. A deletion racing a role removal of the other administrator: exactly one takes effect. Consumes m1c-a/m1c-b; the third administrator is member, granted by API |
Pass (3) in enforced (1 skipped). In off/shadow: pass (1): deletion unchanged, no reservation written (3 skipped) |
authority-m4a-suspension.spec.ts |
M4a acceptance. A PM-only suspension (no Identity call) refuses the old BFF session, a session refreshed before it, a direct bearer token and a socket negotiate on A and B with 403 subject-suspended, and removing it admits the next request. Under a PM-only suspension Identity (which gets the same applicationSuspension value) refuses the BFF refresh (401, the session ends) and a new authorization code (access_denied) (#3896). The API suspension: self-suspension 409; suspend 200 with withdrawal: completed and a settled application-suspension reservation, replayed on B; afterwards the old session, token and refresh are refused. Restore 200, twice 409 not-suspended; the old session and token stay refused and a new sign-in works on A and B. Three password sign-ins (m4a-admin, m4a-target twice) |
Pass (3) in enforced (1 skipped). In off/shadow: pass (1): the endpoint answers 404 and a stored suspension refuses nothing (3 skipped) |
authority-r1-characterization.spec.ts |
R1 gaps, below | In enforced (default): all three gaps are closed and pass (the deactivation gap by M4a subject admission). In off/shadow: expected failure (3) |
Each R1 characterization asserts its preconditions and the majority-acknowledged mutation normally,
records the commit boundary (operationTime) and every status as test annotations, and checks that
each post-commit answer is a real authorization answer (200/401/403). Only then does it call
test.fail() and assert the R1 target, so a broken fixture is a real failure, never a hidden one.
| Characterization | Observed today | Flips to passing in |
|---|---|---|
| Administrator role removed from PM and the Identity copy: existing BFF sessions (from A and B), a refreshed session and a direct bearer token still list users on A and B | 200 everywhere with claims deciding (off/shadow) |
Closed in enforced (M1a boundary, flipped as the lane default by M1b): 403 everywhere. Deployed environments close it at the M6 cutover |
PM Deactivated investigator: existing sessions and token still read /api/projects on A and B |
200 everywhere in off/shadow |
Closed in enforced by M4a (#3888): applicationSuspension follows enforced mode in this lane, and its subject admission refuses a deleted, suspended or pending-deletion subject (403 everywhere). Deployed environments close it when they enable the flag after M6 |
PM Administrator role alone (no Identity group) does not grant ListUsers on A or B: the policy matches the case-sensitive claim group administrator |
403 everywhere with claims deciding | Closed in enforced (M1a/M1b): 200 everywhere |
When a milestone closes a gap, Playwright reports "expected to fail, but passed" and the run fails:
delete that test's markExpectedGap(...) call in the same PR. The two ListUsers characterizations use
markListUsersGap(...), which treats the gap as closed only when the run enforces PM authority on both
replicas; they stay recorded as expected failures in off/shadow, which is what deployed environments run
until M6. Results are written to
e2e/test-results/authority/results.json.
Resource cost¶
Measured on the maintainer's host (2026-09-30), steady state after the specs:
| Component | Memory |
|---|---|
| API replica A / B | ~340 MB each |
| Project Management | ~320 MB |
| Identity | ~260 MB |
| MongoDB (data in tmpfs) | ~355 MB |
| RabbitMQ | ~170 MB |
| Front door | ~70 MB |
| Valkey | ~5 MB |
| Stack total | ~1.9 GB, plus Chromium during the specs (~2.3 GB peak) |
Time: about 3 minutes from a cold checkout (image pulls and a Release build of four projects with
two build workers), about 50 seconds with --skip-build; the specs themselves take about 40 seconds in enforced mode (M1c).
Troubleshooting¶
- A service did not become ready.
run.shprints the last log lines. With--keep, all logs stay in/tmp/syrf-e2e-authority-…/*.log; after a failed run without--keepthey are copied to a printed temporary directory before teardown. - "Invalid email or password" after repeated runs against a kept stack. Identity's password-attempt protection allows five password checks per account per five minutes. A normal run stays inside that budget; start a fresh stack instead of re-running specs against a kept one.
- A sign-in stalls on the login page (the test times out in
signInThroughBff). Identity also admits only 20 password attempts per source per five minutes (PasswordAttemptStore.SourceLimit), and every sign-in in this lane comes from the same loopback address. A full enforced run makes about 17; the 21st is refused with the ordinary "Invalid email or password" page and no log line, which looks like a stall. New specs must reuse bearer tokens or existing sessions rather than add password sign-ins; count them inidentity.log(POST /Account/Login). The lane now enforces this itself:signInThroughBffdraws fromlib/sign-in-budget.ts(18 sign-ins per five minutes in the single Playwright worker) and fails with a message naming the limit instead of stalling. Production limits are unchanged. - A sign-in stalls after the password is accepted (#3893).
identity.logshowsPOST /Account/Loginanswered302, and then nothing from the browser: no/connect/authorize, no/api/auth/callbackon the API. This host is the shared CI runner. When other jobs create or remove Docker networks, Chromium can see a host network change and drop the in-flight redirect (net::ERR_NETWORK_CHANGED). It happened in off, shadow and enforced runs alike. The application-authority mode does no work on the sign-in path: Identity reads only whether the mode is enforced, and the BFF login and callback never call the authority gate.signInThroughBff(lib/bff-sign-in-recovery.ts) waits at most 30 seconds for the callback. After a dropped or stalled navigation it resumes once by opening/api/auth/loginagain. Identity's cookie from the accepted password normally completes the flow without a second password attempt. It logs[authority] BFF sign-in on replica … resumed once: <cause>, and a second failure fails the test with the cause instead of waiting for the 120-second test timeout. - Teardown refuses a container. A container with this stack's project label but another owner label exists. Inspect it; do not remove it by name.
- P3 read benchmark. The linearizable-read benchmark (three-member replica set, failover, deadline)
is a separate stack, not part of this fixture:
bash e2e/authority/benchmark/run.sh, results in P3 benchmark. - Guard tests only:
bash e2e/authority/test-guards.sh,node --test e2e/authority/front-door.test.mjs,node --test e2e/authority/sign-in-budget.test.mjs,node --test e2e/authority/sign-in-recovery.test.mjsandnode --test e2e/authority/mongo-gate.test.mjsneed no stack and also run in CI through.github/scripts/test-e2e-concurrency.sh.