diff --git a/.github/workflows/authenticator.yml b/.github/workflows/authenticator.yml new file mode 100644 index 000000000..ace4fac35 --- /dev/null +++ b/.github/workflows/authenticator.yml @@ -0,0 +1,108 @@ +name: Authenticator — e2e, endpoint coverage + +# The authenticator's end-to-end suite (tests/run-e2e.sh: Redis in docker, +# fakeidp + two authenticator instances as local release binaries, the eight +# e2e_*.rs loops) plus the endpoint coverage gate on top of it: the suite's +# shared client (tests/common/mod.rs) records every request against the +# authenticator into a ledger, and the gate job matches that ledger against +# the committed OpenAPI spec — a documented operation no test exercises fails +# the gate. Same gate script and report as the analytics/identity lanes +# (src/ingestion/tests/e2e/lib/api_coverage.py); the spec universe is kept +# fresh by the openapi-specs drift gate. +on: + pull_request: + branches: [main] + paths: + - "src/backend/services/authenticator/**" + - "src/backend/services/fakeidp/**" + - "src/backend/libs/authenticator-sdk/**" + - "src/ingestion/tests/e2e/lib/api_coverage.py" + - "docs/components/backend/authenticator/openapi.json" + - ".github/workflows/authenticator.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + e2e: + name: e2e (8 suites) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install protoc + librdkafka build deps + # protobuf-compiler: grpc-hub -> prost-build. cmake + libcurl headers: + # the authenticator vendors librdkafka (audit events) via rdkafka's + # cmake-build; librdkafka's OAUTHBEARER path needs curl/curl.h. + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler cmake libcurl4-openssl-dev + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.95.0" + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src/backend -> target + key: authenticator-e2e + + - name: Run the authenticator e2e suite + # run-e2e.sh builds fakeidp + authenticator (release), boots the stack, + # runs every e2e_*.rs loop serially, and leaves the coverage ledger at + # $E2E_COVERAGE_LEDGER (tests/common/mod.rs writes it per response). + env: + E2E_COVERAGE_LEDGER: ${{ github.workspace }}/observed_authenticator_endpoints.json + run: src/backend/services/authenticator/tests/run-e2e.sh + + # Uploaded even if the suite failed, so the gate reports alongside a red + # suite (the ledger holds whatever ran before the failure). + - name: Upload coverage ledger + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: coverage-inputs-authenticator + path: observed_authenticator_endpoints.json + overwrite: true + # A failure before the first recorded request legitimately leaves no + # ledger; warn instead of masking the suite's own failure. + if-no-files-found: warn + retention-days: 7 + + # Pure-Python analysis of the ledger — no Docker, no Rust, no app boot. + # `!cancelled()` so it still reports when the suite itself failed. + authenticator-endpoint-coverage-gate: + name: Authenticator endpoint coverage gate + needs: e2e + if: ${{ !cancelled() && needs.e2e.result != 'skipped' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Checkout only for the gate script + the committed OpenAPI spec (the + # coverage universe — kept fresh by the openapi-specs drift gate). + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/download-artifact@v4 + with: + name: coverage-inputs-authenticator + path: coverage-inputs + - name: Analyse authenticator endpoint coverage + run: | + python3 src/ingestion/tests/e2e/lib/api_coverage.py \ + --suite authenticator \ + --observed coverage-inputs/observed_authenticator_endpoints.json \ + --spec docs/components/backend/authenticator/openapi.json | tee authenticator_coverage.md + rc=${PIPESTATUS[0]} + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then cat authenticator_coverage.md >> "$GITHUB_STEP_SUMMARY"; fi + if [ "$rc" -ne 0 ]; then echo "::error::Authenticator endpoint coverage gate failed — a spec operation is exercised by no test and not in SKIP_LIST (or a skip is stale). See the e2e suites (src/backend/services/authenticator/tests/)."; fi + exit "$rc" diff --git a/.github/workflows/openapi-specs.yml b/.github/workflows/openapi-specs.yml index 30288513e..112c01547 100644 --- a/.github/workflows/openapi-specs.yml +++ b/.github/workflows/openapi-specs.yml @@ -8,6 +8,8 @@ name: OpenAPI Specs # # • analytics — the offline `analytics openapi` subcommand (no DB, # no HTTP listener; see api::openapi_document). +# • authenticator — the offline `authenticator openapi` subcommand (no Redis, +# no IdP; see api::openapi_document), same shape as analytics. # • identity — the served GET /openapi.json, dumped by its integration test # (Testcontainers MariaDB); the drift comparison is external (this workflow), # so the test project itself has no docs/ coupling. @@ -54,6 +56,43 @@ jobs: if [ "$rc" -ne 0 ]; then echo "::error::OpenAPI spec drift — docs/components/backend/analytics/openapi.json is stale. Regenerate: (cd src/backend && cargo run -p analytics -- openapi) > docs/components/backend/analytics/openapi.json"; fi exit "$rc" + # ─── authenticator — offline emit via the `openapi` subcommand ──────────── + authenticator: + name: Authenticator OpenAPI spec drift gate + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Install protoc + librdkafka build deps + # protobuf-compiler: grpc-hub -> prost-build. cmake + libcurl headers: + # the authenticator vendors librdkafka (audit events) via rdkafka's + # cmake-build; librdkafka's OAUTHBEARER path needs curl/curl.h. + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler cmake libcurl4-openssl-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src/backend -> target + key: authenticator-openapi + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Generate the OpenAPI doc offline and drift-check it + env: + AUTHENTICATOR_SPEC: docs/components/backend/authenticator/openapi.json + run: | + # `authenticator openapi` builds offline and prints the doc; a build + # failure here should abort loudly under the runner's default `set -e`. + (cd src/backend && cargo run --quiet -p authenticator --bin authenticator -- openapi) > authenticator.openapi.live.json + # Relax errexit from here: we capture the drift-check rc ourselves, + # write the committed-vs-generated diff to the step summary, and emit a + # friendly ::error:: before exiting non-zero. + set +e + python3 scripts/ci/openapi_spec.py check --file "$AUTHENTICATOR_SPEC" --live-file authenticator.openapi.live.json | tee authenticator_openapi_drift.txt + rc=${PIPESTATUS[0]} + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo '```'; cat authenticator_openapi_drift.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY"; fi + if [ "$rc" -ne 0 ]; then echo "::error::Authenticator OpenAPI spec drift — $AUTHENTICATOR_SPEC is stale. Regenerate: (cd src/backend && cargo run -p authenticator --bin authenticator -- openapi) > dump.json && python3 scripts/ci/openapi_spec.py update --file $AUTHENTICATOR_SPEC --live-file dump.json"; fi + exit "$rc" + # ─── identity — serve GET /openapi.json (integration test) + external diff ── # The test boots the API against a Testcontainers MariaDB, fetches # /openapi.json, and writes it to IDENTITY_OPENAPI_DUMP; openapi_spec.py then diff --git a/docs/components/backend/authenticator/openapi.json b/docs/components/backend/authenticator/openapi.json new file mode 100644 index 000000000..a0f96933c --- /dev/null +++ b/docs/components/backend/authenticator/openapi.json @@ -0,0 +1,435 @@ +{ + "components": { + "schemas": { + "Problem": { + "description": "RFC 9457 problem+json. `context` varies by error category.", + "properties": { + "context": { + "type": "object" + }, + "detail": { + "type": "string" + }, + "instance": { + "type": "string" + }, + "status": { + "format": "int32", + "type": "integer" + }, + "title": { + "type": "string" + }, + "trace_id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "context" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "OIDC login, opaque sessions, and the cookie-to-JWT exchange behind the nginx gateway (the BFF / token-handler pattern). The gateway proxies /auth/* to this service and calls /internal/authz as its auth_request target; /.well-known/* serves the discovery document and JWKS downstream verifiers consume.", + "title": "Authenticator API", + "version": "1.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/.well-known/jwks.json": { + "get": { + "operationId": "authenticator.jwks", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "JWKS document" + } + }, + "summary": "Public JWKS for gateway-JWT verification", + "tags": [ + "internal" + ] + } + }, + "/.well-known/openid-configuration": { + "get": { + "operationId": "authenticator.openid_configuration", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "OIDC discovery document" + } + }, + "summary": "OIDC discovery document (issuer + jwks_uri) for downstream verifiers", + "tags": [ + "internal" + ] + } + }, + "/auth/admin/users/{person_id}/sessions": { + "delete": { + "operationId": "authenticator.sessions.admin_revoke_by_user", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Revocation result" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Revoke every session of a user (admin/service, gateway-JWT authenticated)", + "tags": [ + "auth" + ] + } + }, + "/auth/callback": { + "get": { + "operationId": "authenticator.callback", + "responses": { + "302": { + "description": "Redirect to the SPA with the session cookie set" + } + }, + "summary": "Complete login: exchange the code and set the session cookie", + "tags": [ + "auth" + ] + } + }, + "/auth/csrf": { + "get": { + "operationId": "authenticator.csrf", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "CSRF token" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Issue the CSRF token bound to the current session", + "tags": [ + "auth" + ] + } + }, + "/auth/login": { + "get": { + "operationId": "authenticator.login", + "responses": { + "302": { + "description": "Redirect to the IdP authorize endpoint" + } + }, + "summary": "Start the OIDC code+PKCE login flow", + "tags": [ + "auth" + ] + } + }, + "/auth/logout": { + "post": { + "operationId": "authenticator.logout", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "RP-logout URL" + } + }, + "summary": "Revoke the session, clear the cookie, return the RP-logout URL", + "tags": [ + "auth" + ] + } + }, + "/auth/me": { + "get": { + "operationId": "authenticator.me", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Session summary" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Current session summary for the SPA", + "tags": [ + "auth" + ] + } + }, + "/auth/oidc/back-channel-logout": { + "post": { + "operationId": "authenticator.back_channel_logout", + "responses": { + "200": { + "description": "Logout processed (or idempotent replay)" + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Receive IdP back-channel logout tokens (OIDC BCL 1.0)", + "tags": [ + "auth" + ] + } + }, + "/auth/refresh": { + "post": { + "operationId": "authenticator.refresh", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "{expires_at, refresh_at} + re-issued cookie" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Rotate the session cookie and extend the session (grace-tolerant)", + "tags": [ + "auth" + ] + } + }, + "/auth/sessions": { + "delete": { + "operationId": "authenticator.sessions.revoke_all", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Revocation result" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Revoke all sessions of the current user (log out everywhere)", + "tags": [ + "auth" + ] + }, + "get": { + "operationId": "authenticator.sessions.list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Active sessions" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "List the current user's active sessions", + "tags": [ + "auth" + ] + } + }, + "/auth/sessions/{session_id}": { + "delete": { + "operationId": "authenticator.sessions.revoke", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "Revocation result" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + } + }, + "summary": "Revoke one of the current user's sessions", + "tags": [ + "auth" + ] + } + }, + "/internal/authz": { + "get": { + "operationId": "authenticator.authz", + "responses": { + "200": { + "description": "JWT attached via X-Gateway-Jwt" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Exchange the session cookie for the linked gateway JWT", + "tags": [ + "internal" + ] + } + } + } +} diff --git a/src/backend/services/authenticator/src/api/mod.rs b/src/backend/services/authenticator/src/api/mod.rs index d7c691841..1a9c7be78 100644 --- a/src/backend/services/authenticator/src/api/mod.rs +++ b/src/backend/services/authenticator/src/api/mod.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use axum::http::StatusCode; use axum::{Extension, Router}; -use toolkit::api::{OpenApiRegistry, OperationBuilder}; +use toolkit::api::{OpenApiInfo, OpenApiRegistry, OpenApiRegistryImpl, OperationBuilder}; use crate::config::AuthenticatorConfig; use crate::identity::PersonResolver; @@ -131,6 +131,7 @@ fn register_auth_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router .tag("auth") .public() .no_content_response(StatusCode::OK, "Logout processed (or idempotent replay)") + .error_400(openapi) .handler(handlers::back_channel_logout) .register(router, openapi); @@ -238,3 +239,49 @@ fn register_well_known_routes(router: Router, openapi: &dyn OpenApiRegistry) -> .handler(handlers::jwks) .register(router, openapi) } + +fn openapi_info() -> OpenApiInfo { + OpenApiInfo { + title: "Authenticator API".to_owned(), + version: "1.0.0".to_owned(), + description: Some( + "OIDC login, opaque sessions, and the cookie-to-JWT exchange behind \ + the nginx gateway (the BFF / token-handler pattern). The gateway \ + proxies /auth/* to this service and calls /internal/authz as its \ + auth_request target; /.well-known/* serves the discovery document \ + and JWKS downstream verifiers consume." + .to_owned(), + ), + servers: Vec::new(), + } +} + +/// Build the authenticator `OpenAPI` document **offline** — no `AppState`, +/// Redis, IdP, or HTTP listener. Backs the `authenticator openapi` subcommand +/// (committed-doc regeneration + drift gate), reusing the exact +/// `build_operations` route table the live gear serves, so the two can never +/// diverge. The service-token listener (`POST /internal/token`, DD-AUTH-05) is +/// a separate raw-axum port outside `build_operations`, so it is deliberately +/// absent here. +pub fn openapi_document() -> anyhow::Result { + let openapi = OpenApiRegistryImpl::new(); + let _ = build_operations(Router::new(), &openapi); + openapi + .build_openapi(&openapi_info()) + .map_err(|e| anyhow::anyhow!("failed to build authenticator OpenAPI document: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Exercises the full route table + `OpenAPI` registration with no + /// `AppState`, Redis, or IdP: handlers are only *registered* (state comes + /// in via `Extension` at serve time), never invoked. Guards against + /// overlapping-route panics / bad `OperationBuilder` state. + #[test] + fn build_operations_registers_the_full_table_without_state() { + let openapi = OpenApiRegistryImpl::new(); + let _router: Router = build_operations(Router::new(), &openapi); + } +} diff --git a/src/backend/services/authenticator/src/main.rs b/src/backend/services/authenticator/src/main.rs index 3c5969b52..b6221b7a7 100644 --- a/src/backend/services/authenticator/src/main.rs +++ b/src/backend/services/authenticator/src/main.rs @@ -87,6 +87,11 @@ enum Commands { Run, /// Validate configuration and exit. Check, + /// Print the OpenAPI document to stdout and exit. Built offline from the + /// route table — no Redis, IdP, or config needed. Used to regenerate + /// docs/components/backend/authenticator/openapi.json and to drift-check + /// it in CI. + Openapi, } #[tokio::main] @@ -108,5 +113,16 @@ async fn main() -> Result<()> { println!("configuration OK"); Ok(()) } + // Emit the OpenAPI document offline (no backends) — see `print_openapi`. + Commands::Openapi => print_openapi(), } } + +/// Print the authenticator `OpenAPI` document as pretty JSON. Offline — see +/// [`api::openapi_document`]. No config or backends are touched, and no logging +/// subscriber is initialized on this path, so stdout stays pure JSON. +fn print_openapi() -> Result<()> { + let doc = api::openapi_document()?; + println!("{}", serde_json::to_string_pretty(&doc)?); + Ok(()) +} diff --git a/src/backend/services/authenticator/tests/.gitignore b/src/backend/services/authenticator/tests/.gitignore new file mode 100644 index 000000000..588536b0c --- /dev/null +++ b/src/backend/services/authenticator/tests/.gitignore @@ -0,0 +1,2 @@ +# endpoint-coverage ledger written by run-e2e.sh / tests/common/mod.rs +.artifacts/ diff --git a/src/backend/services/authenticator/tests/common/mod.rs b/src/backend/services/authenticator/tests/common/mod.rs new file mode 100644 index 000000000..591aa880e --- /dev/null +++ b/src/backend/services/authenticator/tests/common/mod.rs @@ -0,0 +1,175 @@ +//! Shared e2e test support: an HTTP client that mirrors the `reqwest` builder +//! surface and records every response from the authenticator under test into +//! the endpoint-coverage ledger. +//! +//! The ledger lands at `$E2E_COVERAGE_LEDGER` (run-e2e.sh sets it; unset means +//! recording is off and the client is a plain passthrough). Its schema matches +//! the bronze-to-api rig's `observed_endpoints.json` — a JSON list of +//! `{method, path, statuses}` rows — so the same gate script consumes it: +//! `src/ingestion/tests/e2e/lib/api_coverage.py --suite authenticator`. +//! +//! Only requests whose origin matches `$AUTH_BASE` / `$AUTH_BASE_DISABLED` +//! (the two authenticator instances) are recorded: the same client also talks +//! to fakeidp and the service-token listener, and those must not pollute the +//! ledger the authenticator spec is matched against. Each `cargo test --test +//! e2e_*` invocation is its own process, and run-e2e.sh runs them serially, so +//! the read-merge-write below needs no cross-process locking; the in-process +//! mutex covers concurrent tests within one binary. + +// Each integration-test crate compiles its own copy of this module and none +// uses the full surface. +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Mutex; + +static LEDGER_LOCK: Mutex<()> = Mutex::new(()); + +/// A `reqwest::Client` that records `(method, path) -> {status}` on `send()`. +#[derive(Clone)] +pub struct Client { + inner: reqwest::Client, +} + +/// The e2e default client: redirects OFF, so 302s from `/auth/login`, +/// `/authorize`, and `/auth/callback` are observed rather than followed. +pub fn client() -> Client { + Client { + inner: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("reqwest client must build"), + } +} + +impl Client { + pub fn get(&self, url: impl reqwest::IntoUrl) -> RequestBuilder { + self.request(reqwest::Method::GET, url) + } + + pub fn post(&self, url: impl reqwest::IntoUrl) -> RequestBuilder { + self.request(reqwest::Method::POST, url) + } + + pub fn delete(&self, url: impl reqwest::IntoUrl) -> RequestBuilder { + self.request(reqwest::Method::DELETE, url) + } + + pub fn request(&self, method: reqwest::Method, url: impl reqwest::IntoUrl) -> RequestBuilder { + RequestBuilder { + client: self.inner.clone(), + inner: self.inner.request(method, url), + } + } +} + +/// Thin wrapper over `reqwest::RequestBuilder`; `send()` returns the plain +/// `reqwest::Response`, so call sites downstream of `send()` are untouched. +pub struct RequestBuilder { + client: reqwest::Client, + inner: reqwest::RequestBuilder, +} + +impl RequestBuilder { + pub fn header(self, key: impl AsRef, value: impl AsRef) -> Self { + Self { + inner: self.inner.header(key.as_ref(), value.as_ref()), + ..self + } + } + + pub fn json(self, json: &T) -> Self { + Self { + inner: self.inner.json(json), + ..self + } + } + + pub fn form(self, form: &T) -> Self { + Self { + inner: self.inner.form(form), + ..self + } + } + + pub async fn send(self) -> reqwest::Result { + // Build first so the request's final method + URL are readable; a + // build error surfaces exactly like reqwest's own send() would. + let request = self.inner.build()?; + let method = request.method().clone(); + let url = request.url().clone(); + let response = self.client.execute(request).await?; + record(&method, &url, response.status().as_u16()); + Ok(response) + } +} + +/// Merge one observation into the ledger file, if recording is on and the +/// request targeted an authenticator instance. +fn record(method: &reqwest::Method, url: &reqwest::Url, status: u16) { + let Ok(ledger) = std::env::var("E2E_COVERAGE_LEDGER") else { + return; + }; + if !is_authenticator_origin(url) { + return; + } + let _guard = LEDGER_LOCK.lock().expect("ledger lock"); + + // (method, path) -> statuses; same row shape api_coverage._dump writes. + let mut merged: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + if let Ok(existing) = std::fs::read_to_string(&ledger) + && let Ok(rows) = serde_json::from_str::>(&existing) + { + for row in rows { + let (Some(m), Some(p), Some(statuses)) = ( + row["method"].as_str(), + row["path"].as_str(), + row["statuses"].as_array(), + ) else { + continue; + }; + merged + .entry((m.to_owned(), p.to_owned())) + .or_default() + .extend( + statuses + .iter() + .filter_map(|s| s.as_u64().and_then(|v| u16::try_from(v).ok())), + ); + } + } + merged + .entry((method.as_str().to_owned(), url.path().to_owned())) + .or_default() + .insert(status); + + let rows: Vec = merged + .into_iter() + .map(|((m, p), statuses)| { + serde_json::json!({ + "method": m, + "path": p, + "statuses": statuses.into_iter().collect::>(), + }) + }) + .collect(); + let mut out = serde_json::to_string_pretty(&rows).expect("ledger serializes"); + out.push('\n'); + if let Some(parent) = std::path::Path::new(&ledger).parent() { + let _ = std::fs::create_dir_all(parent); + } + std::fs::write(&ledger, out).expect("ledger write must succeed"); +} + +/// True when `url` targets one of the authenticator instances under test +/// (`AUTH_BASE`, and `AUTH_BASE_DISABLED` — `e2e_override`'s second instance). +/// `AUTH_BASE` falls back to the same default the tests themselves use. +fn is_authenticator_origin(url: &reqwest::Url) -> bool { + let auth_base = + std::env::var("AUTH_BASE").unwrap_or_else(|_| "http://localhost:8083".to_owned()); + [Some(auth_base), std::env::var("AUTH_BASE_DISABLED").ok()] + .into_iter() + .flatten() + .filter_map(|base| reqwest::Url::parse(&base).ok()) + .any(|base| base.origin() == url.origin()) +} diff --git a/src/backend/services/authenticator/tests/e2e_backchannel.rs b/src/backend/services/authenticator/tests/e2e_backchannel.rs index c0d210f33..6d40ed917 100644 --- a/src/backend/services/authenticator/tests/e2e_backchannel.rs +++ b/src/backend/services/authenticator/tests/e2e_backchannel.rs @@ -16,17 +16,16 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::doc_markdown)] +mod common; + const COOKIE: &str = "__Host-sid"; fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -53,7 +52,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { None } -async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { +async fn login(http: &common::Client, auth_base: &str, user: &str) -> String { let login = http .get(format!("{auth_base}/auth/login")) .send() @@ -78,7 +77,7 @@ async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { cookie_from(&cb).expect("callback must set __Host-sid") } -async fn authz_status(http: &reqwest::Client, auth_base: &str, token: &str) -> u16 { +async fn authz_status(http: &common::Client, auth_base: &str, token: &str) -> u16 { http.get(format!("{auth_base}/internal/authz")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) .send() diff --git a/src/backend/services/authenticator/tests/e2e_login_loop.rs b/src/backend/services/authenticator/tests/e2e_login_loop.rs index e3911f37d..3d35cfa62 100644 --- a/src/backend/services/authenticator/tests/e2e_login_loop.rs +++ b/src/backend/services/authenticator/tests/e2e_login_loop.rs @@ -20,6 +20,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::too_many_lines)] +mod common; + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; use serde::Deserialize; @@ -29,11 +31,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -182,6 +181,27 @@ async fn full_login_exchange_logout_loop() { assert!(!claims.sid.is_empty(), "stable sid present"); let _ = &claims.tenant_id; // present (may be empty in a keyless local run) + // 5b. The discovery document points downstream verifiers at that JWKS + // (cf-gears-oidc-authn-plugin resolves jwks_uri from it). + let discovery: serde_json::Value = http + .get(format!("{auth_base}/.well-known/openid-configuration")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + discovery["issuer"].as_str().is_some_and(|s| !s.is_empty()), + "discovery must carry the issuer" + ); + assert!( + discovery["jwks_uri"] + .as_str() + .is_some_and(|s| s.ends_with("/.well-known/jwks.json")), + "discovery must point at the published JWKS" + ); + // 6. /auth/me returns the session summary. let me = http .get(format!("{auth_base}/auth/me")) diff --git a/src/backend/services/authenticator/tests/e2e_override.rs b/src/backend/services/authenticator/tests/e2e_override.rs index a10a25a98..4e980601f 100644 --- a/src/backend/services/authenticator/tests/e2e_override.rs +++ b/src/backend/services/authenticator/tests/e2e_override.rs @@ -15,6 +15,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; @@ -24,11 +26,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn cookie_from(resp: &reqwest::Response) -> Option { @@ -49,7 +48,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { /// on `/auth/login`. Returns the callback response (302 + cookie on success, /// the error status otherwise). async fn login_flow( - http: &reqwest::Client, + http: &common::Client, auth_base: &str, user: &str, override_email: Option<&str>, @@ -82,7 +81,7 @@ fn urlencode(s: &str) -> String { s.replace('@', "%40").replace('+', "%2B") } -async fn me(http: &reqwest::Client, auth_base: &str, token: &str) -> serde_json::Value { +async fn me(http: &common::Client, auth_base: &str, token: &str) -> serde_json::Value { let resp = http .get(format!("{auth_base}/auth/me")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) @@ -94,7 +93,7 @@ async fn me(http: &reqwest::Client, auth_base: &str, token: &str) -> serde_json: } /// The JWT `sub` behind a session cookie, via the `/internal/authz` exchange. -async fn jwt_sub(http: &reqwest::Client, auth_base: &str, token: &str) -> String { +async fn jwt_sub(http: &common::Client, auth_base: &str, token: &str) -> String { let resp = http .get(format!("{auth_base}/internal/authz")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) @@ -175,7 +174,7 @@ async fn override_mints_the_session_for_the_target_person() { } /// The session-bound CSRF token (state-changing `/auth/*` requires it). -async fn csrf_token(http: &reqwest::Client, auth_base: &str, token: &str) -> String { +async fn csrf_token(http: &common::Client, auth_base: &str, token: &str) -> String { let resp = http .get(format!("{auth_base}/auth/csrf")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) diff --git a/src/backend/services/authenticator/tests/e2e_ratelimit.rs b/src/backend/services/authenticator/tests/e2e_ratelimit.rs index ff97e17fb..33f96a798 100644 --- a/src/backend/services/authenticator/tests/e2e_ratelimit.rs +++ b/src/backend/services/authenticator/tests/e2e_ratelimit.rs @@ -12,6 +12,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use serde::Deserialize; const COOKIE: &str = "__Host-sid"; @@ -20,11 +22,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -51,7 +50,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { None } -async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { +async fn login(http: &common::Client, auth_base: &str, user: &str) -> String { let login = http .get(format!("{auth_base}/auth/login")) .send() @@ -76,7 +75,7 @@ async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { cookie_from(&cb).expect("callback must set __Host-sid") } -async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { +async fn get_csrf(http: &common::Client, auth_base: &str, token: &str) -> String { #[derive(Deserialize)] struct CsrfBody { csrf_token: String, @@ -93,7 +92,7 @@ async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> Strin /// One refresh attempt; returns (status, rotated cookie when present). async fn refresh( - http: &reqwest::Client, + http: &common::Client, auth_base: &str, token: &str, csrf: &str, diff --git a/src/backend/services/authenticator/tests/e2e_refresh.rs b/src/backend/services/authenticator/tests/e2e_refresh.rs index 3bc4e5195..317c18b92 100644 --- a/src/backend/services/authenticator/tests/e2e_refresh.rs +++ b/src/backend/services/authenticator/tests/e2e_refresh.rs @@ -15,6 +15,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use serde::Deserialize; const COOKIE: &str = "__Host-sid"; @@ -23,11 +25,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -55,7 +54,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { } /// Run the full fakeidp login loop; returns the session cookie token. -async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { +async fn login(http: &common::Client, auth_base: &str, user: &str) -> String { let login = http .get(format!("{auth_base}/auth/login")) .send() @@ -81,7 +80,7 @@ async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { } /// Fetch the session's CSRF token (state-changing /auth/* requires it, 10.5). -async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { +async fn get_csrf(http: &common::Client, auth_base: &str, token: &str) -> String { #[derive(Deserialize)] struct CsrfBody { csrf_token: String, @@ -124,7 +123,7 @@ fn jwt_sid(bearer: &str) -> String { serde_json::from_slice::(&bytes).unwrap().sid } -async fn authz_sid(http: &reqwest::Client, auth_base: &str, token: &str) -> Option { +async fn authz_sid(http: &common::Client, auth_base: &str, token: &str) -> Option { let resp = http .get(format!("{auth_base}/internal/authz")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) diff --git a/src/backend/services/authenticator/tests/e2e_refresher.rs b/src/backend/services/authenticator/tests/e2e_refresher.rs index 20deecd9b..1e50767bf 100644 --- a/src/backend/services/authenticator/tests/e2e_refresher.rs +++ b/src/backend/services/authenticator/tests/e2e_refresher.rs @@ -16,6 +16,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::doc_markdown)] +mod common; + use std::time::Duration; const COOKIE: &str = "__Host-sid"; @@ -24,11 +26,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -55,7 +54,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { None } -async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { +async fn login(http: &common::Client, auth_base: &str, user: &str) -> String { let login = http .get(format!("{auth_base}/auth/login")) .send() @@ -80,7 +79,7 @@ async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { cookie_from(&cb).expect("callback must set __Host-sid") } -async fn authz_status(http: &reqwest::Client, auth_base: &str, token: &str) -> u16 { +async fn authz_status(http: &common::Client, auth_base: &str, token: &str) -> u16 { http.get(format!("{auth_base}/internal/authz")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) .send() @@ -90,7 +89,7 @@ async fn authz_status(http: &reqwest::Client, auth_base: &str, token: &str) -> u .as_u16() } -async fn control(http: &reqwest::Client, idp: &str, path: &str, body: Option) { +async fn control(http: &common::Client, idp: &str, path: &str, body: Option) { let req = http.post(format!("{idp}{path}")); let req = match body { Some(json) => req.json(&json), @@ -106,7 +105,7 @@ async fn control(http: &reqwest::Client, idp: &str, path: &str, body: Option Claims { - let http = reqwest::Client::new(); + let http = common::client(); let jwks: Jwks = http .get(format!("{}/.well-known/jwks.json", auth_base())) .send() diff --git a/src/backend/services/authenticator/tests/e2e_sessions.rs b/src/backend/services/authenticator/tests/e2e_sessions.rs index 4a29c34e0..a5c2ac95f 100644 --- a/src/backend/services/authenticator/tests/e2e_sessions.rs +++ b/src/backend/services/authenticator/tests/e2e_sessions.rs @@ -15,6 +15,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] +mod common; + use serde::Deserialize; const COOKIE: &str = "__Host-sid"; @@ -23,11 +25,8 @@ fn env(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_owned()) } -fn client() -> reqwest::Client { - reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() +fn client() -> common::Client { + common::client() } fn rewrite_host(url: &str) -> String { @@ -55,7 +54,7 @@ fn cookie_from(resp: &reqwest::Response) -> Option { } /// Fetch the session's CSRF token (state-changing /auth/* requires it, 10.5). -async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> String { +async fn get_csrf(http: &common::Client, auth_base: &str, token: &str) -> String { #[derive(Deserialize)] struct CsrfBody { csrf_token: String, @@ -71,7 +70,7 @@ async fn get_csrf(http: &reqwest::Client, auth_base: &str, token: &str) -> Strin } /// Run the full fakeidp login loop; returns the session cookie token. -async fn login(http: &reqwest::Client, auth_base: &str, user: &str) -> String { +async fn login(http: &common::Client, auth_base: &str, user: &str) -> String { let login = http .get(format!("{auth_base}/auth/login")) .header(reqwest::header::USER_AGENT, "e2e-sessions-test") @@ -118,7 +117,7 @@ struct SessionsBody { sessions: Vec, } -async fn list(http: &reqwest::Client, auth_base: &str, token: &str) -> Vec { +async fn list(http: &common::Client, auth_base: &str, token: &str) -> Vec { let resp = http .get(format!("{auth_base}/auth/sessions")) .header(reqwest::header::COOKIE, format!("{COOKIE}={token}")) diff --git a/src/backend/services/authenticator/tests/run-e2e.sh b/src/backend/services/authenticator/tests/run-e2e.sh index d40f95957..7f3d23ed6 100755 --- a/src/backend/services/authenticator/tests/run-e2e.sh +++ b/src/backend/services/authenticator/tests/run-e2e.sh @@ -14,6 +14,16 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" cd "$HERE/../../.." # -> src/backend (the cargo workspace root) +# Endpoint-coverage ledger: tests/common/mod.rs records every test-client +# request against the authenticator into this file (merged across the serial +# cargo test invocations below). The endpoint coverage gate consumes it: +# python3 src/ingestion/tests/e2e/lib/api_coverage.py --suite authenticator \ +# --observed "$E2E_COVERAGE_LEDGER" \ +# --spec docs/components/backend/authenticator/openapi.json +# Reset it up front so dead coverage from a previous run can't survive. +export E2E_COVERAGE_LEDGER="${E2E_COVERAGE_LEDGER:-$HERE/.artifacts/observed_authenticator_endpoints.json}" +rm -f "$E2E_COVERAGE_LEDGER" + AUTH_PORT=8083 TOKEN_PORT=8093 AUTH2_PORT=8085 @@ -170,4 +180,4 @@ AUTH_BASE="http://localhost:$AUTH_PORT" \ SVC_KEY="$SVC_KEYS_DIR/testclient.key.pem" \ cargo test -p authenticator --test e2e_service_token -- --ignored --nocapture -echo "==> PASS" +echo "==> PASS (endpoint-coverage ledger: $E2E_COVERAGE_LEDGER)" diff --git a/src/ingestion/tests/e2e/lib/api_coverage.py b/src/ingestion/tests/e2e/lib/api_coverage.py old mode 100644 new mode 100755 index 5e621334a..3c96e4175 --- a/src/ingestion/tests/e2e/lib/api_coverage.py +++ b/src/ingestion/tests/e2e/lib/api_coverage.py @@ -116,9 +116,31 @@ # operation be legitimately unexercised on a Rust run — while the dotnet # suite (no such skip) still REQUIRES it, so a .NET regression can't hide. IDENTITY_RUST_SKIP_LIST: list[tuple[str, str]] = [ - ("GET /v1/persons/{email}", "dropped in the Rust successor (approved removal; tests skip via capabilities)"), + ("GET /v1/persons/{email}", "dropped in the Rust successor (approved removal; tests skip via capabilities)") ] +# ── authenticator suite (src/backend/services/authenticator/tests/, run by +# .github/workflows/authenticator.yml) ────────────────────────────────────── +# The Rust e2e harness records the ledger via tests/common/mod.rs (the same +# {method, path, statuses} schema this gate reads); the spec universe is the +# committed doc kept fresh by the openapi-specs drift gate. +AUTHENTICATOR_SKIP_LIST: list[tuple[str, str]] = [ + ( + "DELETE /auth/admin/users/{person_id}/sessions", + "needs the gateway-JWT authn pipeline (TLS discovery front); exercised " + "in the gateway compose e2e instead (see e2e_sessions.rs)", + ) +] +# The authenticator spec declares codes intentionally (no `.standard_errors` +# stamping), so there is no universal boilerplate to subtract. Its rate +# limiter's 429s are real but undeclared — extra observed codes are ignored. +AUTHENTICATOR_UNIVERSAL_BOILERPLATE = frozenset() +# back-channel-logout's 200 is answered to the IdP's server-side POST (proven +# via fakeidp's rp_status assertion in e2e_backchannel), never to the test +# client — the client can only observe the 400 rejection. +AUTHENTICATOR_BLOCKED: dict[str, frozenset[int]] = {"POST /auth/oidc/back-channel-logout": frozenset({200})} +AUTHENTICATOR_REQUIRED_EXTRA: dict[str, frozenset[int]] = {} + # Codes the suite must observe DESPITE the spec not declaring them (a known # spec-fidelity gap, per suite). Unlike ordinary uncovered codes (advisory), # a missing REQUIRED_EXTRA code BLOCKS — it exists precisely because the @@ -140,18 +162,19 @@ _ANALYTICS_UNIVERSAL_BOILERPLATE, _ANALYTICS_REQUIRED_EXTRA, ), - "identity": ( - IDENTITY_SKIP_LIST, - IDENTITY_BLOCKED, - IDENTITY_UNIVERSAL_BOILERPLATE, - IDENTITY_REQUIRED_EXTRA, - ), + "identity": (IDENTITY_SKIP_LIST, IDENTITY_BLOCKED, IDENTITY_UNIVERSAL_BOILERPLATE, IDENTITY_REQUIRED_EXTRA), "identity-rust": ( IDENTITY_RUST_SKIP_LIST, IDENTITY_BLOCKED, IDENTITY_UNIVERSAL_BOILERPLATE, IDENTITY_REQUIRED_EXTRA, ), + "authenticator": ( + AUTHENTICATOR_SKIP_LIST, + AUTHENTICATOR_BLOCKED, + AUTHENTICATOR_UNIVERSAL_BOILERPLATE, + AUTHENTICATOR_REQUIRED_EXTRA, + ), } @@ -228,15 +251,10 @@ def _dump(path: str | Path, observed: dict[tuple[str, str], set[int]]) -> Path: merged: dict[tuple[str, str], set[int]] = {} if out.exists(): for row in json.loads(out.read_text(encoding="utf-8")): - merged.setdefault((row["method"], row["path"]), set()).update( - int(s) for s in row["statuses"] - ) + merged.setdefault((row["method"], row["path"]), set()).update(int(s) for s in row["statuses"]) for key, codes in observed.items(): merged.setdefault(key, set()).update(codes) - rows = [ - {"method": m, "path": p, "statuses": sorted(codes)} - for (m, p), codes in sorted(merged.items()) - ] + rows = [{"method": m, "path": p, "statuses": sorted(codes)} for (m, p), codes in sorted(merged.items())] out.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8") return out @@ -260,9 +278,7 @@ def spec_operations(spec: dict) -> dict[str, list[int]]: for method, op in methods.items(): if method.lower() not in _HTTP_METHODS: continue - codes = sorted( - int(c) for c in (op.get("responses") or {}) if str(c).isdigit() - ) + codes = sorted(int(c) for c in (op.get("responses") or {}) if str(c).isdigit()) ops[f"{method.upper()} {path}"] = codes return ops @@ -295,10 +311,7 @@ def match_observed(observed: list[dict], spec_ops: dict[str, list[int]]) -> tupl for tmpl, tmpl_segs in spec_paths.get(method, []): if len(tmpl_segs) != len(obs_segs): continue - if all( - t.startswith("{") and t.endswith("}") or t == o - for t, o in zip(tmpl_segs, obs_segs) - ): + if all(t.startswith("{") and t.endswith("}") or t == o for t, o in zip(tmpl_segs, obs_segs)): hit = f"{method} {tmpl}" break if hit is None: @@ -355,15 +368,11 @@ def __post_init__(self) -> None: # coverable codes (required_codes = declared − 5xx − boilerplate − # BLOCKED[op]), how many the suite observed. The `·`/excluded codes are # not in the denominator. - self.covered_codes: dict[str, set[int]] = { - op: self.required[op] & self.validated.get(op, set()) for op in ops - } + self.covered_codes: dict[str, set[int]] = {op: self.required[op] & self.validated.get(op, set()) for op in ops} self.total_coverable = sum(len(c) for c in self.required.values()) self.total_covered = sum(len(c) for c in self.covered_codes.values()) self.coverage_pct = ( - 100.0 - if self.total_coverable == 0 - else round(100.0 * self.total_covered / self.total_coverable, 1) + 100.0 if self.total_coverable == 0 else round(100.0 * self.total_covered / self.total_coverable, 1) ) def required_codes(self, op: str) -> set[int]: @@ -374,11 +383,9 @@ def required_codes(self, op: str) -> set[int]: exercised.""" declared = self.spec_ops.get(op, []) excluded = BLOCKED.get(op, frozenset()) - return ( - {c for c in declared if c < SERVER_FAULT_FLOOR} - - UNIVERSAL_BOILERPLATE - - set(excluded) - ) | set(REQUIRED_EXTRA.get(op, frozenset())) + return ({c for c in declared if c < SERVER_FAULT_FLOOR} - UNIVERSAL_BOILERPLATE - set(excluded)) | set( + REQUIRED_EXTRA.get(op, frozenset()) + ) @property def passed(self) -> bool: @@ -431,8 +438,7 @@ def gate_violations(r: CoverageReport) -> list[str]: unseen = set(extra) - r.validated.get(op, set()) if unseen: out.append( - f"MISSING REQUIRED_EXTRA: {op} never answered {sorted(unseen)} — the " - f"mutation success path is unproven" + f"MISSING REQUIRED_EXTRA: {op} never answered {sorted(unseen)} — the mutation success path is unproven" ) return out @@ -463,8 +469,7 @@ def render_markdown(r: CoverageReport) -> str: # the REQUIRED_EXTRA codes — enforced despite not being declared, so a # reader can see them in the matrix instead of only in the violations text. all_codes = sorted( - {c for codes in r.spec_ops.values() for c in codes} - | {c for codes in REQUIRED_EXTRA.values() for c in codes} + {c for codes in r.spec_ops.values() for c in codes} | {c for codes in REQUIRED_EXTRA.values() for c in codes} ) lines = [ "# API endpoint coverage — by method+path", @@ -512,10 +517,10 @@ def render_markdown(r: CoverageReport) -> str: lines += ["", "## Excluded from coverage (`·` — declared but not coverable)", ""] lines += [ f"_Server-fault 5xx (500) and UNIVERSAL_BOILERPLATE {sorted(UNIVERSAL_BOILERPLATE)} " - "(auth disabled / no rate limiter) are excluded on every route. The committed spec " - "is the `.standard_errors` boilerplate, so most per-op exclusions below are " - "over-declared codes the handler cannot answer (a SPEC BUG, #1669); the rest are " - "rig/product (#1663, #1664):_", + "are excluded on every route. Per-op exclusions below are declared codes the suite " + "cannot observe — spec over-declaration (`.standard_errors` boilerplate, #1669) or a " + "pinned rig/product limitation; each entry's rationale lives beside it in this " + "suite's BLOCKED table (api_coverage.py):_", "", ] for op in sorted(BLOCKED): @@ -550,7 +555,7 @@ def main() -> int: observed_path = Path(args.observed) if not observed_path.exists(): - print( + print( # noqa: T201 — CLI diagnostic on stderr f"ERROR: {observed_path} not found — the e2e suite must run first " f"(it writes the ledger at pytest_sessionfinish)", file=sys.stderr,