CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' into test-oauth-client

Reconciles 71 commits of main evolution (labeler report stage + supporting
refactors) with the 62-commit test-oauth-client branch (OAuth client
conformance suite).

Notable merge decisions:

- src/common/report.rs: kept the newtype `Stage(pub &'static str)` from
the OAuth client branch (extensible across commands without a shared
enum) and added the `Stage::REPORT` constant for the labeler report
stage. Dropped main's enum-form Stage and the `report_stage_ordering`
test that depended on it.
- src/common.rs, src/common/CLAUDE.md: union of both branches' modules —
`diagnostics`, `identity`, `jwt` (main), `oauth` (this branch), and
`report` all live side-by-side. The hand-rolled `common::jwt`
(labeler report service-auth) and the `jsonwebtoken`-backed
`common::oauth::jws` (OAuth client RP) are separate surfaces and do
not share code.
- tests/common/mod.rs: adopted main's rewritten `normalize_timing`
(emits `XXms`, advances past each match) alongside this branch's
`FakeJwksFetcher` / `FakeClock`. All 68 snapshots carrying
`elapsed: Xms` updated to `elapsed: XXms` to match.
- src/commands/test/labeler/pipeline.rs, crypto.rs, create_report.rs,
identity.rs: updated the report-stage additions from main to import
`Stage` / `CheckResult` / `CheckStatus` from `crate::common::report`
(this branch promoted that module out of the labeler tree) and to
reference the stage consts `Stage::IDENTITY` / `Stage::REPORT`
instead of enum variants.
- tests/labeler_identity.rs: dropped the local `normalize_timing`
helper in favour of `common::normalize_timing`. The
`local_http_override_mismatch_is_advisory` test added by main was
adapted to the `&Url` seam this branch introduced on
`FakeHttpClient::add_response`.
- Labeler snapshots: accepted new output where main's report-stage work
and this branch's `blocked_by(..., check_id)` helper combine — the
upstream-failure messages now consistently render as
`blocked by identity::<check>` rather than the older
`blocked by identity stage failures` phrasing.

Verified with `cargo test`, `cargo clippy --all-targets -- -D warnings`,
and `cargo fmt --check`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

+14384 -325
+23
CHANGELOG.md
··· 1 + # Changelog 2 + All notable changes will be documented in this file. The format is based on 3 + [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 + and this project attempts to adhere to Rust's notion of 5 + [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 + 7 + ## [Unreleased] 8 + 9 + ## [0.1.1] - 2026-04-21 10 + ### Added 11 + - `atproto-devtool test labeler` now has an additional stage testing report 12 + creation, gated behind the new `--commit-report` feature flag. This should 13 + only be used when testing labelers that you control. 14 + 15 + ### Fixed 16 + - `atproto-devtool test labeler` now works with locally-hosted labelers for 17 + testing before deployment. 18 + 19 + ## [0.1.0] - 2026-04-16 20 + Initial release 21 + 22 + ### Added 23 + - `atproto-devtool test labeler`
+42 -22
CLAUDE.md
··· 4 4 5 5 Single-crate Rust 2024 binary providing developer tooling for the atproto 6 6 ecosystem. Shipped subcommands: `test labeler` (conformance suite against an 7 - atproto labeler) and `test oauth client` (conformance suite for an atproto 8 - OAuth client — static checks plus an optional interactive mode that runs an 9 - in-process fake authorization server). 7 + atproto labeler, including an optional report-submission stage) and `test 8 + oauth client` (conformance suite for an atproto OAuth client — static checks 9 + plus an optional interactive mode that runs an in-process fake authorization 10 + server). 10 11 11 12 ## Tech stack 12 13 ··· 17 18 - Async runtime: tokio (rt + macros + time + net + signal — not full). 18 19 - HTTP: reqwest with rustls. WebSockets: tokio-tungstenite with 19 20 rustls-tls-native-roots. In-process HTTP server (fake AS for the oauth 20 - client interactive mode): axum 0.8 (default features off; `http1` + `json` 21 - + `tokio` only). 21 + client interactive mode, plus the labeler report stage's self-mint DID 22 + doc server): axum 0.8 (default features off; `http1` + `json` + `tokio` 23 + only). 22 24 - CLI: clap derive. Diagnostics: miette (fancy) + thiserror. 23 - - Crypto: k256 + p256 (ECDSA), sha2, multibase. JWS/JWK signing and parsing 24 - via `jsonwebtoken` 10 with the `rust_crypto` backend (no ring). PKCS8 25 - export on p256 is required because `jsonwebtoken`'s rust_crypto backend 26 - loads keys through `from_pkcs8_der`. 25 + - Crypto: k256 + p256 (ECDSA), sha2, multibase. `getrandom` is a direct 26 + dep so we can reach `OsRng` for sentinel run-ids and JWT `jti` nonces 27 + (the transitive `rand_core` inside `elliptic-curve` is built without 28 + the `getrandom` feature). 29 + - JWTs: two separate surfaces live side-by-side and do not share code. 30 + - `src/common/jwt.rs` — hand-rolled compact JWS encoder/decoder (ES256 31 + / ES256K only), used by the labeler report stage to mint self-mint 32 + service-auth tokens. No JWT library. 33 + - `src/common/oauth/jws.rs` — typed wrappers around `jsonwebtoken` 10 34 + (`rust_crypto` backend, no ring) for the OAuth client's RP. PKCS8 35 + export on p256 is required because `jsonwebtoken`'s rust_crypto 36 + backend loads keys through `from_pkcs8_der`. 27 37 - Deterministic RNG for OAuth test flows: rand_chacha + rand_core (seeded 28 38 `ChaCha20Rng`). Form encoding for OAuth request bodies: serde_urlencoded. 29 39 - atproto types: atrium-api (not atrium-xrpc-client — we use reqwest directly ··· 50 60 - `src/cli.rs` — clap root, tracing subscriber wiring (`--verbose` toggles 51 61 `DEBUG`), `NO_COLOR`/`--no-color` handling. 52 62 - `src/commands/test/labeler/` — the `test labeler` subcommand. See 53 - `src/commands/test/labeler/CLAUDE.md` for pipeline architecture. 63 + `src/commands/test/labeler/CLAUDE.md` for pipeline architecture. The 64 + report stage lives under `src/commands/test/labeler/create_report/` 65 + (self-mint DID doc server, sentinel run-id, pollution checks). 54 66 - `src/commands/test/oauth/client/` — the `test oauth client` subcommand 55 67 (discovery / metadata / JWKS static stages plus an optional interactive 56 68 stage with `scope_variations` and `dpop_edges` sub-stages). Contains an 57 69 in-process `fake_as` axum server module used in interactive mode. See 58 70 `src/commands/test/oauth/client/CLAUDE.md`. 59 - - `src/common/identity.rs` — DID/handle/multikey/PLC primitives shared across 60 - stages. See `src/common/CLAUDE.md`. 71 + - `src/common/identity.rs` — DID/handle/multikey/PLC primitives plus 72 + signing-key types (`AnySigningKey`, `AnyVerifyingKey`, `AnySignature`) 73 + shared across stages. See `src/common/CLAUDE.md`. 74 + - `src/common/jwt.rs` — compact JWS encoder/decoder (ES256 / ES256K) used 75 + by the labeler report stage to mint self-mint service-auth tokens. See 76 + `src/common/CLAUDE.md`. 61 77 - `src/common/oauth/` — OAuth 2.0 / JOSE primitives shared by the `test oauth 62 78 *` family: `clock` (testable time source), `jws` (JWK parsing + ES256 63 79 signing via `jsonwebtoken`), `relying_party` (atproto-spec RP that drives ··· 72 88 spans against JSON payloads. 73 89 - `tests/` — one integration binary per stage. Labeler: 74 90 `labeler_identity.rs`, `labeler_http.rs`, `labeler_subscription.rs`, 75 - `labeler_endtoend.rs`, `labeler_cli.rs`. OAuth client: 76 - `oauth_client_discovery.rs`, `oauth_client_metadata.rs`, 91 + `labeler_report.rs`, `labeler_endtoend.rs`, `labeler_cli.rs`. OAuth 92 + client: `oauth_client_discovery.rs`, `oauth_client_metadata.rs`, 77 93 `oauth_client_jwks.rs`, `oauth_client_interactive.rs`, 78 94 `oauth_client_endtoend.rs`, `oauth_client_cli.rs`, 79 95 `oauth_client_broken_rp.rs` (interactive-mode failure ACs driven by a 80 96 deliberately broken RP), `oauth_client_check_id_coverage.rs` (guards that 81 97 every check ID and diagnostic code is pinned in a snapshot). Shared fakes 82 98 (`FakeHttpClient`, `FakeDnsResolver`, `FakeRawHttpTee`, 83 - `FakeWebSocketClient`, `FakeJwksFetcher`, `FakeClock`) live in 84 - `tests/common/mod.rs`. 99 + `FakeWebSocketClient`, `FakeJwksFetcher`, `FakeClock`, plus the report 100 + stage's `FakeCreateReportTee` / `FakePdsXrpcClient`) live in 101 + `tests/common/mod.rs`, with fake smoke tests in `tests/common_fakes.rs`. 85 102 - `tests/fixtures/` — JSON + binary CBOR fixtures per stage. The 86 103 `tests/snapshots/` tree holds insta snapshots. 87 - - `docs/implementation-plans/2026-04-13-test-labeler/` — the phased 88 - implementation plan that produced the labeler subcommand. 104 + - `docs/implementation-plans/2026-04-13-test-labeler/` — the initial phased 105 + implementation plan for the four-stage labeler pipeline. 106 + - `docs/implementation-plans/2026-04-17-labeler-report-stage/` — the phased 107 + implementation plan that added the labeler report stage. 89 108 - `docs/implementation-plans/2026-04-16-test-oauth-client/` — the phased 90 109 implementation plan (Phases 1-8) that produced the oauth client 91 110 subcommand. ··· 111 130 ## Testing pattern 112 131 113 132 Every network-touching stage goes through an injected trait object 114 - (`HttpClient`, `DnsResolver`, `RawHttpTee`, `WebSocketClient`). Production 115 - wires in `Real*` implementations; tests wire in fakes from 116 - `tests/common/mod.rs`. Do not add network calls that bypass these seams — 117 - they make stages untestable and break snapshot determinism. 133 + (`HttpClient`, `DnsResolver`, `RawHttpTee`, `WebSocketClient`, 134 + `CreateReportTee`, `PdsXrpcClient`). Production wires in `Real*` 135 + implementations; tests wire in fakes from `tests/common/mod.rs`. Do not 136 + add network calls that bypass these seams — they make stages untestable 137 + and break snapshot determinism. 118 138 119 139 Integration tests pin per-check output via insta snapshots. When a check's 120 140 rendered text, diagnostic code, or order changes, `cargo insta review` is the
+2 -1
Cargo.lock
··· 154 154 155 155 [[package]] 156 156 name = "atproto-devtool" 157 - version = "0.1.0" 157 + version = "0.1.1" 158 158 dependencies = [ 159 159 "assert_cmd", 160 160 "async-trait", ··· 166 166 "ciborium", 167 167 "clap", 168 168 "futures-util", 169 + "getrandom 0.2.17", 169 170 "hickory-resolver", 170 171 "humantime", 171 172 "insta",
+7 -1
Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-devtool" 3 - version = "0.1.0" 3 + version = "0.1.1" 4 4 authors = ["Jack Grigg <thestr4d@gmail.com>"] 5 5 edition = "2024" 6 6 rust-version = "1.85" ··· 27 27 ciborium = "0.2" 28 28 clap = { version = "4.6", features = ["derive"] } 29 29 futures-util = { version = "0.3", default-features = false, features = ["std"] } 30 + # Direct CSPRNG access for JWT `jti` nonces and the sentinel run-id. 31 + # Transitively present (k256 → elliptic-curve → rand_core with the 32 + # `getrandom` feature disabled), so we can't reach OsRng through the 33 + # existing dep graph. Promoting `getrandom` to a direct dep is the 34 + # smallest viable fix. 35 + getrandom = "0.2" 30 36 hickory-resolver = "0.25" 31 37 humantime = "2.1" 32 38 # JWS/JWK parsing and signing. Use rust_crypto backend (no ring).
+386
docs/design-plans/2026-04-17-labeler-report-stage.md
··· 1 + # Labeler report stage design 2 + 3 + ## Summary 4 + 5 + The `test labeler` pipeline today exercises the read side of a labeler — verifying that its DID document is correctly formed, that its HTTP endpoint speaks atproto, that its label-subscription WebSocket stream is alive, and that its signing keys are cryptographically valid. What it cannot yet do is prove that the labeler's authenticated write path works: specifically, that it correctly enforces the service-auth JWT protocol for `com.atproto.moderation.createReport`, the XRPC method through which clients submit moderation reports to a labeler. This stage closes that gap. 6 + 7 + The design introduces three operating modes that map to three different deployment milestones a labeler author moves through. In the self-mint mode — suited to local development — the tool generates a temporary cryptographic identity, stands up a miniature DID document server on localhost, and mints its own JWTs. This makes the tool entirely self-contained for negative tests (wrong audience, wrong method, expired token, etc.) without requiring any real atproto account. In the PDS service-auth mode — suited to integration testing against a real labeler — the tool authenticates to the user's Personal Data Server, asks it to mint a JWT bound to the labeler, and delivers that JWT directly. This catches real-world quirks such as the PDS's specific JWT algorithm choices and nonce handling that a hand-crafted self-mint JWT cannot replicate. In the PDS-proxied mode — a production smoke test — the tool sends the report to the PDS and lets it forward the request, exactly as the Bluesky app does in production. This verifies the full network path: that the PDS can reach the labeler and that the labeler accepts JWTs issued on behalf of real accounts. The three modes are additive and gated: each requires progressively more context (locality of the labeler, PDS credentials, and explicit opt-in to actually submitting reports), so the tool degrades gracefully and reports precisely why any particular check was skipped. 8 + 9 + ## Definition of Done 10 + 11 + **Primary deliverable.** A fifth `report` stage in the `test labeler` pipeline that exercises the authenticated `com.atproto.moderation.createReport` path end-to-end, following the same module-level `run()` + `*StageOutput { facts, results }` pattern the existing identity / http / subscription / crypto stages use. The stage lives in `src/commands/test/labeler/create_report.rs` (named after the XRPC method under test, to disambiguate from the existing `src/commands/test/labeler/report.rs` which already holds the stage-agnostic `CheckResult` / `CheckStatus` types). 12 + 13 + **Three operating modes, all in v1.** Each mode verifies a different deployment milestone: 14 + 15 + - **Self-mint (local dev loop).** The tool spins up an ephemeral HTTP server on `127.0.0.1:0` (OS-assigned port), serves a throwaway did:web DID document, generates an ES256K or ES256 keypair (chosen via `--self-mint-curve`, default ES256K for maximum overlap with real atproto accounts), and mints service-auth JWTs itself. Powers every negative check plus `report::self_mint_accepted`. Viable only when the labeler can reach this machine (local labeler, LAN labeler, or `--force-self-mint` override). 16 + - **PDS `getServiceAuth` + direct POST (labeler-integration check).** The tool calls `com.atproto.server.createSession` and then `com.atproto.server.getServiceAuth` on the user's PDS, and POSTs the returned JWT directly to the labeler. Powers `report::pds_service_auth_accepted`. Catches PDS-specific JWT quirks (algorithm choice, `jti` nonce, ES256K handling) that self-mint cannot replicate. Viable whenever the tool can reach both PDS and labeler. 17 + - **PDS-proxied (production smoke test).** The tool POSTs `com.atproto.moderation.createReport` to the user's *PDS* with header `atproto-proxy: <labeler-did>#atproto_labeler`; the PDS mints and forwards. Powers `report::pds_proxied_accepted`. End-to-end path matching the real Bluesky app flow — verifies the PDS can reach and authenticate to the labeler. Viable when the user's PDS can reach the labeler. 18 + 19 + **Stage gating behavior.** Four gating axes interact: `contract_advertised` (from identity-parsed `LabelerPolicies`), `self_mint_viable` (hostname heuristic or `--force-self-mint`), `pds_creds` (`--handle` + `--app-password` both supplied), and `commit` (`--commit-report`). Per-check gating: 20 + 21 + | Check ID | Runs when | Skipped reason when gated off | 22 + |---|---|---| 23 + | `report::contract_published` | Always | n/a — severity depends on `commit` | 24 + | `report::unauthenticated_rejected` | `contract_advertised` | "labeler does not advertise report acceptance" | 25 + | `report::malformed_bearer_rejected` | `contract_advertised` | same | 26 + | `report::wrong_aud_rejected` | `contract_advertised` && `self_mint_viable` | "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)" | 27 + | `report::wrong_lxm_rejected` | same | same | 28 + | `report::expired_rejected` | same | same | 29 + | `report::rejected_shape_returns_400` | same | same | 30 + | `report::self_mint_accepted` | `contract_advertised` && `self_mint_viable` && `commit` | self-mint-viable reason OR "commit gated behind --commit-report" | 31 + | `report::pds_service_auth_accepted` | `contract_advertised` && `pds_creds` && `commit` | "requires --handle, --app-password, and --commit-report" | 32 + | `report::pds_proxied_accepted` | same | same | 33 + 34 + `report::contract_published` severity: `Pass` when `reason_types` and `subject_types` are both non-empty. When the contract is missing: `Skipped` (entire stage, with all other checks Skipped "labeler does not advertise report acceptance") if `commit == false`; `SpecViolation` (diagnostic `labeler::report::contract_missing`, all other checks Skipped "blocked by `report::contract_published`") if `commit == true`. 35 + 36 + The "never short-circuit, always emit one row per stable ID" invariant is preserved in every case — every run produces exactly 10 `report::*` rows. 37 + 38 + **Stage checks (stable IDs, 10 total).** 39 + 40 + - `report::contract_published` — validates identity-parsed `LabelerPolicies` has non-empty `reasonTypes` and `subjectTypes`. Severity interacts with `--commit-report`. 41 + - `report::unauthenticated_rejected` — POST without `Authorization` → expect 401. `SpecViolation` on fail. 42 + - `report::malformed_bearer_rejected` — POST with `Authorization: Bearer not-a-jwt` → expect 401. `SpecViolation` on fail. 43 + - `report::wrong_aud_rejected` — self-mint JWT with `aud` set to a bogus bare DID → expect 401. `SpecViolation` on fail. 44 + - `report::wrong_lxm_rejected` — self-mint JWT with `lxm = "com.atproto.server.getSession"` → expect 401. `SpecViolation` on fail. 45 + - `report::expired_rejected` — self-mint JWT with `exp` 300s in the past → expect 401. `SpecViolation` on fail. 46 + - `report::rejected_shape_returns_400` — self-mint JWT with valid claims but unadvertised `reasonType` → expect 400 `InvalidRequest`. `Advisory` on fail. 47 + - `report::self_mint_accepted` — self-mint JWT with advertised shape, sentinel-reason, local-or-non-local pollution policy → expect 2xx with `createReport#output` body shape. `SpecViolation` on fail. 48 + - `report::pds_service_auth_accepted` — PDS-minted JWT via `getServiceAuth`, delivered directly → expect 2xx. `SpecViolation` on fail. 49 + - `report::pds_proxied_accepted` — POSTed to PDS with `atproto-proxy` header; PDS forwards → expect 2xx. `SpecViolation` on fail. 50 + 51 + **CLI surface.** New flags added to `LabelerCmd`: 52 + 53 + - `--handle <h>` and `--app-password <p>` — `clap(requires = "…")` enforces both-or-neither at parse time. When supplied, enables modes 2 and 3. 54 + - `--report-subject-did <did>` — override the computed default subject. 55 + - `--commit-report` — opt-in to all committing checks and assert reporting conformance (contract-missing becomes a SpecViolation). 56 + - `--force-self-mint` — override the locality heuristic; force self-mint checks to run regardless of labeler endpoint classification. 57 + - `--self-mint-curve {es256,es256k}` — select the self-mint key curve; default `es256k`. 58 + 59 + Exit-code semantics unchanged: any `SpecViolation` → 1, else any `NetworkError` → 2, else 0. 60 + 61 + **Pollution avoidance.** When a positive check actually POSTs AND the labeler endpoint is classified non-local: 62 + - Prefer `reasonOther` from `com.atproto.moderation.defs#reasonType` if advertised; fall back to the lex-first advertised entry. 63 + - Prefer `record` subject type with a hard-coded AT-URI (a to-be-created explanation post) if advertised; fall back to `account` subject type pointing at the reporter's own DID. 64 + - `reason` field contains a stable sentinel — `"atproto-devtool conformance test <UTC-RFC3339> <run-id>"` — so moderators can identify and dismiss. 65 + 66 + For local labelers, use lex-first `reasonType` and `account` subject with the reporter's own DID — pollution is a non-concern in a developer's own queue. 67 + 68 + **Quality bar.** 69 + 70 + - Rich miette diagnostics with stable `code = "labeler::report::..."` strings for every new error. The tool must be useful for debugging a broken labeler, not just pass/fail. 71 + - Every `report::*` check ID and diagnostic code pinned in insta snapshots. 72 + - One new network seam `CreateReportTee` (mirroring `RawHttpTee`), production impl `RealCreateReportTee`, test fake `FakeCreateReportTee` in `tests/common/mod.rs`. 73 + - Integration test binary `tests/labeler_report.rs` wired into the same fake-based pattern as other per-stage integration tests. 74 + - `AnySigningKey` newtype introduced alongside existing `AnyVerifyingKey` in `src/common/identity.rs`; no new crate dependencies (JWT hand-rolled on existing `k256`/`p256`/`sha2`/`base64`; ephemeral did:web server hand-rolled on `tokio::net::TcpListener`). 75 + - All existing tests continue to pass; no regressions in identity / http / subscription / crypto output. 76 + 77 + **Explicitly out of scope for v1.** 78 + 79 + - Verifying label state changes after a submitted report (no post-commit polling of `queryLabels`). 80 + - `report::zero_retention_probe` (low signal, deferred). 81 + - `report::clock_skew_accepted` (the reference implementation has zero skew tolerance by default; check would be noise, deferred). 82 + - PUT / DELETE "negate" API testing (labeler-implementation vocabulary, not protocol). 83 + - Issuer modes other than ephemeral localhost did:web (self-mint) and the user's own PDS (PDS-mediated modes) — no configurable external did:web, no user-supplied raw signing keys, no `did:key` issuer. 84 + 85 + ## Acceptance Criteria 86 + 87 + ### labeler-report-stage.AC1: `report::contract_published` behavior 88 + 89 + - **labeler-report-stage.AC1.1 Success:** Labeler advertises non-empty `reasonTypes` and `subjectTypes` → check emits `Pass`. 90 + - **labeler-report-stage.AC1.2 Success (stage-skip):** No `--commit-report`, contract missing → every `report::*` check emits `Skipped` with reason "labeler does not advertise report acceptance". 91 + - **labeler-report-stage.AC1.3 Failure:** `--commit-report` set, contract missing → `report::contract_published` emits `SpecViolation` with diagnostic `labeler::report::contract_missing`; all other checks emit `Skipped` with reason "blocked by `report::contract_published`". 92 + - **labeler-report-stage.AC1.4 Edge:** Empty arrays (`reasonTypes: []`) treated identically to absent field. 93 + 94 + ### labeler-report-stage.AC2: No-JWT negative checks 95 + 96 + - **labeler-report-stage.AC2.1 Success:** `unauthenticated_rejected` emits `Pass` when labeler returns 401 with non-empty atproto error envelope for unauthenticated POST. 97 + - **labeler-report-stage.AC2.2 Failure:** `unauthenticated_rejected` emits `SpecViolation` (diagnostic `labeler::report::unauthenticated_accepted`) when labeler returns 2xx. 98 + - **labeler-report-stage.AC2.3 Success:** `malformed_bearer_rejected` emits `Pass` when labeler returns 401 for garbage Bearer. 99 + - **labeler-report-stage.AC2.4 Failure:** `malformed_bearer_rejected` emits `SpecViolation` (diagnostic `labeler::report::malformed_bearer_accepted`) when labeler accepts garbage Bearer. 100 + - **labeler-report-stage.AC2.5 Edge:** 401 with empty or missing `error` envelope field still treated as `Pass` on status alone; summary text notes the non-conformant response shape. 101 + 102 + ### labeler-report-stage.AC3: Self-mint negative checks 103 + 104 + - **labeler-report-stage.AC3.1 Success:** `wrong_aud_rejected` emits `Pass` when labeler returns 401 for fresh-signed JWT with mutated `aud`. 105 + - **labeler-report-stage.AC3.2 Failure:** emits `SpecViolation` (diagnostic `labeler::report::wrong_aud_accepted`) when labeler returns 2xx for wrong aud. 106 + - **labeler-report-stage.AC3.3 Success/Failure pair:** `wrong_lxm_rejected` behaves analogously for mutated `lxm`; diagnostic `labeler::report::wrong_lxm_accepted`. 107 + - **labeler-report-stage.AC3.4 Success/Failure pair:** `expired_rejected` behaves analogously for past-expiry JWT; diagnostic `labeler::report::expired_accepted`. 108 + - **labeler-report-stage.AC3.5 Success:** `rejected_shape_returns_400` emits `Pass` when labeler returns 400 `InvalidRequest` for unadvertised `reasonType`. 109 + - **labeler-report-stage.AC3.6 Advisory:** `rejected_shape_returns_400` emits `Advisory` (diagnostic `labeler::report::shape_not_400`) when labeler returns 401 or 500 (rejection for wrong reason). 110 + - **labeler-report-stage.AC3.7 Skip:** Every self-mint negative check emits `Skipped` with reason naming the `--force-self-mint` override when heuristic classifies labeler non-local. 111 + - **labeler-report-stage.AC3.8 Override:** `--force-self-mint` bypasses the heuristic; all self-mint checks run regardless of hostname. 112 + 113 + ### labeler-report-stage.AC4: Self-mint positive check 114 + 115 + - **labeler-report-stage.AC4.1 Success (local labeler):** `self_mint_accepted` emits `Pass` using lex-first `reasonType` and account subject = reporter DID when labeler returns 2xx. 116 + - **labeler-report-stage.AC4.2 Success (non-local labeler):** `self_mint_accepted` emits `Pass` using `reasonOther` (if advertised) and `record` subject with hard-coded AT-URI (if advertised) when labeler returns 2xx. 117 + - **labeler-report-stage.AC4.3 Failure:** emits `SpecViolation` (diagnostic `labeler::report::self_mint_rejected`) when labeler returns non-2xx. 118 + - **labeler-report-stage.AC4.4 Skip (no commit):** emits `Skipped` with reason naming the `--commit-report` gate. 119 + - **labeler-report-stage.AC4.5 Skip (not viable):** emits `Skipped` with the self-mint-unreachable reason when heuristic trips. 120 + - **labeler-report-stage.AC4.6 Sentinel:** The `reason` field in the submitted POST body contains the stable sentinel string `"atproto-devtool conformance test <RFC3339> <run-id>"`. 121 + 122 + ### labeler-report-stage.AC5: PDS `getServiceAuth` mode 123 + 124 + - **labeler-report-stage.AC5.1 Success:** `pds_service_auth_accepted` emits `Pass` when `createSession` + `getServiceAuth` + labeler POST all succeed. 125 + - **labeler-report-stage.AC5.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_service_auth_rejected`) when labeler returns non-2xx for the PDS-minted JWT. 126 + - **labeler-report-stage.AC5.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable, credentials are rejected, or `getServiceAuth` returns an error. 127 + - **labeler-report-stage.AC5.4 Skip:** emits `Skipped` with reason "requires --handle, --app-password, and --commit-report" when any of the three are missing. 128 + 129 + ### labeler-report-stage.AC6: PDS-proxied mode 130 + 131 + - **labeler-report-stage.AC6.1 Success:** `pds_proxied_accepted` emits `Pass` when the proxied POST returns 2xx from the PDS. 132 + - **labeler-report-stage.AC6.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_proxied_rejected`) when the PDS surfaces a labeler-side rejection (status/error envelope indicating downstream 4xx/5xx). 133 + - **labeler-report-stage.AC6.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable or rejects the proxy attempt itself. 134 + - **labeler-report-stage.AC6.4 Skip:** emits `Skipped` ("requires --handle, --app-password, and --commit-report") when any of the three are missing. 135 + 136 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants 137 + 138 + - **labeler-report-stage.AC7.1 Row count:** Every `test labeler` run that reaches the report stage emits exactly 10 `report::*` `CheckResult` rows, regardless of flag or environment combinations. 139 + - **labeler-report-stage.AC7.2 Row order:** Row order is stable and matches the DoD list top-to-bottom. 140 + 141 + ### labeler-report-stage.AC8: CLI flag handling 142 + 143 + - **labeler-report-stage.AC8.1 Both-or-neither:** `--handle` without `--app-password` (and vice versa) produces a clap parse error before any stage runs. 144 + - **labeler-report-stage.AC8.2 Curve selection:** `--self-mint-curve es256` advertises a P-256 key in the did:web DID doc; `es256k` advertises secp256k1; the minted JWT's `alg` header matches. 145 + - **labeler-report-stage.AC8.3 Subject override:** `--report-subject-did <did>` replaces the computed default subject in the body of committing checks. 146 + - **labeler-report-stage.AC8.4 Exit codes:** Exit 1 on any `SpecViolation`; exit 2 on any `NetworkError` absent `SpecViolation`; exit 0 otherwise — unchanged from existing stages. 147 + 148 + ## Glossary 149 + 150 + - **atproto**: The AT Protocol — an open, federated social networking protocol developed by Bluesky. Defines the wire formats, lexicons, identity system, and service roles (PDS, relay, labeler, etc.) that power Bluesky and compatible applications. 151 + - **labeler**: An atproto service that receives moderation reports and emits signed labels (annotations) on accounts or content. Labelers are independent services; the Bluesky moderation infrastructure is one example but users can subscribe to any labeler. 152 + - **DID (Decentralized Identifier)**: A W3C standard identifier of the form `did:<method>:<id>`. In atproto, every account and service has a DID as its stable, globally unique identity anchor. A DID resolves to a DID document describing the entity's keys and service endpoints. 153 + - **did:web**: A DID method that resolves via HTTPS — `did:web:example.com` resolves by fetching `https://example.com/.well-known/did.json`. The self-mint mode uses an ephemeral `did:web` on `127.0.0.1:<port>`. 154 + - **did:plc**: The primary DID method used for atproto user accounts. Identities are controlled through a signed log of operations stored at `plc.directory`. Unlike `did:web`, it does not require the subject to host their own infrastructure. 155 + - **XRPC**: The RPC protocol used by atproto. Calls are HTTP requests to `GET /xrpc/<nsid>` (queries) or `POST /xrpc/<nsid>` (procedures), where the NSID identifies the Lexicon method. 156 + - **PDS (Personal Data Server)**: The server that hosts a user's atproto repository — their posts, follows, and other records. The PDS also acts as an authentication intermediary: it can mint service-auth JWTs on behalf of a logged-in user and proxy XRPC calls to downstream services. 157 + - **Ozone**: The reference open-source labeler and moderation server implementation from Bluesky. Many independent labelers run Ozone. The error envelope vocabulary documented in the design (`BadJwt`, `BadJwtAudience`, etc.) originates from Ozone's `@atproto/xrpc-server`. 158 + - **service-auth JWT**: A short-lived JSON Web Token that atproto services use to authenticate machine-to-machine calls. Unlike a user's access token, a service-auth JWT carries an `aud` (audience, the target service's DID) and an `lxm` (the specific Lexicon method being called), tightly scoping what the token may be used for. 159 + - **atproto-proxy header**: An HTTP request header (`atproto-proxy: <did>#<service-id>`) sent by a client to its PDS asking the PDS to forward the request to the named service on the client's behalf. The PDS resolves the DID, mints an appropriate service-auth JWT, and proxies the call. 160 + - **`lxm` claim**: A custom JWT claim in atproto's service-auth spec containing the NSID (namespaced identifier) of the Lexicon method the token authorizes. A labeler must reject tokens whose `lxm` does not match the method being invoked. 161 + - **`iss` / `aud` claims**: Standard JWT claims for issuer and audience. In atproto service-auth, `iss` is the caller's DID and `aud` is the target service's DID. A labeler must reject tokens with an `aud` that does not match its own DID. 162 + - **`jti` claim**: Standard JWT claim for a unique token identifier. atproto service-auth includes a random `jti` nonce to prevent replay attacks. 163 + - **`com.atproto.moderation.createReport`**: The XRPC procedure that clients call to submit a moderation report to a labeler. This is the method under test throughout the design. 164 + - **`LabelerPolicies`**: The structured data published in a labeler's DID document (and reflected in its atproto records) declaring which `reasonTypes` and `subjectTypes` it accepts for reports. The stage treats this as the "contract" the labeler has advertised. 165 + - **`reasonTypes` / `subjectTypes`**: Fields within `LabelerPolicies`. `reasonTypes` lists the moderation reason categories the labeler accepts (e.g., `reasonSpam`, `reasonOther`); `subjectTypes` lists what can be reported (`account`, `record`). 166 + - **Lexicon**: The atproto schema definition language. Lexicons define the types, fields, and constraints for every XRPC method and record type. `com.atproto.moderation.defs#reasonType` is a Lexicon-defined enum of moderation reason categories. 167 + - **insta**: A Rust snapshot-testing library. Tests call `insta::assert_snapshot!()` and the library writes `.snap` files that are committed to the repository. Reviewing and approving output changes is done via `cargo insta review`. 168 + - **miette**: A Rust diagnostics library that renders rich, human-readable error messages with source spans, labels, and diagnostic codes. All user-facing errors in this codebase carry a stable `code = "..."` string so users can search for documentation on a specific error. 169 + - **clap**: The Rust command-line argument parsing library used by this tool. The design uses clap's `requires` attribute to enforce that `--handle` and `--app-password` must be supplied together. 170 + - **ES256 / ES256K**: JWT algorithm identifiers for ECDSA signatures. ES256 uses the P-256 elliptic curve (NIST); ES256K uses secp256k1 (the same curve used by Bitcoin). atproto accounts predominantly use ES256K; P-256 is increasingly common for hardware-backed keys. 171 + - **low-s ECDSA normalization**: ECDSA signatures have two mathematically valid forms (high-s and low-s). atproto requires the low-s form. The `AnySigningKey::sign` method normalizes to low-s to match the convention already established in `AnySignature`. 172 + - **sentinel reason**: A stable, recognizable string embedded in the `reason` field of test report submissions — `"atproto-devtool conformance test <RFC3339> <run-id>"` — so that labeler operators can identify and dismiss reports submitted by the conformance tool. 173 + - **multibase**: A self-describing encoding prefix scheme (e.g., `z` prefix for base58btc). atproto DID documents encode public keys as multibase-prefixed, multicodec-prefixed byte strings. The existing `AnyVerifyingKey` type in `src/common/identity.rs` already handles multibase decoding. 174 + - **`k256` / `p256`**: Rust crates implementing secp256k1 and P-256 elliptic curve cryptography respectively. Used directly for signing and verification rather than through a higher-level JWT library, avoiding a new dependency. 175 + - **did:web on localhost**: A `did:web` whose hostname is `127.0.0.1:<port>`. The self-mint mode relies on this: the tool binds a port, serves a DID document there, and constructs a `did:web:127.0.0.1:<port>` identity. The labeler must be able to reach `127.0.0.1` to resolve and verify the identity, which is why this mode is gated on the labeler itself being local. 176 + 177 + ## Architecture 178 + 179 + The report stage is a new module `src/commands/test/labeler/create_report.rs`. It exposes `pub async fn run(...) -> CreateReportStageOutput` and is invoked as the final stage in `pipeline::run_pipeline` after the existing `crypto::run(...)` call. Placing it last matches the existing "reads before writes" ordering and keeps side-effectful POSTs at the tail of the pipeline. 180 + 181 + Stage inputs: 182 + 183 + - `identity_facts: &IdentityFacts` (labeler DID, endpoint URL, `labeler_policies`). Required; stage emits all 10 checks as `Skipped` with reason "blocked by identity stage" when `None`. 184 + - `report_tee: &dyn CreateReportTee` (the new I/O seam for POSTing createReport to the labeler). 185 + - `http: &dyn HttpClient` (reused for PDS calls in modes 2 and 3). 186 + - `self_mint_signer: Option<&SelfMintSigner>` — always present in production (the baseline); `None` only in tests that exercise the "self-mint not viable, no override" path by construction. 187 + - `pds_credentials: Option<&PdsCredentials>` — present when `--handle` + `--app-password` supplied. 188 + - `report_subject_override: Option<&Did>`, `commit_report: bool`, `self_mint_viable: bool` — CLI-derived booleans and optional overrides. 189 + 190 + Stage output: `CreateReportStageOutput { facts: Option<CreateReportFacts>, results: Vec<CheckResult> }`. `CreateReportFacts` is minimal — one `Option<bool>` per positive check (`self_mint_succeeded`, `pds_service_auth_succeeded`, `pds_proxied_succeeded`) for possible future consumer stages. 191 + 192 + **Key components introduced:** 193 + 194 + - **`CreateReportTee`** trait (new test seam): `async fn post_create_report(&self, auth: Option<&str>, body: &CreateReportRequest) -> Result<RawCreateReportResponse, CreateReportStageError>`. Real impl wraps `reqwest::Client`; fake impl in `tests/common/mod.rs` records the last request and returns scripted responses keyed by call index. 195 + - **`SelfMintSigner`** concrete struct: owns an `AnySigningKey`, an issuer DID, and a `DidDocServer` handle. Exposes `sign_jwt(claims: JwtClaims) -> String` for the stage to build both valid and mutated JWTs. 196 + - **`DidDocServer`** RAII type: binds `127.0.0.1:0`, spawns a tokio task serving a single JSON response at `/.well-known/did.json`, shuts down on drop. Hand-rolled on `tokio::net::TcpListener` — no web framework dependency. 197 + - **`AnySigningKey`** newtype enum in `src/common/identity.rs`: variants `Secp256k1(k256::ecdsa::SigningKey)`, `P256(p256::ecdsa::SigningKey)`. Mirrors the existing `AnyVerifyingKey`. Method `sign(msg: &[u8]) -> AnySignature` produces low-s-normalized ECDSA signatures. 198 + - **`PdsJwtFetcher`** concrete type: wraps an `&dyn HttpClient` and a PDS endpoint URL; exposes `create_session(...)` and `mint_valid(aud, lxm)` for mode 2. 199 + - **`PdsProxiedPoster`** concrete type: wraps an `&dyn HttpClient` and PDS endpoint; exposes `post_via_proxy(labeler_did, body, access_jwt)` for mode 3. 200 + 201 + **JWT construction (self-mint, hand-rolled).** Header: `{"typ":"JWT","alg":"ES256K"}` or `ES256`. Compact form: `base64url(header) + "." + base64url(claims) + "." + base64url(sig)`. Claims shape: 202 + 203 + ```rust 204 + pub struct JwtClaims { 205 + pub iss: Did, // did:web:127.0.0.1:PORT for self-mint 206 + pub aud: Did, // bare labeler DID (no #atproto_labeler fragment) 207 + pub exp: i64, 208 + pub iat: i64, 209 + pub lxm: Nsid, // com.atproto.moderation.createReport 210 + pub jti: String, // 16 random bytes hex 211 + // nbf deliberately omitted — not in atproto service-auth spec. 212 + } 213 + ``` 214 + 215 + Negative-test claim mutations are applied to a `valid_claims_template()` before signing, so each mutated JWT is freshly signed by the self-mint key. This isolates the labeler's rejection to the mutated claim, regardless of whether the labeler checks claims or signature first. 216 + 217 + **PDS call shapes (modes 2 and 3).** Mode 2 issues two XRPC calls (`com.atproto.server.createSession`, `com.atproto.server.getServiceAuth`) then hands the returned JWT to `CreateReportTee` verbatim. Mode 3 issues one XRPC call — POST `com.atproto.moderation.createReport` to the PDS with `Authorization: Bearer <access_jwt>` and `atproto-proxy: <labeler-did>#atproto_labeler` — and reads the PDS's response as the outcome. Tool never handles the service-auth JWT in mode 3. 218 + 219 + **Self-mint viability heuristic.** Applied to the labeler endpoint URL's hostname: 220 + 221 + - Classified local (self-mint viable): `localhost`, `127.0.0.1`, `::1`, hostnames ending in `.local`, RFC 1918 ranges (`10.*`, `172.16.*`–`172.31.*`, `192.168.*`). 222 + - Classified non-local: everything else. 223 + - `--force-self-mint` overrides the classification. 224 + 225 + ## Existing Patterns 226 + 227 + Investigation verified the following patterns, and the design follows them strictly: 228 + 229 + - **Stage shape:** module-level `pub async fn run(...)` returning `*StageOutput { facts: Option<*Facts>, results: Vec<CheckResult> }`. Established by `src/commands/test/labeler/identity.rs` (`run` + `IdentityFacts`), `http.rs` (`run` + `HttpStageOutput`), `subscription.rs`, and `crypto.rs`. 230 + - **Never short-circuit invariant:** every check in a stage emits exactly one `CheckResult` per run, with "blocked by …" `Skipped` reasons when prerequisites fail. Enforced by per-stage loops in `pipeline::run_pipeline` (`src/commands/test/labeler/pipeline.rs:197-381`). 231 + - **Check ID namespacing:** `identity::xxx`, `http::yyy`, `subscription::zzz`, `crypto::www`. New IDs use `report::...`. 232 + - **Miette diagnostic codes:** stable `code = "labeler::<stage>::<subject>"` strings (e.g., `labeler::identity::labeler_service_present` at `src/commands/test/labeler/identity.rs:64`). Pinned in `tests/snapshots/` — renaming one is a breaking change. 233 + - **Single new I/O trait per stage:** `RawHttpTee` in http, `WebSocketClient + FrameStream` in subscription. New stage gets `CreateReportTee`. Incidental HTTP reuses the existing `HttpClient` trait from `src/common/identity.rs:282`. 234 + - **Newtype enums for crypto primitives:** `AnyVerifyingKey` (`src/common/identity.rs:112-117`) wraps `k256` and `p256` verifying keys; `AnySignature` (same file, line 150) wraps their signatures. New `AnySigningKey` follows the identical shape for signing keys. 235 + - **Integration test structure:** one `tests/labeler_<stage>.rs` binary per stage, fixtures under `tests/fixtures/labeler/<stage>/`, shared fakes in `tests/common/mod.rs`. New stage gets `tests/labeler_report.rs` and `tests/fixtures/labeler/report/`. 236 + - **Clap derive with `requires`:** argument dependencies enforced at parse time, as already practiced in the workspace's CLI conventions. 237 + 238 + One new pattern is introduced — the ephemeral `DidDocServer` (single-endpoint tokio HTTP server). Scoped narrowly to this stage. Not positioned as a reusable framework; if future stages need similar facilities, they can be generalized then. 239 + 240 + ## Implementation Phases 241 + 242 + <!-- START_PHASE_1 --> 243 + ### Phase 1: Shared primitives 244 + 245 + **Goal:** Land the crate-wide primitives that later phases depend on, without yet introducing any report-stage code. 246 + 247 + **Components:** 248 + - `AnySigningKey` enum in `src/common/identity.rs` — variants `Secp256k1(k256::ecdsa::SigningKey)` and `P256(p256::ecdsa::SigningKey)`; method `sign(msg: &[u8]) -> AnySignature` producing low-s-normalized signatures matching the existing `AnySignature` conventions. 249 + - New module `src/common/jwt.rs` — `JwtHeader`, `JwtClaims` structs; `encode_compact(header, claims, signer) -> String` helper; `decode_compact(token) -> (JwtHeader, JwtClaims, sig_bytes)` helper for round-trip verification in tests. 250 + - Sentinel-reason builder in `src/commands/test/labeler/create_report/sentinel.rs` (small module; produces `"atproto-devtool conformance test <RFC3339> <run-id>"`). 251 + - Local-labeler viability heuristic — function in `src/common/identity.rs` classifying a `&url::Url`'s hostname against the rules above, returning `bool`. 252 + 253 + **Dependencies:** None (first phase). 254 + 255 + **Done when:** `cargo build` succeeds; unit tests pass for (a) AnySigningKey sign/verify round-trip in both curves against AnyVerifyingKey, (b) JWT encode+decode round-trip, (c) hostname classifier over a table of cases including RFC 1918 ranges and `.local` suffixes. No ACs directly covered — this phase is infrastructure. 256 + <!-- END_PHASE_1 --> 257 + 258 + <!-- START_PHASE_2 --> 259 + ### Phase 2: Self-mint JWT infrastructure 260 + 261 + **Goal:** Produce a `SelfMintSigner` that can mint a JWT the labeler can resolve and verify end-to-end. 262 + 263 + **Components:** 264 + - `DidDocServer` type in `src/commands/test/labeler/create_report/did_doc_server.rs` — RAII struct that binds `127.0.0.1:0`, spawns a tokio task serving one JSON body at `/.well-known/did.json`, and closes on drop. Hand-rolled HTTP/1.1 reply on `tokio::net::TcpListener`. 265 + - `SelfMintSigner` struct in `src/commands/test/labeler/create_report/self_mint.rs` — owns an `AnySigningKey`, the issuer `Did` (built from the port), and a `DidDocServer` handle; exposes `sign_jwt(claims: JwtClaims) -> String` and `issuer_did() -> &Did`. 266 + - Curve-selection parser for `--self-mint-curve` (clap `ValueEnum`). 267 + 268 + **Dependencies:** Phase 1 (AnySigningKey, JWT helpers). 269 + 270 + **Done when:** Integration test verifies a `SelfMintSigner` round-trip — a second `tokio::task` fetches the DID doc over HTTP, extracts the multibase key, and verifies a JWT signed by the signer using `AnyVerifyingKey`. Test runs in both curves. 271 + <!-- END_PHASE_2 --> 272 + 273 + <!-- START_PHASE_3 --> 274 + ### Phase 3: `CreateReportTee` seam 275 + 276 + **Goal:** Stand up the test seam for the labeler POST so all later functionality phases have a mockable entry point. 277 + 278 + **Components:** 279 + - `CreateReportTee` trait in `src/commands/test/labeler/create_report.rs` module root. 280 + - `RealCreateReportTee` concrete type wrapping `reqwest::Client` + labeler endpoint URL. 281 + - `FakeCreateReportTee` in `tests/common/mod.rs` — scripted per-call-index responses, records last request (`auth` header + body) for assertion by tests. 282 + - `RawCreateReportResponse` struct (status, headers, raw body, content-type) for preserved diagnostics. 283 + 284 + **Dependencies:** None beyond existing crate; can proceed in parallel with Phase 2. 285 + 286 + **Done when:** Smoke test proves `FakeCreateReportTee` can serve a scripted sequence of responses and record the last request's Authorization header and body for inspection. 287 + <!-- END_PHASE_3 --> 288 + 289 + <!-- START_PHASE_4 --> 290 + ### Phase 4: Stage scaffolding and contract check 291 + 292 + **Goal:** Wire the new stage into the pipeline with the `report::contract_published` check operational in all four gating outcomes. 293 + 294 + **Components:** 295 + - Module `src/commands/test/labeler/create_report.rs` with `pub async fn run(...) -> CreateReportStageOutput`, `CreateReportStageOutput`, `CreateReportFacts`, `CreateReportStageError` (thiserror + miette). 296 + - `report::contract_published` check logic using `IdentityFacts::labeler_policies`; diagnostic `labeler::report::contract_missing` when `commit_report == true` and contract is empty. 297 + - Pipeline integration: new call after `crypto::run(...)` in `src/commands/test/labeler/pipeline.rs` around line 381; new field on `LabelerOptions` for the `CreateReportTee` ref. 298 + - CLI plumbing in `src/commands/test/labeler.rs`: `--commit-report`, `--self-mint-curve`, `--force-self-mint`, `--report-subject-did` (other flags arrive in Phase 7). 299 + - Initial insta snapshots for: contract present + commit false; contract present + commit true; contract missing + commit false (whole-stage skip); contract missing + commit true (SpecViolation + 9 blocked rows). 300 + 301 + **Dependencies:** Phase 3 (CreateReportTee plumbed into LabelerOptions). 302 + 303 + **Done when:** Four new integration tests pass in `tests/labeler_report.rs` covering every contract × commit combination. Snapshots pinned. Covers **labeler-report-stage.AC1.1** through **AC1.4**. 304 + <!-- END_PHASE_4 --> 305 + 306 + <!-- START_PHASE_5 --> 307 + ### Phase 5: No-JWT negative checks 308 + 309 + **Goal:** Cover the two checks that don't depend on self-mint infrastructure (no JWT is constructed) — they can run against any reachable labeler. 310 + 311 + **Components:** 312 + - `report::unauthenticated_rejected` implementation — POST with `auth: None`. 313 + - `report::malformed_bearer_rejected` implementation — POST with `auth: Some("not-a-jwt")`. 314 + - Response assertion helper: status must be 401 AND response body must be parseable as the atproto error envelope with a non-empty `error` field. Diagnostic codes `labeler::report::unauthenticated_accepted`, `labeler::report::malformed_bearer_accepted`. 315 + - Fixtures: 401 with typical envelope, 200 (failure path), non-envelope 401 response. 316 + 317 + **Dependencies:** Phase 4. 318 + 319 + **Done when:** Integration tests cover both checks' success and failure paths. Covers **labeler-report-stage.AC2.1** through **AC2.4**. 320 + <!-- END_PHASE_5 --> 321 + 322 + <!-- START_PHASE_6 --> 323 + ### Phase 6: Self-mint negative checks 324 + 325 + **Goal:** The four checks requiring a valid-signature JWT with mutated claims or body. 326 + 327 + **Components:** 328 + - `report::wrong_aud_rejected` — mutate `aud` to `did:plc:0000000000000000000000000`. 329 + - `report::wrong_lxm_rejected` — mutate `lxm` to `com.atproto.server.getSession`. 330 + - `report::expired_rejected` — mutate `exp` to `now - 300`, `iat` to `now - 360`. 331 + - `report::rejected_shape_returns_400` — valid claims but body with a `reasonType` not in `labeler_policies.reasonTypes`; assert status 400 with `error: "InvalidRequest"`. 332 + - Self-mint viability gating applied — when `self_mint_viable == false`, all four emit `Skipped` with the override hint. 333 + - Diagnostic codes `labeler::report::wrong_aud_accepted`, `wrong_lxm_accepted`, `expired_accepted`, `shape_not_400`. 334 + 335 + **Dependencies:** Phase 2 (SelfMintSigner), Phase 4 (stage wiring). 336 + 337 + **Done when:** Integration tests cover each check's success/failure paths and the viability-heuristic skip path. Covers **labeler-report-stage.AC3.1** through **AC3.6**. 338 + <!-- END_PHASE_6 --> 339 + 340 + <!-- START_PHASE_7 --> 341 + ### Phase 7: Self-mint positive check + pollution avoidance 342 + 343 + **Goal:** Land `report::self_mint_accepted` with the pollution-avoidance rules applied based on labeler locality. 344 + 345 + **Components:** 346 + - `report::self_mint_accepted` implementation — well-formed JWT, advertised `reasonType`/`subject`, sentinel `reason`, expect 2xx + `createReport#output` body shape. 347 + - Pollution-avoidance helper module: `choose_reason_type(policies, local) -> Nsid` (prefers `reasonOther` when non-local), `choose_subject(policies, local, reporter_did, subject_override) -> Subject` (prefers `record` with hard-coded AT-URI when non-local and advertised). 348 + - Placeholder constant `const CONFORMANCE_REPORT_SUBJECT_URI: &str = "<TBD: at://did:plc:... — release-gate>"` with a release-checklist entry in the implementation plan. 349 + - Diagnostic code `labeler::report::self_mint_rejected`. 350 + - `--commit-report` gating: emits `Skipped` with advisory reason when false. 351 + 352 + **Dependencies:** Phase 6 (self-mint plumbing). 353 + 354 + **Done when:** Integration tests cover: successful 2xx (local labeler, lex-first reasonType, account subject); successful 2xx (non-local, reasonOther, record subject); 4xx failure emits SpecViolation; commit-false skip; self-mint-unviable skip. Covers **labeler-report-stage.AC4.1** through **AC4.6**. 355 + <!-- END_PHASE_7 --> 356 + 357 + <!-- START_PHASE_8 --> 358 + ### Phase 8: PDS modes and end-to-end integration 359 + 360 + **Goal:** Land the two PDS-backed positive checks, the remaining CLI flags, and the complete end-to-end snapshot coverage for the 10-check contract. 361 + 362 + **Components:** 363 + - `PdsJwtFetcher` concrete type — calls `com.atproto.server.createSession` and `com.atproto.server.getServiceAuth` via the existing `HttpClient` seam. 364 + - `PdsProxiedPoster` concrete type — POSTs `com.atproto.moderation.createReport` to the PDS with `atproto-proxy` header via `HttpClient`. 365 + - `report::pds_service_auth_accepted` — uses `PdsJwtFetcher` to mint JWT, then `CreateReportTee` to POST to labeler. 366 + - `report::pds_proxied_accepted` — uses `PdsProxiedPoster` to POST via PDS. 367 + - Diagnostic codes `labeler::report::pds_service_auth_rejected`, `labeler::report::pds_proxied_rejected`. 368 + - CLI flags: `--handle`, `--app-password` (with clap `requires`). 369 + - End-to-end snapshot fixtures: `all_pass_local_labeler/` (7 applicable checks pass, PDS checks Skipped), `all_pass_full_suite/` (all 10 checks pass), `all_fail_misconfigured_labeler/` (negatives pass, positives fail). 370 + 371 + **Dependencies:** Phase 7. 372 + 373 + **Done when:** Integration tests cover each PDS mode's success/failure/skip paths; end-to-end snapshots confirm the full 10-row output contract across representative runtime configurations. Covers **labeler-report-stage.AC5.1** through **AC5.5** and **AC6.1** through **AC6.5**. Also verifies **AC7.1** (always-10-rows invariant) and **AC8** (CLI-flag validation) via `tests/labeler_cli.rs` extensions. 374 + <!-- END_PHASE_8 --> 375 + 376 + ## Additional Considerations 377 + 378 + **Pre-release TODO: hard-coded subject AT-URI.** Phase 7 introduces `CONFORMANCE_REPORT_SUBJECT_URI` as a placeholder constant. Before v1 ships, the user must (a) publish an explanation post on an atproto account explaining what atproto-devtool is, (b) capture its AT-URI and CID, and (c) replace the placeholder constant. The implementation plan must include this as an explicit release-gate checklist item, not code work. 379 + 380 + **Error envelope assertion is deliberately loose.** Research confirmed that labelers using `@atproto/xrpc-server` return specific error names (`AuthenticationRequired`, `BadJwt`, `BadJwtAudience`, `BadJwtLexiconMethod`, `JwtExpired`, `BadJwtSignature`). Non-xrpc-server labelers may use different names while still being conformant. Negative checks assert "status = 401 AND envelope has a non-empty `error` field," not specific error strings. A SecondaryAdvisory surfaces the specific `error` value when it doesn't match Ozone's vocabulary — useful for debugging without causing false SpecViolations. 381 + 382 + **PDS call failures distinguish from labeler failures.** When mode 2 or mode 3 fails at the PDS call itself (unreachable, invalid credentials, PDS error), the check emits `NetworkError`, not `SpecViolation` — the labeler never got tested. Diagnostic text clarifies which hop failed. This means exit code 2 from a PDS-side failure, exit code 1 from a labeler rejection; the tool's existing exit-code precedence rule holds. 383 + 384 + **Snapshot churn during development.** Every new `report::*` check ID and diagnostic code is part of the public snapshot contract. Breaking one is a CLI-surface change. The implementation plan should note that snapshot reviews (`cargo insta review`) are the expected workflow during development, and that any phase introducing new IDs must pin them before being considered done. 385 + 386 + **No cross-run state.** Each `test labeler` invocation is a fresh run; no persisted state between runs. The `<run-id>` in the sentinel reason is generated per invocation and used only within that run.
+755
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_01.md
··· 1 + # Labeler report stage — Phase 1: Shared primitives 2 + 3 + **Goal:** Land the crate-wide primitives (signing-key newtype, hand-rolled JWT helpers, sentinel-reason builder, local-labeler viability classifier) that later phases depend on. No report-stage code is introduced yet. 4 + 5 + **Architecture:** Extend `src/common/identity.rs` with a mirror of `AnyVerifyingKey` for signing keys, introduce a new tiny `src/common/jwt.rs` module for compact JWS (hand-rolled over already-present `k256`/`p256`/`sha2`/`base64`), and stage-local helpers for the sentinel-reason string and hostname classification. No new Cargo dependencies. 6 + 7 + **Tech Stack:** Rust 2024, `k256`/`p256` ECDSA, `sha2`, `base64` (all already in Cargo.toml), `std::net::Ipv4Addr::is_private/is_loopback`, `serde_json`. 8 + 9 + **Scope:** Phase 1 of 8 from design `docs/design-plans/2026-04-17-labeler-report-stage.md`. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `Cargo.toml`, `src/common/identity.rs` lines 1-320, `src/commands/test/labeler/CLAUDE.md`, `src/common/CLAUDE.md`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `AnyVerifyingKey` at `src/common/identity.rs:112-117` has variants `K256(k256::ecdsa::VerifyingKey)` and `P256(p256::ecdsa::VerifyingKey)`. Design said `Secp256k1` — actual variant is `K256`. New `AnySigningKey` MUST use matching variant names (`K256`, `P256`) to align with `AnyVerifyingKey`. 15 + - ✓ `AnySignature` at `src/common/identity.rs:150-155` has `K256(k256::ecdsa::Signature)` and `P256(p256::ecdsa::Signature)`. No existing `normalize_s` helper — must normalize on construction. 16 + - ✓ `k256` and `p256` in `Cargo.toml:31,34` with `ecdsa` feature. 17 + - ✓ `src/common/` currently contains only `identity.rs` and `diagnostics.rs`. `jwt.rs` does NOT exist. 18 + - ✓ `src/common.rs` (or `src/common/mod.rs`) sibling file — verify existence via `ls src/common.rs` during Task 1. The crate uses foo.rs + foo/ sibling-file layout (CLAUDE.md convention), so `src/common.rs` exists and declares `pub mod identity;` / `pub mod diagnostics;`. 19 + - ✓ `src/commands/test/labeler/` contains `crypto.rs`, `http.rs`, `identity.rs`, `subscription.rs`, `pipeline.rs`, `report.rs`. No `create_report.rs` / `create_report/` yet. 20 + - ✓ No hostname-classification functions in `src/common/`. 21 + - ✓ No `chrono`/`time`/`uuid` crate. Sentinel RFC3339 and `jti` randomness must be hand-rolled. 22 + - ✓ `rustc 1.85` + Rust 2024 edition. 23 + - ✓ `uninlined_format_args = "deny"` — use `format!("{x}")`, never `format!("{}", x)`. 24 + - + `percent-encoding` at `Cargo.toml:35` is already a dep (will be used in Phase 2 for `did:web:127.0.0.1%3A{port}`). 25 + - + `base64 = "0.22"` already in `Cargo.toml:23` with `base64::Engine` usage pattern at `src/commands/test/labeler/http.rs:12,361-363` (URL_SAFE_NO_PAD needed for JWT). 26 + 27 + **External dependency research findings:** 28 + - ✓ `k256::ecdsa::SigningKey::sign_prehash(&hash) -> Result<Signature>` produces a low-s-normalized signature by default (BIP-0062 low-s enforcement is built-in). Returns a non-recoverable `k256::ecdsa::Signature` whose `r || s` bytes can be extracted via `Signature::to_bytes() -> GenericArray<u8, 64>`. 29 + - ✓ `p256::ecdsa::SigningKey::sign_prehash(&hash) -> Result<Signature>` returns a `p256::ecdsa::Signature` that may be high-s; call `Signature::normalize_s()` to get the low-s form. Both curves: the resulting JWS signature bytes are the raw `r || s` 64-byte big-endian concatenation (not DER), per RFC 7518 §3.4 (ES256) and §3.8 (analogously for ES256K). 30 + - ✓ JWT compact serialization per RFC 7515: `BASE64URL(UTF8(header)).BASE64URL(UTF8(claims)).BASE64URL(signature)` with unpadded base64url (`STANDARD_NO_PAD` is wrong encoding; we need `URL_SAFE_NO_PAD`). 31 + - ✓ Header fields: `{"typ":"JWT","alg":"ES256K"}` or `"ES256"`. `typ` is required in atproto service-auth (observed in Ozone JWT validation). 32 + - ✓ `sha2::Sha256` is already a dep; `Digest::digest` consumes a `&[u8]` and returns a 32-byte array — use for the JWT prehash (the string `header_b64 + "." + claims_b64`). 33 + - ✓ `jti`: atproto spec says "unique random nonce string"; no format constraint. Hex of 16 random bytes (32 hex chars) is a standard choice. **Verified 2026-04-17 via `cargo read` + dependency inspection:** `k256 0.13.4` → `elliptic-curve 0.13.8` → `rand_core 0.6.4` with `default-features = false`, which means `OsRng` is NOT re-exported through `k256::elliptic_curve::rand_core` (OsRng is gated behind the `getrandom` feature). **Resolution: add `getrandom = "0.2"` as a direct dep in `Cargo.toml`.** This is a narrow, well-maintained crate already present transitively (three versions in the lockfile); promoting it to a direct dep costs nothing and is the smallest viable fix. The design's "no new crate dependencies" note assumed OsRng was reachable; investigation showed otherwise. 34 + - ✓ RFC 3339 UTC: format as `YYYY-MM-DDTHH:MM:SSZ` from `SystemTime::now().duration_since(UNIX_EPOCH)`. A 16-line hand-rolled formatter is adequate (no chrono/time needed). Leap seconds ignored; the sentinel reason is a human-readable label, not a parseable timestamp. 35 + - ✓ Hostname classifier: `std::net::Ipv4Addr::is_private()` matches RFC 1918 ranges exactly (10/8, 172.16/12, 192.168/16). `is_loopback()` covers 127/8. IPv6 `::1` via `Ipv6Addr::is_loopback()`. `.local` suffix is a case-insensitive string match. `localhost` is a case-insensitive string match. 36 + 37 + --- 38 + 39 + ## Acceptance criteria coverage 40 + 41 + This phase implements and tests infrastructure only. No acceptance criteria from the design are directly verified here — these primitives are exercised through phases 2-8. 42 + 43 + **Verifies: None** — Phase 1 is pure scaffolding. 44 + 45 + --- 46 + 47 + <!-- START_TASK_0 --> 48 + ### Task 0: Add `getrandom` as a direct Cargo dep 49 + 50 + **Files:** 51 + - Modify: `Cargo.toml` — add a new entry in `[dependencies]`. 52 + 53 + **Implementation:** 54 + 55 + ```toml 56 + # Direct CSPRNG access for JWT `jti` nonces and the sentinel run-id. 57 + # Transitively present (k256 → elliptic-curve → rand_core with the 58 + # `getrandom` feature disabled), so we can't reach OsRng through the 59 + # existing dep graph. Promoting `getrandom` to a direct dep is the 60 + # smallest viable fix. 61 + getrandom = "0.2" 62 + ``` 63 + 64 + Place the entry in the existing `[dependencies]` block at `Cargo.toml:17-45` in alphabetical order (between `futures-util` and `hickory-resolver`). 65 + 66 + **Verification:** 67 + Run: `cargo build` 68 + Expected: new dep is resolved; all existing deps still resolve. 69 + 70 + Run: `cargo tree -p getrandom@0.2 --depth 0 2>/dev/null | head -5` 71 + Expected: `getrandom v0.2.*` appears as a direct dep of `atproto-devtool`. 72 + 73 + **Commit:** `chore(deps): add getrandom for CSPRNG access` 74 + <!-- END_TASK_0 --> 75 + 76 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 77 + <!-- START_TASK_1 --> 78 + ### Task 1: Add `AnySigningKey` enum mirroring `AnyVerifyingKey` 79 + 80 + **Files:** 81 + - Modify: `src/common/identity.rs` — add new enum and impl block directly after `impl AnyVerifyingKey { ... }` (after current line ~146, before `pub enum AnySignature` at line 150). 82 + - Modify: `src/common.rs` if it re-exports symbols — verify and extend if needed. (If `src/common.rs` only contains `pub mod identity;` / `pub mod diagnostics;`, no change is required; `crate::common::identity::AnySigningKey` works.) 83 + 84 + **Implementation:** 85 + 86 + Define a new newtype enum parallel to `AnyVerifyingKey`: 87 + 88 + ```rust 89 + /// A private signing key that may be one of several supported curves. 90 + /// 91 + /// Mirrors `AnyVerifyingKey` for the signing side. Signatures produced by 92 + /// `sign` are always low-s normalized to match the atproto convention 93 + /// already established by `AnySignature`. 94 + #[derive(Debug, Clone)] 95 + pub enum AnySigningKey { 96 + /// secp256k1 signing key. 97 + K256(k256::ecdsa::SigningKey), 98 + /// P-256 signing key. 99 + P256(p256::ecdsa::SigningKey), 100 + } 101 + 102 + impl AnySigningKey { 103 + /// Returns the corresponding verifying key. 104 + pub fn verifying_key(&self) -> AnyVerifyingKey { 105 + match self { 106 + AnySigningKey::K256(k) => AnyVerifyingKey::K256(*k.verifying_key()), 107 + AnySigningKey::P256(k) => AnyVerifyingKey::P256(*k.verifying_key()), 108 + } 109 + } 110 + 111 + /// Returns the JWT `alg` header identifier for this key's curve 112 + /// ("ES256K" for secp256k1, "ES256" for P-256). 113 + pub fn jwt_alg(&self) -> &'static str { 114 + match self { 115 + AnySigningKey::K256(_) => "ES256K", 116 + AnySigningKey::P256(_) => "ES256", 117 + } 118 + } 119 + 120 + /// Signs the SHA-256 prehash of `msg` and returns the signature in 121 + /// low-s normalized form. 122 + /// 123 + /// The returned `AnySignature` is guaranteed to satisfy 124 + /// `AnyVerifyingKey::verify_prehash` against the corresponding 125 + /// verifying key when given the same prehash bytes. 126 + pub fn sign(&self, msg: &[u8]) -> AnySignature { 127 + use sha2::{Digest, Sha256}; 128 + let prehash: [u8; 32] = Sha256::digest(msg).into(); 129 + self.sign_prehash(&prehash) 130 + } 131 + 132 + /// Signs a precomputed 32-byte SHA-256 prehash directly. 133 + pub fn sign_prehash(&self, prehash: &[u8; 32]) -> AnySignature { 134 + use k256::ecdsa::signature::hazmat::PrehashSigner as K256PrehashSigner; 135 + use p256::ecdsa::signature::hazmat::PrehashSigner as P256PrehashSigner; 136 + match self { 137 + AnySigningKey::K256(k) => { 138 + // k256's sign_prehash already returns a low-s normalized 139 + // signature (BIP-0062 enforcement is built in). Returns an 140 + // ecdsa::Signature. 141 + let sig: k256::ecdsa::Signature = 142 + K256PrehashSigner::sign_prehash(k, prehash) 143 + .expect("SHA-256 output is always 32 bytes"); 144 + AnySignature::K256(sig) 145 + } 146 + AnySigningKey::P256(k) => { 147 + // p256's sign_prehash may return a high-s signature; 148 + // normalize explicitly. 149 + let sig: p256::ecdsa::Signature = 150 + P256PrehashSigner::sign_prehash(k, prehash) 151 + .expect("SHA-256 output is always 32 bytes"); 152 + let normalized = sig.normalize_s().unwrap_or(sig); 153 + AnySignature::P256(normalized) 154 + } 155 + } 156 + } 157 + 158 + /// Serializes the signature bytes for JWS compact form: raw `r || s` 159 + /// big-endian concatenation (NOT DER). 160 + /// 161 + /// For both ES256 and ES256K this is a 64-byte fixed-length array. 162 + pub fn signature_to_jws_bytes(sig: &AnySignature) -> [u8; 64] { 163 + match sig { 164 + AnySignature::K256(s) => s.to_bytes().into(), 165 + AnySignature::P256(s) => s.to_bytes().into(), 166 + } 167 + } 168 + } 169 + ``` 170 + 171 + **Notes for the implementor:** 172 + - `PrehashSigner::sign_prehash` returns `Result<Signature, Error>`; the only failure case is an invalid prehash length, which cannot happen for a fixed 32-byte array. The `.expect()` on a known-32-byte input is safe. If clippy complains, an `unreachable!()` after a `match prehash.len()` check is equivalent. 173 + - `k256::ecdsa::Signature::to_bytes()` and `p256::ecdsa::Signature::to_bytes()` both return a `GenericArray<u8, 64>` (32-byte `r` ++ 32-byte `s`). Convert to `[u8; 64]` with `.into()`. 174 + - Imports: no new imports at the top of the file are required for the fn itself beyond what's already present (`sha2::Digest`, `k256`, `p256`). Import the `PrehashSigner` traits inside the function with aliased names per the existing pattern at line 7 (`use k256::ecdsa::signature::hazmat::PrehashVerifier`). 175 + 176 + **Testing:** 177 + 178 + Add a unit-test module inside `src/common/identity.rs` (or extend the existing one if present — search for `#[cfg(test)]`). Tests must verify: 179 + 180 + - Generating a random `AnySigningKey::K256` and `AnySigningKey::P256`, signing the same message, and verifying against the `verifying_key()` succeeds with `verify_prehash`. 181 + - `AnySigningKey::jwt_alg()` returns `"ES256K"` and `"ES256"` respectively. 182 + - `AnySigningKey::signature_to_jws_bytes(&sig)` produces a 64-byte array. 183 + - For P-256: signing yields a low-s signature. After `sig.normalize_s().unwrap_or(sig)`, the resulting `s` scalar is ≤ n/2. (A deterministic test: generate a fixed seed, sign known input, assert `s` is in the low half of the curve order by comparing to `Scalar::ONE.neg().div_by_two()` or use `ecdsa::hazmat::bits2field` identity — simpler is to verify the signature round-trips with a standalone verifier that rejects high-s.) 184 + 185 + Follow the existing test pattern at the bottom of `src/common/identity.rs` (search for `#[cfg(test)]`). Use `k256::ecdsa::SigningKey::random(&mut OsRng)` — the `OsRng` path is `k256::elliptic_curve::rand_core::OsRng` (re-exported via the `rand_core` feature of the underlying `elliptic-curve` crate). If `OsRng` is not re-exported, use a deterministic seed: `SigningKey::from_slice(&[1u8; 32])` (any non-zero 32-byte value works for test purposes). 186 + 187 + **Verification:** 188 + Run: `cargo test --lib common::identity::tests::any_signing_key_` 189 + Expected: all new tests pass. 190 + 191 + Run: `cargo clippy --all-targets -- -D warnings` 192 + Expected: no warnings. 193 + 194 + **Commit:** `feat(identity): add AnySigningKey newtype with ES256/ES256K low-s signing` 195 + <!-- END_TASK_1 --> 196 + 197 + <!-- START_TASK_2 --> 198 + ### Task 2: Add hand-rolled JWT helpers in `src/common/jwt.rs` 199 + 200 + **Files:** 201 + - Create: `src/common/jwt.rs` — new module. 202 + - Modify: `src/common.rs` — add `pub mod jwt;`. 203 + - Test: unit tests in the same file under `#[cfg(test)]`. 204 + 205 + **Implementation:** 206 + 207 + Create the module with a minimal compact-form JWS encoder and a round-trip decoder. No external JWT library; use `serde_json` + `base64` + the new `AnySigningKey`. 208 + 209 + ```rust 210 + //! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for 211 + //! atproto service-auth. 212 + //! 213 + //! This module exists to avoid pulling a full JWT library for a handful of 214 + //! tightly-scoped use cases: minting self-mint JWTs for labeler conformance 215 + //! tests, and decoding them in tests to verify round-trip correctness. 216 + //! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 217 + //! encoding, unpadded base64url segments, UTF-8 JSON payloads. 218 + 219 + use base64::Engine; 220 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 221 + use serde::{Deserialize, Serialize}; 222 + use thiserror::Error; 223 + 224 + use crate::common::identity::{AnySignature, AnySigningKey, AnyVerifyingKey, AnySignatureError}; 225 + 226 + /// Compact JWS header for atproto service-auth tokens. 227 + /// 228 + /// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT 229 + /// rename without updating `serde` attributes — atproto wire format 230 + /// requires exactly `alg` and `typ`. 231 + #[derive(Debug, Clone, Serialize, Deserialize)] 232 + pub struct JwtHeader { 233 + /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256). 234 + pub alg: String, 235 + /// Token type; always "JWT". 236 + pub typ: String, 237 + } 238 + 239 + impl JwtHeader { 240 + /// Build a header for the given signing key, setting `alg` to match the 241 + /// curve and `typ` to `"JWT"`. 242 + pub fn for_signing_key(key: &AnySigningKey) -> Self { 243 + Self { 244 + alg: key.jwt_alg().to_string(), 245 + typ: "JWT".to_string(), 246 + } 247 + } 248 + } 249 + 250 + /// Atproto service-auth JWT claims. 251 + /// 252 + /// Fields match the atproto inter-service authentication spec: 253 + /// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is 254 + /// deliberately omitted — the spec does not require it and some servers 255 + /// reject unexpected claims. 256 + /// 257 + /// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`, 258 + /// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT 259 + /// rename without adding `#[serde(rename = "...")]` attributes. 260 + #[derive(Debug, Clone, Serialize, Deserialize)] 261 + pub struct JwtClaims { 262 + /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`). 263 + pub iss: String, 264 + /// Audience — the target service's DID, bare (no `#fragment`). 265 + pub aud: String, 266 + /// Expiration, UNIX seconds. 267 + pub exp: i64, 268 + /// Issued-at, UNIX seconds. 269 + pub iat: i64, 270 + /// Lexicon method NSID the token authorizes (e.g., 271 + /// `com.atproto.moderation.createReport`). 272 + pub lxm: String, 273 + /// Random nonce to prevent replay — hex string, 32 chars (16 bytes). 274 + pub jti: String, 275 + } 276 + 277 + /// Errors from JWT encode/decode. 278 + /// 279 + /// **Not user-rendered:** these errors only surface inside tests and 280 + /// library helpers. They deliberately do NOT derive `miette::Diagnostic` 281 + /// with stable codes — the stage converts any failure into a 282 + /// `CreateReportStageError::Transport` or a specific check SpecViolation 283 + /// before rendering. If a future caller needs one of these variants 284 + /// rendered to the user, they must wrap it in a stage-local diagnostic 285 + /// with a proper `code = "labeler::..."` string. 286 + #[derive(Debug, Error)] 287 + pub enum JwtError { 288 + /// Compact form was not three `.`-separated base64url segments. 289 + #[error("malformed compact JWT: expected three segments")] 290 + MalformedCompact, 291 + /// A base64url segment failed to decode. 292 + #[error("base64url decode failed for {segment}")] 293 + Base64Decode { 294 + /// Which segment failed: "header", "claims", or "signature". 295 + segment: &'static str, 296 + /// Underlying base64 error. 297 + #[source] 298 + source: base64::DecodeError, 299 + }, 300 + /// A segment decoded to valid bytes but invalid JSON. 301 + #[error("JSON decode failed for {segment}")] 302 + JsonDecode { 303 + /// Which segment failed: "header" or "claims". 304 + segment: &'static str, 305 + /// Underlying serde_json error. 306 + #[source] 307 + source: serde_json::Error, 308 + }, 309 + /// JSON serialization of header or claims failed (should not happen for 310 + /// well-formed structs). 311 + #[error("JSON encode failed")] 312 + JsonEncode(#[from] serde_json::Error), 313 + /// Signature was not exactly 64 bytes. 314 + #[error("signature was {actual} bytes; expected 64")] 315 + SignatureLength { 316 + /// Actual length in bytes. 317 + actual: usize, 318 + }, 319 + /// The algorithm identifier in the header is not recognized. 320 + #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")] 321 + UnsupportedAlg { 322 + /// The unrecognized algorithm string. 323 + alg: String, 324 + }, 325 + /// Underlying ECDSA verification failure (e.g., curve mismatch). 326 + #[error("signature verification failed")] 327 + SignatureVerify(#[from] AnySignatureError), 328 + } 329 + 330 + /// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`. 331 + /// 332 + /// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 333 + /// prehash under the supplied key. Returns the full compact token string. 334 + pub fn encode_compact( 335 + header: &JwtHeader, 336 + claims: &JwtClaims, 337 + signer: &AnySigningKey, 338 + ) -> Result<String, JwtError> { 339 + let header_json = serde_json::to_vec(header)?; 340 + let claims_json = serde_json::to_vec(claims)?; 341 + let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); 342 + let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json); 343 + let signing_input = format!("{header_b64}.{claims_b64}"); 344 + let sig = signer.sign(signing_input.as_bytes()); 345 + let sig_bytes = AnySigningKey::signature_to_jws_bytes(&sig); 346 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); 347 + Ok(format!("{header_b64}.{claims_b64}.{sig_b64}")) 348 + } 349 + 350 + /// Decode a compact JWT into `(header, claims, signature_bytes)`. 351 + /// 352 + /// Does NOT verify the signature — use `verify_compact` for that. This helper 353 + /// is primarily for test round-tripping and for negative-test assertions 354 + /// (e.g., "the minted token has the expected `alg` header"). 355 + pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 356 + let mut parts = token.splitn(3, '.'); 357 + let header_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 358 + let claims_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 359 + let sig_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 360 + if parts.next().is_some() { 361 + return Err(JwtError::MalformedCompact); 362 + } 363 + let header_bytes = URL_SAFE_NO_PAD 364 + .decode(header_b64) 365 + .map_err(|source| JwtError::Base64Decode { segment: "header", source })?; 366 + let claims_bytes = URL_SAFE_NO_PAD 367 + .decode(claims_b64) 368 + .map_err(|source| JwtError::Base64Decode { segment: "claims", source })?; 369 + let sig_bytes = URL_SAFE_NO_PAD 370 + .decode(sig_b64) 371 + .map_err(|source| JwtError::Base64Decode { segment: "signature", source })?; 372 + let header: JwtHeader = serde_json::from_slice(&header_bytes) 373 + .map_err(|source| JwtError::JsonDecode { segment: "header", source })?; 374 + let claims: JwtClaims = serde_json::from_slice(&claims_bytes) 375 + .map_err(|source| JwtError::JsonDecode { segment: "claims", source })?; 376 + Ok((header, claims, sig_bytes)) 377 + } 378 + 379 + /// Verify a compact JWT against the given verifying key. Does NOT check 380 + /// claim values (exp/aud/lxm) — that is the labeler's job in production, 381 + /// or the stage's assertion job in tests. Only verifies the signature. 382 + pub fn verify_compact(token: &str, vkey: &AnyVerifyingKey) -> Result<(JwtHeader, JwtClaims), JwtError> { 383 + let (header, claims, sig_bytes) = decode_compact(token)?; 384 + let expected_alg = match vkey { 385 + AnyVerifyingKey::K256(_) => "ES256K", 386 + AnyVerifyingKey::P256(_) => "ES256", 387 + }; 388 + if header.alg != expected_alg { 389 + return Err(JwtError::UnsupportedAlg { alg: header.alg.clone() }); 390 + } 391 + if sig_bytes.len() != 64 { 392 + return Err(JwtError::SignatureLength { actual: sig_bytes.len() }); 393 + } 394 + let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above"); 395 + let any_sig = match vkey { 396 + AnyVerifyingKey::K256(_) => { 397 + let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into()) 398 + .map_err(|_| JwtError::SignatureLength { actual: sig_bytes.len() })?; 399 + AnySignature::K256(sig) 400 + } 401 + AnyVerifyingKey::P256(_) => { 402 + let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into()) 403 + .map_err(|_| JwtError::SignatureLength { actual: sig_bytes.len() })?; 404 + AnySignature::P256(sig) 405 + } 406 + }; 407 + // Recompute the signing input and verify. 408 + let dot = token.rfind('.').expect("three-segment token has a last dot"); 409 + let signing_input = &token[..dot]; 410 + use sha2::{Digest, Sha256}; 411 + let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into(); 412 + vkey.verify_prehash(&prehash, &any_sig)?; 413 + Ok((header, claims)) 414 + } 415 + ``` 416 + 417 + **Testing:** 418 + 419 + Unit tests in the same file under `#[cfg(test)]`: 420 + 421 + - Round-trip for both curves: build a random signing key, encode a JwtClaims struct, decode with `verify_compact`, assert header and claims fields match. 422 + - `encode_compact` then tampering with the payload and calling `verify_compact` returns an error. 423 + - `decode_compact` rejects a two-segment or four-segment input with `MalformedCompact`. 424 + - `decode_compact` rejects invalid base64url with `Base64Decode`. 425 + - `verify_compact` rejects a JWT signed with one curve against the other curve's verifying key with `UnsupportedAlg`. 426 + 427 + **Verification:** 428 + Run: `cargo test --lib common::jwt::tests` 429 + Expected: all tests pass. 430 + 431 + Run: `cargo clippy --all-targets -- -D warnings` 432 + Expected: no warnings. 433 + 434 + **Commit:** `feat(jwt): add hand-rolled compact JWS helpers for ES256/ES256K` 435 + <!-- END_TASK_2 --> 436 + 437 + <!-- START_TASK_3 --> 438 + ### Task 3: Tests proving `AnySigningKey` + `jwt::encode_compact` round-trip end-to-end 439 + 440 + **Files:** 441 + - Modify: `src/common/jwt.rs` — add integration-style test asserting a signed token can be verified by `AnyVerifyingKey::verify_prehash` via `verify_compact`. 442 + - Modify: `src/common/identity.rs` — add a test that generates an `AnySigningKey::P256` key whose underlying `sign_prehash` would produce high-s, and verifies that `AnySigningKey::sign` normalizes. 443 + 444 + **Testing:** 445 + 446 + Tests must verify (these are the "subcomponent completeness" assertions): 447 + 448 + - Given a random `AnySigningKey::K256`, a `JwtClaims` struct, and `JwtHeader::for_signing_key`: `encode_compact` → `verify_compact` succeeds and returns the same claim fields. 449 + - Same for `AnySigningKey::P256`. 450 + - A token with a tampered `claims` segment fails `verify_compact` with `SignatureVerify`. 451 + - `encode_compact` output satisfies `token.split('.').count() == 3` and every segment decodes as URL-safe-no-pad base64. 452 + 453 + **Verification:** 454 + Run: `cargo test --lib common::jwt` 455 + Expected: 4+ tests pass, all green. 456 + 457 + **Commit:** `test(jwt): round-trip ES256/ES256K tokens against AnyVerifyingKey` 458 + <!-- END_TASK_3 --> 459 + <!-- END_SUBCOMPONENT_A --> 460 + 461 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 462 + <!-- START_TASK_4 --> 463 + ### Task 4: Add `is_local_labeler_hostname` classifier in `src/common/identity.rs` 464 + 465 + **Files:** 466 + - Modify: `src/common/identity.rs` — add a free function near the bottom of the module (after the existing `plc_history_for_fragment` helpers, before the `#[cfg(test)]` mod). 467 + 468 + **Implementation:** 469 + 470 + ```rust 471 + /// Classify a URL's hostname as "locally reachable from the tool's 472 + /// machine" for the purposes of self-mint `did:web` viability. 473 + /// 474 + /// Returns `true` when the hostname is one of: 475 + /// - `localhost` (case-insensitive) 476 + /// - `127.0.0.1` (or any IPv4 loopback / `::1`) 477 + /// - Any `.local` mDNS suffix (case-insensitive) 478 + /// - Any RFC 1918 IPv4 private address (10/8, 172.16/12, 192.168/16) 479 + /// 480 + /// Returns `false` for all other hostnames. IPv6 private ranges (fc00::/7, 481 + /// link-local) are deliberately NOT classified as local in v1; revisit if 482 + /// users report issues. 483 + pub fn is_local_labeler_hostname(url: &Url) -> bool { 484 + let host = match url.host_str() { 485 + Some(h) => h, 486 + None => return false, 487 + }; 488 + let lower = host.to_ascii_lowercase(); 489 + if lower == "localhost" { 490 + return true; 491 + } 492 + if lower.ends_with(".local") { 493 + return true; 494 + } 495 + if let Ok(ipv4) = lower.parse::<std::net::Ipv4Addr>() { 496 + return ipv4.is_loopback() || ipv4.is_private(); 497 + } 498 + if let Ok(ipv6) = lower.parse::<std::net::Ipv6Addr>() { 499 + return ipv6.is_loopback(); 500 + } 501 + false 502 + } 503 + ``` 504 + 505 + **Testing:** 506 + 507 + Unit test table near the existing tests in `src/common/identity.rs`: 508 + 509 + ```rust 510 + #[test] 511 + fn is_local_labeler_hostname_classifies_expected_hosts() { 512 + let cases: &[(&str, bool)] = &[ 513 + // Positive: localhost variants. 514 + ("http://localhost/", true), 515 + ("https://LOCALHOST:8080/foo", true), 516 + ("http://127.0.0.1/", true), 517 + ("http://127.1.2.3/", true), 518 + ("http://[::1]/", true), 519 + // Positive: .local mDNS. 520 + ("http://mybox.local/", true), 521 + ("https://mybox.LOCAL:8443/", true), 522 + // Positive: RFC 1918. 523 + ("http://10.0.0.1/", true), 524 + ("http://172.16.0.1/", true), 525 + ("http://172.31.255.255/", true), 526 + ("http://192.168.1.100/", true), 527 + // Negative: public. 528 + ("https://labeler.example.com/", false), 529 + ("http://8.8.8.8/", false), 530 + ("http://172.15.0.1/", false), // outside 172.16/12 531 + ("http://172.32.0.1/", false), // outside 172.16/12 532 + ("http://11.0.0.1/", false), // outside 10/8 once we pass 10.x 533 + ("http://172.17.1.1/", true), // inside 172.16/12 534 + ]; 535 + for (url, expected) in cases { 536 + let parsed = url::Url::parse(url).expect("test URLs are valid"); 537 + assert_eq!( 538 + is_local_labeler_hostname(&parsed), 539 + *expected, 540 + "classification mismatch for {url}" 541 + ); 542 + } 543 + } 544 + ``` 545 + 546 + **Verification:** 547 + Run: `cargo test --lib common::identity::tests::is_local_labeler_hostname_` 548 + Expected: table test passes. 549 + 550 + **Commit:** `feat(identity): classify labeler hostnames as local vs remote` 551 + <!-- END_TASK_4 --> 552 + 553 + <!-- START_TASK_5 --> 554 + ### Task 5: Sentinel-reason builder module 555 + 556 + **Files:** 557 + - Create: `src/commands/test/labeler/create_report.rs` — new file containing only the module declaration and a pub-use re-export of the `sentinel` submodule. (This seeds the module tree; phases 3-4 will add the stage's actual logic.) 558 + - Create: `src/commands/test/labeler/create_report/` — new sibling directory. 559 + - Create: `src/commands/test/labeler/create_report/sentinel.rs` — the builder. 560 + - Modify: `src/commands/test/labeler.rs` — add `pub mod create_report;` after the existing `pub mod crypto;` entry (maintaining alphabetical order: crypto, create_report — `create_report` comes before `crypto` alphabetically, so insert it as the new first entry). 561 + 562 + **Implementation:** 563 + 564 + `src/commands/test/labeler/create_report.rs` (seed file, will be extended in Phase 4): 565 + 566 + ```rust 567 + //! `report` stage: exercises the labeler's authenticated 568 + //! `com.atproto.moderation.createReport` path. 569 + //! 570 + //! Scaffolding only in Phase 1. Stage `run()` and full public surface land 571 + //! in Phase 4. The `sentinel` submodule is self-contained and is exercised 572 + //! by later phases for the pollution-avoidance sentinel reason string. 573 + 574 + pub mod sentinel; 575 + ``` 576 + 577 + `src/commands/test/labeler/create_report/sentinel.rs`: 578 + 579 + ```rust 580 + //! Builder for the sentinel `reason` field used in conformance-test reports. 581 + //! 582 + //! Every committing check's report body carries a stable, recognizable 583 + //! string in its `reason` field so that labeler operators can identify and 584 + //! dismiss reports submitted by `atproto-devtool` without mistaking them 585 + //! for real user reports. 586 + //! 587 + //! Format: `atproto-devtool conformance test <RFC3339-UTC> <run-id>` 588 + //! 589 + //! Example: `atproto-devtool conformance test 2026-04-17T12:34:56Z 5f9c1a3b4d7e8a0f` 590 + //! 591 + //! The run-id is a 16-hex-char random nonce generated once per pipeline run 592 + //! (not per check); the same run-id is reused across all report submissions 593 + //! within a single `test labeler` invocation so operators can trace a group 594 + //! of test reports back to one run. 595 + 596 + use std::time::{SystemTime, UNIX_EPOCH}; 597 + 598 + /// Prefix used so operators can grep their moderation queue for 599 + /// conformance-test reports with a single query. 600 + pub const SENTINEL_PREFIX: &str = "atproto-devtool conformance test"; 601 + 602 + /// Build a sentinel reason string. `run_id` should be a stable 16-hex-char 603 + /// identifier for the current test invocation; `now` is the current wall-clock 604 + /// time, typically `SystemTime::now()`. 605 + pub fn build(run_id: &str, now: SystemTime) -> String { 606 + let rfc3339 = format_rfc3339_utc(now); 607 + format!("{SENTINEL_PREFIX} {rfc3339} {run_id}") 608 + } 609 + 610 + /// Hand-rolled RFC 3339 UTC formatter: `YYYY-MM-DDTHH:MM:SSZ`. 611 + /// 612 + /// Avoids a `chrono` / `time` dependency. Leap seconds are not handled; 613 + /// the sentinel reason is a human-readable label, not a parseable timestamp. 614 + /// For times before the UNIX epoch or more than `i64::MAX` seconds in the 615 + /// future we degrade gracefully to `1970-01-01T00:00:00Z`. 616 + fn format_rfc3339_utc(ts: SystemTime) -> String { 617 + let secs = ts.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); 618 + let (year, month, day, hour, min, sec) = unix_to_civil(secs); 619 + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") 620 + } 621 + 622 + /// Convert UNIX seconds to a civil date-time (UTC) using Howard Hinnant's 623 + /// algorithm for the Gregorian calendar. Correct for all years in [1, 9999]. 624 + fn unix_to_civil(secs: i64) -> (i32, u32, u32, u32, u32, u32) { 625 + // Seconds-of-day. 626 + let days = secs.div_euclid(86_400); 627 + let sod = secs.rem_euclid(86_400); 628 + let hour = (sod / 3600) as u32; 629 + let min = ((sod % 3600) / 60) as u32; 630 + let sec = (sod % 60) as u32; 631 + 632 + // Days since 1970-01-01 -> civil date. Algorithm from 633 + // http://howardhinnant.github.io/date_algorithms.html#civil_from_days. 634 + let z = days + 719_468; 635 + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; 636 + let doe = (z - era * 146_097) as u32; // [0, 146096] 637 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] 638 + let y = yoe as i32 + era as i32 * 400; 639 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] 640 + let mp = (5 * doy + 2) / 153; // [0, 11] 641 + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] 642 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] 643 + let year = if m <= 2 { y + 1 } else { y }; 644 + (year, m, d, hour, min, sec) 645 + } 646 + 647 + /// Generate a random 16-hex-char run identifier. Uses `getrandom` 648 + /// (added as a direct dep during this task — see Cargo.toml edit below). 649 + pub fn new_run_id() -> String { 650 + let mut bytes = [0u8; 8]; 651 + getrandom::getrandom(&mut bytes).expect("OS CSPRNG is always available on supported platforms"); 652 + bytes.iter().map(|b| format!("{b:02x}")).collect() 653 + } 654 + ``` 655 + 656 + **Notes for the implementor:** 657 + - Howard Hinnant's algorithm above is correct for Gregorian civil dates in [0001-01-01, 9999-12-31]. The tests below pin a few known conversions so any transcription error is caught immediately. 658 + - If `k256::elliptic_curve::rand_core::OsRng` is not exposed in the installed version of `k256`, run `cargo read k256 --api | grep -i rand` during implementation to locate the re-export path. As a last resort, `getrandom = "0.2"` can be added to `Cargo.toml` — but the design explicitly targets "no new crate dependencies," so prefer the transitive path first. If a new dep is unavoidable, flag to the user rather than adding it silently. 659 + 660 + **Testing:** 661 + 662 + Unit tests in the same file: 663 + 664 + ```rust 665 + #[cfg(test)] 666 + mod tests { 667 + use super::*; 668 + 669 + #[test] 670 + fn format_rfc3339_utc_pins_known_points() { 671 + // 1970-01-01T00:00:00Z 672 + assert_eq!(format_rfc3339_utc(UNIX_EPOCH), "1970-01-01T00:00:00Z"); 673 + // 2026-04-17T00:00:00Z — 1_776_643_200 UNIX seconds. 674 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_776_643_200); 675 + assert_eq!(format_rfc3339_utc(t), "2026-04-17T00:00:00Z"); 676 + // Leap year: 2024-02-29T12:34:56Z — 1_709_210_096. 677 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_709_210_096); 678 + assert_eq!(format_rfc3339_utc(t), "2024-02-29T12:34:56Z"); 679 + } 680 + 681 + #[test] 682 + fn build_contains_prefix_and_run_id() { 683 + let s = build("abcdef1234567890", UNIX_EPOCH); 684 + assert!(s.starts_with(SENTINEL_PREFIX)); 685 + assert!(s.ends_with("abcdef1234567890")); 686 + assert!(s.contains("1970-01-01T00:00:00Z")); 687 + } 688 + 689 + #[test] 690 + fn new_run_id_is_16_hex_chars() { 691 + let id = new_run_id(); 692 + assert_eq!(id.len(), 16); 693 + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); 694 + } 695 + 696 + #[test] 697 + fn new_run_id_is_unique_between_calls() { 698 + // 128 bits of entropy in 16 hex chars / 2 = 64 bits. Collision 699 + // probability is negligible in a two-call test. 700 + let a = new_run_id(); 701 + let b = new_run_id(); 702 + assert_ne!(a, b); 703 + } 704 + } 705 + ``` 706 + 707 + **Verification:** 708 + Run: `cargo test --lib commands::test::labeler::create_report::sentinel::tests` 709 + Expected: all 4 tests pass. 710 + 711 + Run: `cargo build` 712 + Expected: builds cleanly, new module trees are wired in. 713 + 714 + Run: `cargo clippy --all-targets -- -D warnings` 715 + Expected: no warnings. 716 + 717 + **Commit:** `feat(labeler): add create_report stage module seed with sentinel builder` 718 + <!-- END_TASK_5 --> 719 + <!-- END_SUBCOMPONENT_B --> 720 + 721 + <!-- START_TASK_6 --> 722 + ### Task 6: Phase 1 integration check 723 + 724 + **Files:** None changed in this task. 725 + 726 + **Implementation:** Run the full test suite and lint pass as a checkpoint that all Phase 1 pieces compose. 727 + 728 + **Verification:** 729 + Run: `cargo build` 730 + Expected: clean build. 731 + 732 + Run: `cargo test` 733 + Expected: all pre-existing tests still pass; 10+ new tests (signing-key, JWT, hostname classifier, sentinel) pass. 734 + 735 + Run: `cargo clippy --all-targets -- -D warnings` 736 + Expected: no warnings. 737 + 738 + Run: `cargo fmt --check` 739 + Expected: no changes required. 740 + 741 + **Commit:** No new commit unless fixes were needed; this task is a gate. 742 + <!-- END_TASK_6 --> 743 + 744 + --- 745 + 746 + ## Phase 1 complete when 747 + 748 + - `AnySigningKey` is available in `src/common/identity.rs` with sign/verify round-trips proven against `AnyVerifyingKey`. 749 + - `src/common/jwt.rs` compiles and its encode→decode→verify round-trip is proven for both ES256 and ES256K. 750 + - `is_local_labeler_hostname` classifies hostnames correctly across a table of cases that covers localhost, RFC 1918, `.local`, and public hosts. 751 + - `src/commands/test/labeler/create_report.rs` + `src/commands/test/labeler/create_report/sentinel.rs` exist and build; `sentinel::build` produces a string matching the design's format. 752 + - `cargo test`, `cargo clippy -- -D warnings`, and `cargo fmt --check` all pass. 753 + - **No new crate dependencies** have been added to `Cargo.toml`. If the implementor finds that `OsRng` is truly unreachable without a new dep, they MUST stop and flag to the user before adding one. 754 + 755 + No acceptance criteria from the design are directly tested by this phase — AC coverage begins in Phase 4.
+657
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_02.md
··· 1 + # Labeler report stage — Phase 2: Self-mint JWT infrastructure 2 + 3 + **Goal:** Produce a `SelfMintSigner` that can mint a JWT the labeler can resolve and verify end-to-end: owns an ephemeral `did:web:127.0.0.1%3A{port}` identity, serves its DID document at `http://127.0.0.1:{port}/.well-known/did.json`, and signs JWTs whose `iss` matches the served identity. 4 + 5 + **Architecture:** Two new types in the new `create_report/` subdirectory: `DidDocServer` (RAII holder around a background `tokio::spawn` serving one JSON response on `tokio::net::TcpListener`) and `SelfMintSigner` (owns the `AnySigningKey`, the issuer `Did`, and the `DidDocServer` handle). A clap `ValueEnum` for `--self-mint-curve` selects the key type. 6 + 7 + **Tech Stack:** `tokio::net::TcpListener` (already depended on via `tokio = { features = ["net", ...] }` in `Cargo.toml:41`), `multibase` (already a dep at line 33), `serde_json`, the Phase 1 `AnySigningKey` + `jwt` module. 8 + 9 + **Scope:** Phase 2 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (verified against `Cargo.toml`, `src/common/identity.rs` lines 1-320 + the existing `parse_multikey` helper, `src/commands/test/labeler.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `tokio` at `Cargo.toml:41` has `net` feature enabled; `tokio::net::TcpListener` is usable without adding features. 15 + - ✓ `multibase` at `Cargo.toml:33` version `0.9`. The existing `parse_multikey` helper in `src/common/identity.rs` (search for `fn parse_multikey`) decodes multibase-prefixed `z...` keys. We need the inverse direction (encode). 16 + - ✓ `percent-encoding` at `Cargo.toml:35`. Used for safely percent-encoding the `:` in `127.0.0.1:{port}` for the DID string. 17 + - ✓ No existing `tokio::spawn` HTTP server in the codebase. This is a genuinely new pattern; the module scopes it narrowly. 18 + - ✓ `atrium-api` at `Cargo.toml:19` version `0.25`. The self-mint DID document must be a standard W3C DID Document that atproto's `did:web` resolver can parse. The tool's own `DidDocument` struct at `src/common/identity.rs:83-95` is a useful shape reference but is *only for deserialization* — we should serialize the atproto-specific DID Document JSON directly as a `serde_json::Value` or typed `serde::Serialize` struct so we control the exact wire bytes. 19 + - ✓ `clap` derive at `Cargo.toml:27` supports `ValueEnum` (feature `derive` is enabled). No existing `ValueEnum` usage in the codebase — this will be the first. 20 + - ✓ `src/commands/test/labeler.rs:26-52` is the `LabelerCmd` struct. `--self-mint-curve` flag lands here in Phase 2 (the other CLI flags in Phase 4). 21 + 22 + **External dependency research findings:** 23 + - ✓ DID Document JSON shape for atproto did:web (see <https://atproto.com/specs/did>): required top-level `id` (the DID itself), an `alsoKnownAs` array (can be empty), a `verificationMethod` array where each entry is `{id, type, controller, publicKeyMultibase}`, and a `service` array (can be empty). For a self-mint JWT signer, one verification method is sufficient. `id` per-method is `"{did}#{fragment}"`; atproto service-auth uses fragments like `#atproto` for the primary account key. We'll use `#atproto` for the self-mint's one and only key. 24 + - ✓ `type` field for verification methods: `"Multikey"` is now the atproto-preferred generic type (previously `EcdsaSecp256k1VerificationKey2019` / `EcdsaSecp256r1VerificationKey2019`). `"Multikey"` paired with `publicKeyMultibase` is what Ozone and modern atproto tooling expect. 25 + - ✓ Multibase/multicodec encoding of public keys per atproto cryptography spec (<https://atproto.com/specs/cryptography>): prefix the raw *compressed SEC1* public key bytes with the multicodec prefix for the curve, then multibase-encode with `base58btc` ('z' prefix): 26 + - secp256k1: multicodec `0xe7 0x01` (varint for `0xe7` = secp256k1-pub) + 33-byte compressed key 27 + - p256: multicodec `0x80 0x24` (varint for `0x1200` = p256-pub) + 33-byte compressed key 28 + 29 + Wait — the correct multicodec values are: `secp256k1-pub = 0xe7` (varint encoding = `0xe7 0x01`), `p256-pub = 0x1200` (varint encoding = `0x80 0x24`). Reference: <https://github.com/multiformats/multicodec/blob/master/table.csv>. Both prefixes are 2 bytes. 30 + - ✓ Compressed SEC1 public key: 33 bytes (1 byte sign tag + 32 byte x-coordinate). `k256::ecdsa::VerifyingKey::to_encoded_point(true)` and `p256::ecdsa::VerifyingKey::to_encoded_point(true)` both produce the compressed form. 31 + - ✓ `did:web` path encoding for localhost + port per <https://atproto.com/specs/did>: `did:web:127.0.0.1%3A{port}`. Resolution URL is `http://127.0.0.1:{port}/.well-known/did.json` (HTTP, not HTTPS, for localhost). The labeler must be configured to allow plaintext-HTTP resolution for did:web (Ozone dev mode allows this via `DID_PLC_URL` / `PLC_URL` config; for self-mint-against-Ozone the user must be running a dev-mode labeler). This is covered by the design's "Viable only when the labeler can reach this machine (local labeler)" gate. 32 + - ✓ atproto service-auth `iss` encoding: the full DID string, optionally with `#fragment` to indicate signing key identifier. Our self-mint uses `iss = "did:web:127.0.0.1%3A{port}"` (no fragment); the labeler resolves the DID document, finds the first suitable `verificationMethod`, and uses it. (With fragment, it looks for a method whose `id` ends in that fragment.) For simplicity and widest compatibility, we'll publish one verificationMethod with `id = "{did}#atproto"` and use a bare `iss` (no fragment); the labeler will match it by curve. 33 + 34 + --- 35 + 36 + **Critical architectural decision:** `DidDocServer::spawn` takes a **body-builder closure** `FnOnce(SocketAddr) -> Vec<u8>` rather than a pre-built body. This lets the server bind first, hand the known `SocketAddr` to the closure (which then builds the DID-doc JSON using the port), and serve — atomically. The earlier "probe + re-spawn" pattern is race-prone (another process can grab the port between probe-drop and re-bind) and is rejected. 37 + 38 + ## Acceptance criteria coverage 39 + 40 + This phase implements and tests: 41 + 42 + ### labeler-report-stage.AC8: CLI flag handling 43 + - **labeler-report-stage.AC8.2 Curve selection:** `--self-mint-curve es256` advertises a P-256 key in the did:web DID doc; `es256k` advertises secp256k1; the minted JWT's `alg` header matches. 44 + 45 + No other ACs are directly verified here — `AC8.2` is partially covered via the in-Phase integration test that asserts key type matches curve selection; end-to-end CLI verification rolls up in Phase 8's `tests/labeler_cli.rs` extension. 46 + 47 + --- 48 + 49 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 50 + <!-- START_TASK_1 --> 51 + ### Task 1: Public-key multibase encoding helper 52 + 53 + **Verifies:** No direct AC; supports AC8.2. 54 + 55 + **Files:** 56 + - Modify: `src/common/identity.rs` — add a companion to the existing `parse_multikey` helper. Place it immediately after `parse_multikey` (search for `fn parse_multikey`). 57 + 58 + **Implementation:** 59 + 60 + ```rust 61 + /// Encode an `AnyVerifyingKey` as the atproto multibase-multikey format: 62 + /// base58btc multibase prefix `z`, multicodec curve prefix, compressed SEC1 63 + /// public key bytes. 64 + /// 65 + /// See <https://atproto.com/specs/cryptography>. The inverse of `parse_multikey`. 66 + pub fn encode_multikey(key: &AnyVerifyingKey) -> String { 67 + // Multicodec varint prefixes (see https://github.com/multiformats/multicodec). 68 + const SECP256K1_PUB: &[u8] = &[0xe7, 0x01]; 69 + const P256_PUB: &[u8] = &[0x80, 0x24]; 70 + 71 + let (prefix, compressed): (&[u8], Vec<u8>) = match key { 72 + AnyVerifyingKey::K256(k) => { 73 + let point = k.to_encoded_point(true); 74 + (SECP256K1_PUB, point.as_bytes().to_vec()) 75 + } 76 + AnyVerifyingKey::P256(k) => { 77 + let point = k.to_encoded_point(true); 78 + (P256_PUB, point.as_bytes().to_vec()) 79 + } 80 + }; 81 + 82 + let mut buf = Vec::with_capacity(prefix.len() + compressed.len()); 83 + buf.extend_from_slice(prefix); 84 + buf.extend_from_slice(&compressed); 85 + multibase::encode(multibase::Base::Base58Btc, &buf) 86 + } 87 + ``` 88 + 89 + **Notes for the implementor:** 90 + - Both `k256::ecdsa::VerifyingKey` and `p256::ecdsa::VerifyingKey` have `to_encoded_point(compress: bool) -> EncodedPoint`. `EncodedPoint::as_bytes()` returns the 33-byte compressed form. These are already in scope if the `ecdsa` feature of the respective crate is enabled (both are, per Cargo.toml). 91 + - `multibase::encode(Base::Base58Btc, &bytes) -> String` produces the `z`-prefixed output. Version 0.9 of the `multibase` crate is the one currently vendored. 92 + 93 + **Testing:** 94 + 95 + Round-trip test: generate a random `AnySigningKey::K256` and `AnySigningKey::P256`, call `encode_multikey(&signing.verifying_key())`, parse the output back with `parse_multikey`, assert the resulting `AnyVerifyingKey` matches the original. Two tests, one per curve. 96 + 97 + **Verification:** 98 + Run: `cargo test --lib common::identity::tests::encode_multikey_round_trip` 99 + Expected: both tests pass. 100 + 101 + **Commit:** `feat(identity): add encode_multikey companion to parse_multikey` 102 + <!-- END_TASK_1 --> 103 + 104 + <!-- START_TASK_2 --> 105 + ### Task 2: DID-construction helper for self-mint identities 106 + 107 + **Verifies:** No direct AC; supports AC8.2. 108 + 109 + **Files:** 110 + - Modify: `src/commands/test/labeler/create_report.rs` — add a small free helper (not a method on `Did`, to keep `src/common/identity.rs` focused on general primitives; self-mint is stage-local). 111 + 112 + **Implementation:** 113 + 114 + Add to the module: 115 + 116 + ```rust 117 + use std::net::SocketAddr; 118 + use url::Url; 119 + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 120 + 121 + use crate::common::identity::Did; 122 + 123 + /// Construct `did:web:127.0.0.1%3A{port}` for a self-mint identity bound 124 + /// to the given local `SocketAddr`. The `:` between the IP and the port is 125 + /// percent-encoded per atproto did:web rules. 126 + /// 127 + /// Uses the `SocketAddr` IP literally (typically `127.0.0.1`). IPv6 128 + /// loopback would produce `did:web:::1%3A{port}` which the atproto did 129 + /// syntax regex rejects; for v1 the self-mint server is IPv4-only. 130 + pub(crate) fn self_mint_did_for(addr: SocketAddr) -> Did { 131 + assert!(addr.is_ipv4(), "self-mint DidDocServer is IPv4-only"); 132 + let host = addr.ip().to_string(); 133 + let port = addr.port(); 134 + // Percent-encode the `:` (and, defensively, any other non-alphanumeric) 135 + // with the standard set. For the `127.0.0.1:{port}` case this yields 136 + // exactly `127.0.0.1%3A{port}`. 137 + let encoded_hostport = format!( 138 + "{host}{}{port}", 139 + utf8_percent_encode(":", NON_ALPHANUMERIC) 140 + ); 141 + Did(format!("did:web:{encoded_hostport}")) 142 + } 143 + 144 + /// Base URL the labeler uses to fetch the self-mint DID document: 145 + /// `http://127.0.0.1:{port}`. 146 + pub(crate) fn self_mint_base_url(addr: SocketAddr) -> Url { 147 + Url::parse(&format!("http://{addr}")) 148 + .expect("SocketAddr Display is always a valid authority") 149 + } 150 + ``` 151 + 152 + **Testing:** 153 + 154 + Unit tests: 155 + 156 + ```rust 157 + #[cfg(test)] 158 + mod did_tests { 159 + use super::*; 160 + 161 + #[test] 162 + fn self_mint_did_encodes_colon() { 163 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 164 + let did = self_mint_did_for(addr); 165 + assert_eq!(did.0, "did:web:127.0.0.1%3A5000"); 166 + } 167 + 168 + #[test] 169 + fn self_mint_base_url_uses_http() { 170 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 171 + let url = self_mint_base_url(addr); 172 + assert_eq!(url.as_str(), "http://127.0.0.1:5000/"); 173 + } 174 + } 175 + ``` 176 + 177 + **Verification:** 178 + Run: `cargo test --lib commands::test::labeler::create_report::did_tests` 179 + Expected: both tests pass. 180 + 181 + **Commit:** `feat(create_report): did:web constructor for self-mint identities` 182 + <!-- END_TASK_2 --> 183 + <!-- END_SUBCOMPONENT_A --> 184 + 185 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 186 + <!-- START_TASK_3 --> 187 + ### Task 3: `DidDocServer` RAII type 188 + 189 + **Verifies:** No direct AC; supports AC4 path via integration test. 190 + 191 + **Files:** 192 + - Create: `src/commands/test/labeler/create_report/did_doc_server.rs`. 193 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod did_doc_server;`. 194 + 195 + **Implementation:** 196 + 197 + ```rust 198 + //! Ephemeral `did:web` document server for self-mint conformance checks. 199 + //! 200 + //! Binds `127.0.0.1:0`, accepts the first inbound TCP connection, and 201 + //! serves a single hand-crafted HTTP/1.1 response carrying the DID 202 + //! document JSON at `/.well-known/did.json`. Any other path returns 203 + //! 404. Other requests to `/.well-known/did.json` are honored too — 204 + //! the labeler may retry. 205 + //! 206 + //! Shuts down on drop: the RAII handle aborts the background task and 207 + //! closes the listener. 208 + 209 + use std::net::SocketAddr; 210 + use std::sync::Arc; 211 + 212 + use tokio::io::{AsyncReadExt, AsyncWriteExt}; 213 + use tokio::net::TcpListener; 214 + use tokio::task::JoinHandle; 215 + 216 + /// A running DID document server. 217 + /// 218 + /// The server runs on `127.0.0.1:{os-assigned-port}` in a background 219 + /// task; the listening address is exposed via `local_addr()`. When the 220 + /// `DidDocServer` is dropped, the background task is aborted. 221 + pub struct DidDocServer { 222 + local_addr: SocketAddr, 223 + task: JoinHandle<()>, 224 + } 225 + 226 + impl DidDocServer { 227 + /// Bind `127.0.0.1:0` and start serving DID-document JSON bytes at 228 + /// `/.well-known/did.json`. The body is built **after** the listener 229 + /// has bound, by invoking `build_body` with the known `SocketAddr`. 230 + /// This lets callers embed the bound port into the DID document 231 + /// atomically — there is no probe phase, no possibility of port drift 232 + /// between binding and serving. 233 + pub async fn spawn<F>(build_body: F) -> std::io::Result<Self> 234 + where 235 + F: FnOnce(SocketAddr) -> Vec<u8>, 236 + { 237 + let listener = TcpListener::bind("127.0.0.1:0").await?; 238 + let local_addr = listener.local_addr()?; 239 + let did_doc_json = build_body(local_addr); 240 + let body: Arc<[u8]> = did_doc_json.into(); 241 + 242 + let task = tokio::spawn(async move { 243 + loop { 244 + let accept = listener.accept().await; 245 + let (mut stream, _peer) = match accept { 246 + Ok(sp) => sp, 247 + Err(_) => return, 248 + }; 249 + let body = body.clone(); 250 + tokio::spawn(async move { 251 + let _ = Self::handle_connection(&mut stream, &body).await; 252 + }); 253 + } 254 + }); 255 + 256 + Ok(Self { local_addr, task }) 257 + } 258 + 259 + /// The listening address (always `127.0.0.1:{port}`). 260 + pub fn local_addr(&self) -> SocketAddr { 261 + self.local_addr 262 + } 263 + 264 + /// Minimal HTTP/1.1 handler: reads the request line, routes on path. 265 + async fn handle_connection( 266 + stream: &mut tokio::net::TcpStream, 267 + did_doc: &[u8], 268 + ) -> std::io::Result<()> { 269 + // Read up to 8 KiB of request headers; servers don't send large 270 + // GET requests but we cap to avoid unbounded reads. 271 + let mut buf = [0u8; 8192]; 272 + let mut total = 0usize; 273 + while total < buf.len() { 274 + let n = stream.read(&mut buf[total..]).await?; 275 + if n == 0 { 276 + break; 277 + } 278 + total += n; 279 + // Headers end at CRLFCRLF. 280 + if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") { 281 + break; 282 + } 283 + } 284 + let request = &buf[..total]; 285 + 286 + // Parse just the request line: `GET /path HTTP/1.1\r\n`. 287 + let first_line_end = request 288 + .iter() 289 + .position(|&b| b == b'\r' || b == b'\n') 290 + .unwrap_or(request.len()); 291 + let first_line = std::str::from_utf8(&request[..first_line_end]).unwrap_or(""); 292 + let mut parts = first_line.split_whitespace(); 293 + let method = parts.next().unwrap_or(""); 294 + let path = parts.next().unwrap_or(""); 295 + 296 + let (status_line, body, content_type) = if method == "GET" && path == "/.well-known/did.json" 297 + { 298 + ("HTTP/1.1 200 OK", did_doc, "application/json") 299 + } else { 300 + ("HTTP/1.1 404 Not Found", b"not found" as &[u8], "text/plain") 301 + }; 302 + 303 + let response_head = format!( 304 + "{status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 305 + body.len() 306 + ); 307 + stream.write_all(response_head.as_bytes()).await?; 308 + stream.write_all(body).await?; 309 + stream.flush().await?; 310 + let _ = stream.shutdown().await; 311 + Ok(()) 312 + } 313 + } 314 + 315 + impl Drop for DidDocServer { 316 + fn drop(&mut self) { 317 + self.task.abort(); 318 + } 319 + } 320 + ``` 321 + 322 + **Notes for the implementor:** 323 + - The HTTP parsing here is deliberately *minimal* — it is not a full HTTP/1.1 server. It handles only the request line and is not robust against pipelining, chunked encoding, or TLS. For the self-mint use case (localhost, single client, single request for DID doc) this is sufficient. 324 + - `Connection: close` after the response keeps each connection single-request, matching the minimal-parser simplification. 325 + - The outer loop accepts connections forever until the task is aborted on drop. Each connection runs in its own `tokio::spawn` so a slow request doesn't block others (labelers sometimes retry). 326 + 327 + **Testing:** 328 + 329 + Integration test: spawn the server with a canned JSON body, use `reqwest::Client::new().get(...)` to hit `http://127.0.0.1:{port}/.well-known/did.json`, assert status 200, content-type `application/json`, body byte-equal to the seed. Also test: GET `/other/path` returns 404. 330 + 331 + ```rust 332 + #[cfg(test)] 333 + mod tests { 334 + use super::*; 335 + 336 + #[tokio::test] 337 + async fn serves_did_doc_on_well_known_path() { 338 + let body = br#"{"id":"did:web:127.0.0.1%3A0"}"#.to_vec(); 339 + let body_for_assert = body.clone(); 340 + let server = DidDocServer::spawn(move |_addr| body).await.expect("spawn"); 341 + let url = format!("http://{}/.well-known/did.json", server.local_addr()); 342 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 343 + assert_eq!(resp.status(), 200); 344 + assert_eq!(resp.headers()["content-type"], "application/json"); 345 + let got = resp.bytes().await.expect("bytes"); 346 + assert_eq!(got.as_ref(), body_for_assert.as_slice()); 347 + } 348 + 349 + #[tokio::test] 350 + async fn returns_404_for_other_paths() { 351 + let server = DidDocServer::spawn(|_addr| b"{}".to_vec()).await.expect("spawn"); 352 + let url = format!("http://{}/nope", server.local_addr()); 353 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 354 + assert_eq!(resp.status(), 404); 355 + } 356 + 357 + #[tokio::test] 358 + async fn body_builder_receives_bound_addr() { 359 + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); 360 + let captured_clone = captured.clone(); 361 + let server = DidDocServer::spawn(move |addr| { 362 + *captured_clone.lock().unwrap() = Some(addr); 363 + format!(r#"{{"port":{}}}"#, addr.port()).into_bytes() 364 + }) 365 + .await 366 + .expect("spawn"); 367 + let addr = *captured.lock().unwrap(); 368 + assert_eq!(addr, Some(server.local_addr())); 369 + } 370 + } 371 + ``` 372 + 373 + **Verification:** 374 + Run: `cargo test --lib commands::test::labeler::create_report::did_doc_server::tests` 375 + Expected: both tests pass. 376 + 377 + **Commit:** `feat(create_report): ephemeral did:web document server` 378 + <!-- END_TASK_3 --> 379 + 380 + <!-- START_TASK_4 --> 381 + ### Task 4: `SelfMintSigner` struct bringing it all together 382 + 383 + **Verifies:** AC8.2 (via integration test asserting curve → alg header). 384 + 385 + **Files:** 386 + - Create: `src/commands/test/labeler/create_report/self_mint.rs`. 387 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod self_mint;`. 388 + 389 + **Implementation:** 390 + 391 + ```rust 392 + //! `SelfMintSigner`: owns a random key, an ephemeral `did:web` identity 393 + //! server, and a reference curve. Exposes a single method for signing 394 + //! atproto service-auth JWTs with that identity. 395 + 396 + use std::time::Duration; 397 + 398 + use serde_json::json; 399 + 400 + // Local RNG shim: `k256::ecdsa::SigningKey::random` and 401 + // `p256::ecdsa::SigningKey::random` take `CryptoRngCore`. We build a 402 + // thin adapter around `getrandom` (Phase 1 Task 0 direct dep) since 403 + // `elliptic_curve::rand_core::OsRng` is not re-exported through the 404 + // current dep graph. 405 + struct GetrandomRng; 406 + impl rand_core::RngCore for GetrandomRng { 407 + fn next_u32(&mut self) -> u32 { 408 + let mut b = [0u8; 4]; 409 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 410 + u32::from_le_bytes(b) 411 + } 412 + fn next_u64(&mut self) -> u64 { 413 + let mut b = [0u8; 8]; 414 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 415 + u64::from_le_bytes(b) 416 + } 417 + fn fill_bytes(&mut self, dest: &mut [u8]) { 418 + getrandom::getrandom(dest).expect("OS CSPRNG"); 419 + } 420 + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { 421 + getrandom::getrandom(dest).map_err(|_| rand_core::Error::new("getrandom failed")) 422 + } 423 + } 424 + impl rand_core::CryptoRng for GetrandomRng {} 425 + 426 + use crate::commands::test::labeler::create_report::did_doc_server::DidDocServer; 427 + use crate::commands::test::labeler::create_report::{self_mint_base_url, self_mint_did_for}; 428 + use crate::common::identity::{AnySigningKey, Did, encode_multikey}; 429 + use crate::common::jwt::{self, JwtClaims, JwtHeader}; 430 + 431 + /// Curve selector for self-mint keys, mirrors clap's `--self-mint-curve` flag. 432 + #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] 433 + pub enum SelfMintCurve { 434 + /// secp256k1 (JWT `alg = "ES256K"`). Default. 435 + Es256k, 436 + /// NIST P-256 (JWT `alg = "ES256"`). 437 + Es256, 438 + } 439 + 440 + impl Default for SelfMintCurve { 441 + fn default() -> Self { 442 + SelfMintCurve::Es256k 443 + } 444 + } 445 + 446 + /// A self-mint JWT signer. Owns the keypair, the DID, and the backing DID 447 + /// document server (which is shut down on drop). 448 + pub struct SelfMintSigner { 449 + signing_key: AnySigningKey, 450 + issuer_did: Did, 451 + /// Held for its Drop side effect (the server stays up while this 452 + /// field is alive). Also read by `did_doc_url()` to expose the 453 + /// listening port. 454 + did_doc_server: DidDocServer, 455 + } 456 + 457 + impl SelfMintSigner { 458 + /// Create a new self-mint signer with a freshly-generated key of the 459 + /// requested curve. Binds `127.0.0.1:0` for the DID document server. 460 + /// 461 + /// Port-stable by construction: the server binds first, then the 462 + /// body-builder closure embeds the bound port into the DID document 463 + /// before the first request is served. There is no probe phase and 464 + /// no window in which the port can drift. 465 + pub async fn spawn(curve: SelfMintCurve) -> std::io::Result<Self> { 466 + let signing_key = match curve { 467 + SelfMintCurve::Es256k => { 468 + AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut GetrandomRng)) 469 + } 470 + SelfMintCurve::Es256 => { 471 + AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut GetrandomRng)) 472 + } 473 + }; 474 + let verifying_key = signing_key.verifying_key(); 475 + let multikey = encode_multikey(&verifying_key); 476 + 477 + // Capture the DID that the builder computes so we can store it. 478 + // `Arc<Mutex<Option<Did>>>` lets the one-shot closure publish the 479 + // DID out through the body-builder boundary. 480 + let issuer_capture: std::sync::Arc<std::sync::Mutex<Option<Did>>> = 481 + std::sync::Arc::new(std::sync::Mutex::new(None)); 482 + let issuer_capture_clone = issuer_capture.clone(); 483 + let multikey_for_builder = multikey.clone(); 484 + 485 + let server = DidDocServer::spawn(move |addr| { 486 + let did = self_mint_did_for(addr); 487 + let did_doc = json!({ 488 + "@context": ["https://www.w3.org/ns/did/v1"], 489 + "id": did.0, 490 + "alsoKnownAs": [], 491 + "verificationMethod": [{ 492 + "id": format!("{}#atproto", did.0), 493 + "type": "Multikey", 494 + "controller": did.0, 495 + "publicKeyMultibase": multikey_for_builder, 496 + }], 497 + "service": [], 498 + }); 499 + let bytes = serde_json::to_vec(&did_doc).expect("static JSON serializes"); 500 + *issuer_capture_clone.lock().unwrap() = Some(did); 501 + bytes 502 + }) 503 + .await?; 504 + 505 + let issuer_did = issuer_capture 506 + .lock() 507 + .unwrap() 508 + .take() 509 + .expect("body-builder runs synchronously before spawn returns"); 510 + 511 + Ok(Self { 512 + signing_key, 513 + issuer_did, 514 + did_doc_server: server, 515 + }) 516 + } 517 + 518 + /// The issuer DID bound to this signer (`did:web:127.0.0.1%3A{port}`). 519 + pub fn issuer_did(&self) -> &Did { 520 + &self.issuer_did 521 + } 522 + 523 + /// URL the labeler will fetch to resolve the DID document. 524 + pub fn did_doc_url(&self) -> url::Url { 525 + let mut u = self_mint_base_url(self.did_doc_server.local_addr()); 526 + u.set_path("/.well-known/did.json"); 527 + u 528 + } 529 + 530 + /// Sign a JWT with these claims. The `iss` field in `claims` is 531 + /// overridden with this signer's DID so callers never forget. 532 + pub fn sign_jwt(&self, mut claims: JwtClaims) -> String { 533 + claims.iss = self.issuer_did.0.clone(); 534 + let header = JwtHeader::for_signing_key(&self.signing_key); 535 + jwt::encode_compact(&header, &claims, &self.signing_key) 536 + .expect("encode_compact is infallible for well-formed structs") 537 + } 538 + 539 + /// Build a valid-claims template for the given labeler DID and the 540 + /// createReport NSID. Callers mutate specific fields for negative 541 + /// tests. `now_unix_secs` is the current wall-clock time in UNIX 542 + /// seconds; `exp_after` is the lifetime. 543 + pub fn valid_claims_template( 544 + &self, 545 + labeler_did: &Did, 546 + lxm: &str, 547 + now_unix_secs: i64, 548 + exp_after: Duration, 549 + ) -> JwtClaims { 550 + JwtClaims { 551 + iss: self.issuer_did.0.clone(), 552 + aud: labeler_did.0.clone(), 553 + exp: now_unix_secs + exp_after.as_secs() as i64, 554 + iat: now_unix_secs, 555 + lxm: lxm.to_string(), 556 + jti: crate::commands::test::labeler::create_report::sentinel::new_run_id(), 557 + } 558 + } 559 + } 560 + ``` 561 + 562 + **Notes for the implementor:** 563 + - The body-builder closure pattern removes the only race in the earlier "probe + re-spawn" design: the listener binds once, and the builder sees the exact `SocketAddr` the server will serve from. The `Arc<Mutex<Option<Did>>>` capture is necessary because the DID (derived from the port) is needed *after* the closure returns; it publishes the value out through shared state. The closure runs synchronously inside `DidDocServer::spawn` before the task returns, so `.take()` after `.await` is safe. 564 + - `k256::ecdsa::SigningKey::random(&mut OsRng)` and `p256::ecdsa::SigningKey::random(&mut OsRng)` are the idiomatic RNG-seeded constructors. `OsRng` path is `k256::elliptic_curve::rand_core::OsRng` (re-exported from `elliptic-curve`'s `rand_core` feature) — verify during Phase 1 with `cargo read k256 --api | grep -i OsRng` and pin the exact path in the shared `src/common/jwt.rs` imports. 565 + - Expose `new_run_id` from Phase 1's sentinel module as the `jti` source. This keeps random-bytes ownership in one place. 566 + 567 + **Testing:** 568 + 569 + Integration-style test (in the same file, `#[cfg(test)]` section, under `#[tokio::test]`): 570 + 571 + ```rust 572 + #[cfg(test)] 573 + mod tests { 574 + use super::*; 575 + use crate::common::identity::{AnyVerifyingKey, parse_multikey}; 576 + use crate::common::jwt::verify_compact; 577 + 578 + async fn round_trip(curve: SelfMintCurve, expected_alg: &str) { 579 + let signer = SelfMintSigner::spawn(curve).await.expect("spawn"); 580 + 581 + // Fetch the DID document as the labeler would. 582 + let url = signer.did_doc_url(); 583 + let client = reqwest::Client::new(); 584 + let resp = client.get(url).send().await.expect("http"); 585 + assert_eq!(resp.status(), 200); 586 + let doc: serde_json::Value = resp.json().await.expect("json"); 587 + assert_eq!(doc["id"], serde_json::Value::String(signer.issuer_did().0.clone())); 588 + let vm = doc["verificationMethod"][0].clone(); 589 + let multikey = vm["publicKeyMultibase"].as_str().expect("multikey").to_string(); 590 + 591 + // Decode the key and verify a signature from the signer. 592 + let parsed = parse_multikey(&multikey).expect("parse multikey"); 593 + let vkey: AnyVerifyingKey = parsed.verifying_key; 594 + 595 + let claims = signer.valid_claims_template( 596 + &Did("did:plc:aaa22222222222222222bbbbbb".to_string()), 597 + "com.atproto.moderation.createReport", 598 + 1_776_000_000, 599 + Duration::from_secs(60), 600 + ); 601 + let token = signer.sign_jwt(claims.clone()); 602 + let (header, decoded_claims) = verify_compact(&token, &vkey).expect("verify"); 603 + assert_eq!(header.alg, expected_alg); 604 + assert_eq!(decoded_claims.iss, signer.issuer_did().0); 605 + assert_eq!(decoded_claims.aud, claims.aud); 606 + assert_eq!(decoded_claims.lxm, "com.atproto.moderation.createReport"); 607 + } 608 + 609 + #[tokio::test] 610 + async fn self_mint_signer_es256k_round_trips() { 611 + round_trip(SelfMintCurve::Es256k, "ES256K").await; 612 + } 613 + 614 + #[tokio::test] 615 + async fn self_mint_signer_es256_round_trips() { 616 + round_trip(SelfMintCurve::Es256, "ES256").await; 617 + } 618 + } 619 + ``` 620 + 621 + **Verification:** 622 + Run: `cargo test --lib commands::test::labeler::create_report::self_mint::tests` 623 + Expected: both tests pass. 624 + 625 + **Commit:** `feat(create_report): SelfMintSigner with curve-selected identity` 626 + <!-- END_TASK_4 --> 627 + <!-- END_SUBCOMPONENT_B --> 628 + 629 + <!-- START_TASK_5 --> 630 + ### Task 5: Phase 2 integration check 631 + 632 + **Files:** None changed. 633 + 634 + **Implementation:** Checkpoint — run full tests to confirm Phase 2 composes cleanly with Phase 1. 635 + 636 + **Verification:** 637 + Run: `cargo build` 638 + Expected: clean build. 639 + 640 + Run: `cargo test` 641 + Expected: all Phase 1 tests still pass; Phase 2 adds 4+ new passing tests (encode_multikey, did helpers, DidDocServer, SelfMintSigner). 642 + 643 + Run: `cargo clippy --all-targets -- -D warnings` 644 + Expected: no warnings. 645 + 646 + **Commit:** No new commit unless fixes were needed. 647 + <!-- END_TASK_5 --> 648 + 649 + --- 650 + 651 + ## Phase 2 complete when 652 + 653 + - `encode_multikey` in `src/common/identity.rs` round-trips against `parse_multikey` for both curves. 654 + - `DidDocServer::spawn` serves a canned JSON body at `/.well-known/did.json` over HTTP on an OS-assigned localhost port. 655 + - `SelfMintSigner::spawn(SelfMintCurve::Es256k)` and `::spawn(SelfMintCurve::Es256)` each produce a running server + a signer whose JWTs decode and verify against the published multikey in the DID doc. 656 + - The JWT `alg` header matches the selected curve (`ES256K` for `Es256k`, `ES256` for `Es256`). 657 + - All Phase 1 tests still pass.
+524
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_03.md
··· 1 + # Labeler report stage — Phase 3: `CreateReportTee` seam 2 + 3 + **Goal:** Stand up the test seam for POSTing `com.atproto.moderation.createReport` so all later functionality phases have a mockable entry point. Mirrors the existing `RawHttpTee` pattern for query_labels. 4 + 5 + **Architecture:** A new trait `CreateReportTee` in the `create_report` module with a single async method `post_create_report(auth, body) -> RawCreateReportResponse`, a production impl `RealCreateReportTee` wrapping `reqwest::Client`, and a `FakeCreateReportTee` test helper in `tests/common/mod.rs` that returns scripted responses keyed by call index and records every request for assertion. 6 + 7 + **Tech Stack:** `reqwest::Client` (existing dep), `async_trait`, `serde_json` for body serialization, `thiserror` + `miette::Diagnostic` for errors. 8 + 9 + **Scope:** Phase 3 of 8. Can proceed in parallel with Phase 2 in principle, but the plan assumes linear execution. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/commands/test/labeler/http.rs`, `tests/common/mod.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Trait pattern at `src/commands/test/labeler/http.rs:194-203`: `#[async_trait] pub trait RawHttpTee: Send + Sync { async fn query_labels(...) -> Result<RawXrpcResponse, HttpStageError>; }`. Mirror exactly. 15 + - ✓ Real impl pattern at `src/commands/test/labeler/http.rs:206-295`: `pub struct RealHttpTee { client: reqwest::Client, endpoint: Url }` + `pub fn new(client, endpoint) -> Self`. Mirror exactly. 16 + - ✓ Fake impl pattern at `tests/common/mod.rs:23-103`: `FakeRawHttpTee` with `Arc<Mutex<HashMap<...>>>` for scripted responses and `Arc<Mutex<bool>>` for transport error toggle. Construction style matches existing conventions. 17 + - ✓ `Arc<[u8]>` for raw bodies at `src/commands/test/labeler/http.rs:143`. Reuse for `RawCreateReportResponse`. 18 + - ✓ `reqwest::StatusCode` is the response status type, not a `u16`. Follow `src/commands/test/labeler/http.rs:141`. 19 + - ✓ Error type pattern at `src/commands/test/labeler/http.rs:166-188`: enum with `Transport { message, source }` and a domain-specific variant. No `miette::Diagnostic` on the error itself; diagnostics are constructed when checks fire. 20 + - ✓ No existing `FakeCreateReportTee` in `tests/common/mod.rs` — confirmed by reading the full file. New type goes after `FakeRawHttpTee`. 21 + - ✓ `atrium_api` is already imported in `tests/common/mod.rs:11`. The createReport input body will be a `serde_json::Value` for test flexibility (negative tests need to submit intentionally invalid bodies), not a strongly-typed atrium input. The Real impl accepts `serde_json::Value` and posts it with `Content-Type: application/json`. 22 + - ✓ Production tracing pattern at `src/commands/test/labeler/http.rs:239-243,265-270` (`tracing::debug!(url = %..., status = %..., body_len = ...)`). Mirror for new POST. 23 + 24 + **External dependency research findings:** N/A — uses reqwest already in the project; no new external research. 25 + 26 + --- 27 + 28 + ## Acceptance criteria coverage 29 + 30 + This phase implements and tests infrastructure only. No acceptance criteria are directly verified here — but `AC7.1` (row-count invariant) and every check in `AC2`/`AC3`/`AC4`/`AC5`/`AC6` depends on the `CreateReportTee` seam the stage uses. 31 + 32 + **Verifies: None** — Phase 3 is pure scaffolding. 33 + 34 + --- 35 + 36 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 37 + <!-- START_TASK_1 --> 38 + ### Task 1: `CreateReportTee` trait, response, and error types 39 + 40 + **Files:** 41 + - Modify: `src/commands/test/labeler/create_report.rs` — append the trait, response struct, and error enum at the module root (after the existing `pub mod sentinel;`, `pub mod did_doc_server;`, `pub mod self_mint;` re-exports and the `self_mint_did_for` helper). 42 + 43 + **Implementation:** 44 + 45 + Add to `src/commands/test/labeler/create_report.rs`: 46 + 47 + ```rust 48 + use std::sync::Arc; 49 + 50 + use async_trait::async_trait; 51 + use miette::Diagnostic; 52 + use reqwest::StatusCode; 53 + use thiserror::Error; 54 + use url::Url; 55 + 56 + /// Raw HTTP response from POSTing `com.atproto.moderation.createReport`. 57 + /// 58 + /// Mirrors `RawXrpcResponse` from the HTTP stage but specialized for the 59 + /// createReport shape: no typed decode (positive and negative checks need 60 + /// different decode strategies) and the raw body is kept for diagnostic 61 + /// rendering via miette. 62 + #[derive(Debug)] 63 + pub struct RawCreateReportResponse { 64 + /// HTTP status code. 65 + pub status: StatusCode, 66 + /// Content-Type header value, if present. Lowercased for matching. 67 + pub content_type: Option<String>, 68 + /// Raw response body bytes. 69 + pub raw_body: Arc<[u8]>, 70 + /// The URL that was POSTed to (for diagnostics). 71 + pub source_url: String, 72 + } 73 + 74 + /// Error type for `CreateReportTee` operations. 75 + /// 76 + /// Kept intentionally narrow: either a transport failure (TCP / TLS / DNS / 77 + /// reqwest internal), or a well-formed HTTP response that we return as-is. 78 + /// Callers — i.e., the stage — decide what each non-2xx status means per 79 + /// check. 80 + #[derive(Debug, Error, Diagnostic)] 81 + pub enum CreateReportStageError { 82 + /// Transport-level failure: the request never reached a well-formed 83 + /// HTTP exchange. 84 + #[error("createReport transport error: {message}")] 85 + #[diagnostic(code = "labeler::report::transport_error")] 86 + Transport { 87 + /// Human-readable error message. 88 + message: String, 89 + /// Underlying reqwest error, if available. 90 + #[source] 91 + source: Option<Box<dyn std::error::Error + Send + Sync>>, 92 + }, 93 + } 94 + 95 + /// Trait for POSTing `com.atproto.moderation.createReport`. Production 96 + /// impl (`RealCreateReportTee`) wraps a `reqwest::Client`; tests inject 97 + /// `FakeCreateReportTee` from `tests/common/mod.rs`. 98 + /// 99 + /// The body is serialized from a `serde_json::Value` so negative-shape 100 + /// tests can POST intentionally invalid bodies without fighting the type 101 + /// system. 102 + #[async_trait] 103 + pub trait CreateReportTee: Send + Sync { 104 + /// POST the given body to the labeler's `com.atproto.moderation.createReport` 105 + /// endpoint. 106 + /// 107 + /// # Arguments 108 + /// * `auth` — optional Bearer token. `None` ⇒ no `Authorization` header 109 + /// (for the `unauthenticated_rejected` check). `Some(token)` is 110 + /// included as `Authorization: Bearer {token}`. 111 + /// * `body` — JSON body to POST. The impl sends `Content-Type: application/json`. 112 + async fn post_create_report( 113 + &self, 114 + auth: Option<&str>, 115 + body: &serde_json::Value, 116 + ) -> Result<RawCreateReportResponse, CreateReportStageError>; 117 + } 118 + ``` 119 + 120 + **Notes for the implementor:** 121 + - Don't derive `Clone` on `RawCreateReportResponse` — `Arc<[u8]>` is already cheap to clone structurally, and the response travels through the stage once. If a later phase needs to duplicate it for diagnostic attachment, clone the `Arc` explicitly. 122 + - The `Transport` variant is intentionally the only variant. Any HTTP-level response — including 5xx — is returned successfully; "status = 500" is a labeler bug, not a transport failure from our point of view. This mirrors `HttpClient::get_bytes` which returns the status even for non-2xx. 123 + 124 + **Testing:** Trait definitions aren't directly tested; the Real impl's behavior is tested in Task 2, and the Fake impl's behavior is tested in Task 3. 125 + 126 + **Verification:** 127 + Run: `cargo build` 128 + Expected: builds cleanly. 129 + 130 + Run: `cargo clippy --all-targets -- -D warnings` 131 + Expected: no warnings. 132 + 133 + **Commit:** `feat(create_report): CreateReportTee trait and response/error types` 134 + <!-- END_TASK_1 --> 135 + 136 + <!-- START_TASK_2 --> 137 + ### Task 2: `RealCreateReportTee` production implementation 138 + 139 + **Files:** 140 + - Modify: `src/commands/test/labeler/create_report.rs` — append the Real impl. 141 + 142 + **Implementation:** 143 + 144 + ```rust 145 + /// Real `CreateReportTee` implementation using reqwest. 146 + pub struct RealCreateReportTee { 147 + client: reqwest::Client, 148 + endpoint: Url, 149 + } 150 + 151 + impl RealCreateReportTee { 152 + /// Create a new `RealCreateReportTee` using the given shared reqwest 153 + /// client and labeler endpoint. The endpoint is the labeler's service 154 + /// URL (e.g., `https://labeler.example.com`); the POST path 155 + /// `/xrpc/com.atproto.moderation.createReport` is appended. 156 + pub fn new(client: reqwest::Client, endpoint: Url) -> Self { 157 + Self { client, endpoint } 158 + } 159 + } 160 + 161 + #[async_trait] 162 + impl CreateReportTee for RealCreateReportTee { 163 + async fn post_create_report( 164 + &self, 165 + auth: Option<&str>, 166 + body: &serde_json::Value, 167 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 168 + let mut url = self.endpoint.clone(); 169 + url.set_path("xrpc/com.atproto.moderation.createReport"); 170 + let source_url = url.to_string(); 171 + 172 + tracing::debug!( 173 + url = %source_url, 174 + auth_kind = match auth { 175 + None => "none", 176 + Some(t) if !t.starts_with("ey") => "malformed", 177 + Some(_) => "jwt", 178 + }, 179 + "report stage: issuing createReport POST" 180 + ); 181 + 182 + let mut req = self 183 + .client 184 + .post(url.as_str()) 185 + .header("Content-Type", "application/json") 186 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 187 + if let Some(token) = auth { 188 + req = req.header("Authorization", format!("Bearer {token}")); 189 + } 190 + 191 + let response = req 192 + .send() 193 + .await 194 + .map_err(|e| CreateReportStageError::Transport { 195 + message: e.to_string(), 196 + source: Some(Box::new(e)), 197 + })?; 198 + 199 + let status = response.status(); 200 + let content_type = response 201 + .headers() 202 + .get(reqwest::header::CONTENT_TYPE) 203 + .and_then(|h| h.to_str().ok()) 204 + .map(|s| s.to_ascii_lowercase()); 205 + 206 + let body_bytes = response 207 + .bytes() 208 + .await 209 + .map_err(|e| CreateReportStageError::Transport { 210 + message: e.to_string(), 211 + source: Some(Box::new(e)), 212 + })?; 213 + 214 + tracing::debug!( 215 + url = %source_url, 216 + status = %status, 217 + body_len = body_bytes.len(), 218 + "report stage: createReport response received" 219 + ); 220 + 221 + Ok(RawCreateReportResponse { 222 + status, 223 + content_type, 224 + raw_body: Arc::from(body_bytes.as_ref()), 225 + source_url, 226 + }) 227 + } 228 + } 229 + ``` 230 + 231 + **Testing:** 232 + 233 + No standalone unit test for `RealCreateReportTee` beyond a compile check — the real HTTP path is exercised indirectly by end-to-end tests in Phase 8. The FakeCreateReportTee (Task 3) is the artifact exercised by per-check integration tests. 234 + 235 + **Verification:** 236 + Run: `cargo build` 237 + Expected: clean build; the new type + impl compile. 238 + 239 + **Commit:** `feat(create_report): RealCreateReportTee wrapping reqwest` 240 + <!-- END_TASK_2 --> 241 + <!-- END_SUBCOMPONENT_A --> 242 + 243 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 244 + <!-- START_TASK_3 --> 245 + ### Task 3: `FakeCreateReportTee` test fake with scripted responses + request capture 246 + 247 + **Files:** 248 + - Modify: `tests/common/mod.rs` — add the `FakeCreateReportTee` type after `FakeRawHttpTee`'s impl block (around line 103). 249 + 250 + **Implementation:** 251 + 252 + Add these imports at the top alongside the existing ones: 253 + 254 + ```rust 255 + use atproto_devtool::commands::test::labeler::create_report::{ 256 + CreateReportStageError, CreateReportTee, RawCreateReportResponse, 257 + }; 258 + use reqwest::StatusCode; 259 + ``` 260 + 261 + Then the fake type: 262 + 263 + ```rust 264 + /// Scripted response for a single `FakeCreateReportTee::post_create_report` 265 + /// call. A `Transport` variant short-circuits with an error; a `Response` 266 + /// variant returns a `RawCreateReportResponse` built from the supplied parts. 267 + #[derive(Debug, Clone)] 268 + pub enum FakeCreateReportResponse { 269 + /// Simulate a transport-level failure (no HTTP exchange took place). 270 + Transport { 271 + /// Error message the stage will surface. 272 + message: String, 273 + }, 274 + /// Simulate a well-formed HTTP response. 275 + Response { 276 + /// HTTP status (200, 401, 400, 500, ...). 277 + status: u16, 278 + /// Optional content-type header. Fake normalizes to lowercase. 279 + content_type: Option<String>, 280 + /// Raw response body bytes. 281 + body: Vec<u8>, 282 + }, 283 + } 284 + 285 + impl FakeCreateReportResponse { 286 + /// Convenience: a 200 OK with an empty atproto createReport#output body. 287 + pub fn ok_empty() -> Self { 288 + Self::Response { 289 + status: 200, 290 + content_type: Some("application/json".to_string()), 291 + body: br#"{"id":1,"reasonType":"com.atproto.moderation.defs#reasonOther","subject":{"$type":"com.atproto.admin.defs#repoRef","did":"did:plc:aaa22222222222222222bbbbbb"},"reportedBy":"did:web:127.0.0.1%3A0","createdAt":"2026-04-17T00:00:00.000Z"}"#.to_vec(), 292 + } 293 + } 294 + 295 + /// Convenience: a 401 Unauthorized with the atproto error envelope. 296 + pub fn unauthorized(error_name: &str, message: &str) -> Self { 297 + Self::Response { 298 + status: 401, 299 + content_type: Some("application/json".to_string()), 300 + body: serde_json::to_vec(&serde_json::json!({ 301 + "error": error_name, 302 + "message": message, 303 + })).unwrap(), 304 + } 305 + } 306 + 307 + /// Convenience: a 400 Bad Request with the given error and message. 308 + pub fn bad_request(error_name: &str, message: &str) -> Self { 309 + Self::Response { 310 + status: 400, 311 + content_type: Some("application/json".to_string()), 312 + body: serde_json::to_vec(&serde_json::json!({ 313 + "error": error_name, 314 + "message": message, 315 + })).unwrap(), 316 + } 317 + } 318 + } 319 + 320 + /// A recorded request observed by `FakeCreateReportTee`. 321 + #[derive(Debug, Clone)] 322 + pub struct RecordedCreateReportRequest { 323 + /// Authorization bearer token, if any (stripped of "Bearer " prefix). 324 + pub auth: Option<String>, 325 + /// JSON body as posted by the stage. 326 + pub body: serde_json::Value, 327 + } 328 + 329 + /// Fake `CreateReportTee` for integration tests. 330 + /// 331 + /// Scripted per-call-index responses: first call gets `responses[0]`, 332 + /// second gets `responses[1]`, etc. Panics if a call is made with no 333 + /// script queued — tests must declare every `post_create_report` the 334 + /// stage is expected to make. 335 + pub struct FakeCreateReportTee { 336 + /// Queued responses. 337 + scripts: Arc<Mutex<Vec<FakeCreateReportResponse>>>, 338 + /// Every request observed (in order). 339 + recorded: Arc<Mutex<Vec<RecordedCreateReportRequest>>>, 340 + } 341 + 342 + impl FakeCreateReportTee { 343 + /// Create a fake with no scripted responses. 344 + pub fn new() -> Self { 345 + Self { 346 + scripts: Arc::new(Mutex::new(Vec::new())), 347 + recorded: Arc::new(Mutex::new(Vec::new())), 348 + } 349 + } 350 + 351 + /// Queue a scripted response for the next `post_create_report` call. 352 + pub fn enqueue(&self, response: FakeCreateReportResponse) { 353 + self.scripts.lock().unwrap().push(response); 354 + } 355 + 356 + /// Return the recorded request history (cloned). 357 + pub fn recorded_requests(&self) -> Vec<RecordedCreateReportRequest> { 358 + self.recorded.lock().unwrap().clone() 359 + } 360 + 361 + /// Get the last recorded request, panicking if none. 362 + pub fn last_request(&self) -> RecordedCreateReportRequest { 363 + self.recorded 364 + .lock() 365 + .unwrap() 366 + .last() 367 + .cloned() 368 + .expect("FakeCreateReportTee: no requests recorded yet") 369 + } 370 + } 371 + 372 + impl Default for FakeCreateReportTee { 373 + fn default() -> Self { 374 + Self::new() 375 + } 376 + } 377 + 378 + #[async_trait] 379 + impl CreateReportTee for FakeCreateReportTee { 380 + async fn post_create_report( 381 + &self, 382 + auth: Option<&str>, 383 + body: &serde_json::Value, 384 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 385 + self.recorded.lock().unwrap().push(RecordedCreateReportRequest { 386 + auth: auth.map(|s| s.to_string()), 387 + body: body.clone(), 388 + }); 389 + 390 + let mut scripts = self.scripts.lock().unwrap(); 391 + if scripts.is_empty() { 392 + panic!( 393 + "FakeCreateReportTee: post_create_report called with no script queued. \ 394 + Each test must enqueue() exactly the responses it expects the stage to consume." 395 + ); 396 + } 397 + let script = scripts.remove(0); 398 + 399 + match script { 400 + FakeCreateReportResponse::Transport { message } => { 401 + Err(CreateReportStageError::Transport { 402 + message, 403 + source: None, 404 + }) 405 + } 406 + FakeCreateReportResponse::Response { status, content_type, body } => { 407 + let raw_body: Arc<[u8]> = Arc::from(body.as_slice()); 408 + Ok(RawCreateReportResponse { 409 + status: StatusCode::from_u16(status) 410 + .expect("test must use valid HTTP status"), 411 + content_type: content_type.map(|s| s.to_ascii_lowercase()), 412 + raw_body, 413 + source_url: "https://labeler.test/xrpc/com.atproto.moderation.createReport" 414 + .to_string(), 415 + }) 416 + } 417 + } 418 + } 419 + } 420 + ``` 421 + 422 + **Notes for the implementor:** 423 + - `tests/common/mod.rs` already has the `#![allow(dead_code)]` attribute at the top — these new helpers will not trigger dead-code warnings in test binaries that don't use them. 424 + - The panic on "no script queued" matches `FakeWebSocketClient::new`'s behavior and deliberately forces tests to declare every call. 425 + 426 + **Testing:** 427 + 428 + Add a smoke test in `tests/common/mod.rs` under `#[cfg(test)]`: 429 + 430 + ```rust 431 + #[cfg(test)] 432 + mod tests { 433 + use super::*; 434 + 435 + #[tokio::test] 436 + async fn fake_create_report_tee_serves_scripted_responses_and_records_requests() { 437 + let fake = FakeCreateReportTee::new(); 438 + fake.enqueue(FakeCreateReportResponse::unauthorized( 439 + "AuthenticationRequired", 440 + "jwt required", 441 + )); 442 + fake.enqueue(FakeCreateReportResponse::ok_empty()); 443 + 444 + let body1 = serde_json::json!({"a": 1}); 445 + let resp1 = fake 446 + .post_create_report(None, &body1) 447 + .await 448 + .expect("fake returns Ok"); 449 + assert_eq!(resp1.status, StatusCode::UNAUTHORIZED); 450 + let envelope: serde_json::Value = serde_json::from_slice(&resp1.raw_body).unwrap(); 451 + assert_eq!(envelope["error"], "AuthenticationRequired"); 452 + 453 + let body2 = serde_json::json!({"b": 2}); 454 + let resp2 = fake 455 + .post_create_report(Some("abc.def.ghi"), &body2) 456 + .await 457 + .expect("fake returns Ok"); 458 + assert_eq!(resp2.status, StatusCode::OK); 459 + 460 + let recorded = fake.recorded_requests(); 461 + assert_eq!(recorded.len(), 2); 462 + assert_eq!(recorded[0].auth, None); 463 + assert_eq!(recorded[0].body, body1); 464 + assert_eq!(recorded[1].auth.as_deref(), Some("abc.def.ghi")); 465 + assert_eq!(recorded[1].body, body2); 466 + } 467 + 468 + #[tokio::test] 469 + #[should_panic(expected = "no script queued")] 470 + async fn fake_create_report_tee_panics_on_unscripted_call() { 471 + let fake = FakeCreateReportTee::new(); 472 + let _ = fake 473 + .post_create_report(None, &serde_json::json!({})) 474 + .await; 475 + } 476 + } 477 + ``` 478 + 479 + **Notes:** These tests live inside `tests/common/mod.rs`, which cargo compiles as part of each integration test binary. The module-level `#![allow(dead_code)]` already at `tests/common/mod.rs:8` (existing convention; the comment there explains why `#[expect(...)]` can't be used at module scope when different test binaries use different subsets) already silences unused-item warnings. No new attributes are needed in `tests/common/mod.rs` itself. 480 + 481 + To ensure a test binary actually compiles and runs the smoke tests, put them in a new file `tests/common_fakes.rs` with `mod common;` and `use common::*;`. That isolates the tests without touching existing binaries, keeps per-binary attribute hygiene intact, and avoids the `#[expect]`-vs-`#[allow]` question at the item level (no items are tagged either way). 482 + 483 + **Verification:** 484 + Run: `cargo test --test common_fakes` (or whichever test binary hosts these) 485 + Expected: smoke tests pass. 486 + 487 + Run: `cargo test` (full suite) 488 + Expected: all pre-existing tests still pass. 489 + 490 + **Commit:** `test(create_report): FakeCreateReportTee with scripted responses` 491 + <!-- END_TASK_3 --> 492 + 493 + <!-- START_TASK_4 --> 494 + ### Task 4: Phase 3 integration check 495 + 496 + **Files:** None changed. 497 + 498 + **Implementation:** Gate. 499 + 500 + **Verification:** 501 + Run: `cargo build` 502 + Expected: clean build. 503 + 504 + Run: `cargo test` 505 + Expected: Phase 1 + Phase 2 tests still pass; Phase 3 adds 2+ new passing smoke tests. 506 + 507 + Run: `cargo clippy --all-targets -- -D warnings` 508 + Expected: no warnings. 509 + 510 + Run: `cargo fmt --check` 511 + Expected: no changes required. 512 + 513 + **Commit:** No new commit unless fixes were needed. 514 + <!-- END_TASK_4 --> 515 + 516 + --- 517 + 518 + ## Phase 3 complete when 519 + 520 + - `CreateReportTee` trait + `RawCreateReportResponse` + `CreateReportStageError` exist in `src/commands/test/labeler/create_report.rs`. 521 + - `RealCreateReportTee` compiles and matches the `RealHttpTee` pattern. 522 + - `FakeCreateReportTee` in `tests/common/mod.rs` serves scripted responses in FIFO order, records every request (auth + body) for inspection, and panics on unscripted calls. 523 + - Smoke tests prove the fake's scripted-response and request-capture behavior. 524 + - No acceptance criteria are directly verified in this phase; Phase 4 begins AC coverage.
+1166
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_04.md
··· 1 + # Labeler report stage — Phase 4: Stage scaffolding and contract check 2 + 3 + **Goal:** Wire a new `report` stage into `pipeline::run_pipeline` with `report::contract_published` operational in all four gating outcomes. Add the four new `LabelerCmd` CLI flags that Phase 4 needs (`--commit-report`, `--self-mint-curve`, `--force-self-mint`, `--report-subject-did`), plus supporting infrastructure — the `Stage::Report` variant, the `CreateReportStageOutput`/`Facts` types, the stage's `run()` function, and the pipeline integration. All 10 `report::*` row IDs are emitted every run (9 as `Skipped` for now). 4 + 5 + **Architecture:** The stage's `run()` takes `identity_facts`, a `&dyn CreateReportTee`, pipeline options, and CLI-derived flags; it decides the contract state, emits `report::contract_published` with the correct status, and emits all nine other checks as `Skipped` with the appropriate reason. The stage output follows the existing `*StageOutput { facts, results }` pattern (e.g., `HttpStageOutput` at `src/commands/test/labeler/http.rs:130-136`). 6 + 7 + **Tech Stack:** clap derive for new flags, `serde_json::Value` for rough payload inspection, the Phase 3 `CreateReportTee` seam (unused in this phase but plumbed so later phases need zero pipeline changes), insta snapshots for pinning output. 8 + 9 + **Scope:** Phase 4 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/commands/test/labeler/pipeline.rs` lines 1-381, `src/commands/test/labeler/report.rs`, `src/commands/test/labeler/http.rs`, `src/commands/test/labeler.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Stage enum at `src/commands/test/labeler/report.rs:84-94`: `Identity, Http, Subscription, Crypto`. ADD `Report` variant after `Crypto`; update `Stage::label` at line 98-105. 15 + - ✓ Pipeline insertion point at `src/commands/test/labeler/pipeline.rs:341-344` (the `crypto::run(...)` call site, inside the `if http_facts.is_some() || sub_has_labels` branch of the crypto block). New `report::run(...)` call goes AFTER the entire crypto block (after line 377) and BEFORE `report.finish()` at line 379. 16 + - ✓ `LabelerOptions` at `src/commands/test/labeler/pipeline.rs:48-61`. Add fields: `create_report_tee: CreateReportTeeKind<'a>`, `commit_report: bool`, `force_self_mint: bool`, `self_mint_curve: SelfMintCurve`, `report_subject_override: Option<Did>`. Mirror the `HttpTee<'a>` Real/Test enum at lines 64-69 with a new `CreateReportTeeKind<'a>`. 17 + - ✓ Identity facts: `IdentityFacts` populated in `identity::run` at `src/commands/test/labeler/identity.rs:375+` and threaded through pipeline at lines 209-216. The field `labeler_policies` is of type `atrium_api::app::bsky::labeler::defs::LabelerPolicies`. To access `reason_types` and `subject_types` without making the stage depend on atrium-api internals, the stage reads them via the already-public `IdentityFacts` (same pattern as the crypto stage consumes identity facts). 18 + - ✓ Check-builder pattern at `src/commands/test/labeler/http.rs:22-114`: `enum Check { ... }` with `fn id(self) -> &'static str` and `pub fn pass(self) / spec_violation / network_error / advisory` helpers. Mirror exactly for `create_report::Check`. 19 + - ✓ `CheckResult` direct construction pattern at `src/commands/test/labeler/pipeline.rs:264-271`. For `Skipped` the whole `CheckResult { id, stage, status: Skipped, summary, diagnostic: None, skipped_reason: Some(...) }` is constructed inline. 20 + - ✓ Diagnostic pattern at `src/commands/test/labeler/http.rs:151-163`: `#[derive(Debug, Error, Diagnostic)] #[diagnostic(code = "labeler::<stage>::<subject>")]` with `#[source_code]` + `#[label]` fields. Use for `labeler::report::contract_missing`. 21 + - ✓ LabelerCmd at `src/commands/test/labeler.rs:26-52`. Add new `#[arg(long)]` fields. clap `ValueEnum` for `--self-mint-curve`: use `SelfMintCurve` from Phase 2. 22 + - ✓ `LabelerCmd::run` at `src/commands/test/labeler.rs:54-93` constructs `LabelerOptions`. Extend with the new fields. `--handle`/`--app-password`/`clap requires` land in Phase 8; do NOT add them here. 23 + - ✓ **Correction after direct source-read:** `reason_types` and `subject_types` are NOT on `atrium_api::app::bsky::labeler::defs::LabelerPoliciesData` (that struct holds only `label_value_definitions` and `label_values`). They ARE on the full `atrium_api::app::bsky::labeler::service::RecordData` (the `app.bsky.labeler.service` record itself), which is already parsed in `src/commands/test/labeler/identity.rs:922` as `GetRecordResponse::value`, BUT the existing code extracts only `response.value.policies` into `IdentityFacts.labeler_policies` — it discards `reason_types` and `subject_types`. **Phase 4 must extend `IdentityFacts` to retain them.** The fields, per `atrium-api 0.25.8/src/app/bsky/labeler/service.rs`: 24 + - `reason_types: Option<Vec<crate::com::atproto::moderation::defs::ReasonType>>` — where `ReasonType = String`. 25 + - `subject_types: Option<Vec<crate::com::atproto::moderation::defs::SubjectType>>` — where `SubjectType = String`. 26 + - `subject_collections: Option<Vec<crate::types::string::Nsid>>` — bonus field, useful for AC3.5 subject-type validation; retain for future use. 27 + - `None` vs `Some(vec![])` are semantically distinct per the lexicon comment ("If not defined (distinct from empty array), all reason types are allowed"). However, the design's AC1.4 explicitly treats empty arrays as equivalent to absent — the stage normalizes `None` or `Some(empty)` as "no contract advertised." 28 + - ✓ Snapshot pattern at `tests/labeler_endtoend.rs:304` using `insta::assert_snapshot!("name", body)`. Per-stage binaries (e.g., `tests/labeler_http.rs`) follow the same pattern. Integration tests normalize output then snapshot. 29 + - ✓ `tests/fixtures/labeler/<stage>/<case>/` layout — mirror with new `tests/fixtures/labeler/report/contract_present/`, `/contract_missing/`, etc. 30 + - ✓ Empty case directories need `.gitkeep` (documented in `src/commands/test/labeler/CLAUDE.md:151-152`). 31 + 32 + **External dependency research findings:** N/A — internal wiring only. 33 + 34 + --- 35 + 36 + ## Acceptance criteria coverage 37 + 38 + This phase implements and tests: 39 + 40 + ### labeler-report-stage.AC1: `report::contract_published` behavior 41 + - **labeler-report-stage.AC1.1 Success:** Labeler advertises non-empty `reasonTypes` and `subjectTypes` → check emits `Pass`. 42 + - **labeler-report-stage.AC1.2 Success (stage-skip):** No `--commit-report`, contract missing → every `report::*` check emits `Skipped` with reason "labeler does not advertise report acceptance". 43 + - **labeler-report-stage.AC1.3 Failure:** `--commit-report` set, contract missing → `report::contract_published` emits `SpecViolation` with diagnostic `labeler::report::contract_missing`; all other checks emit `Skipped` with reason "blocked by `report::contract_published`". 44 + - **labeler-report-stage.AC1.4 Edge:** Empty arrays (`reasonTypes: []`) treated identically to absent field. 45 + 46 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants 47 + - **labeler-report-stage.AC7.1 Row count:** Every `test labeler` run that reaches the report stage emits exactly 10 `report::*` `CheckResult` rows, regardless of flag or environment combinations. (Exercised in Phase 4 for the contract-present / contract-missing / commit-on / commit-off combinations; re-verified in Phase 8 with the full 10-check matrix.) 48 + - **labeler-report-stage.AC7.2 Row order:** Row order is stable and matches the DoD list top-to-bottom. 49 + 50 + --- 51 + 52 + <!-- START_TASK_0 --> 53 + ### Task 0: Extend `IdentityFacts` to retain `reason_types` / `subject_types` / `subject_collections` 54 + 55 + **Verifies:** Prerequisite for AC1.1–AC1.4 (and indirectly every downstream committing check that uses the advertised contract). 56 + 57 + **Files:** 58 + - Modify: `src/commands/test/labeler/identity.rs` — add three new fields to `IdentityFacts`, update `fetch_labeler_record` to return them, update the facts-construction block at lines 812-823. 59 + 60 + **Implementation:** 61 + 62 + In `IdentityFacts` (around lines 27-50), add three new fields after `labeler_policies`: 63 + 64 + ```rust 65 + /// `app.bsky.labeler.service.reasonTypes` — the NSIDs of reason types 66 + /// this labeler accepts for `createReport`. `None` means "not advertised"; 67 + /// the report stage treats `None` and `Some(vec![])` identically as 68 + /// "contract not published" per AC1.4. 69 + pub reason_types: Option<Vec<String>>, 70 + /// `app.bsky.labeler.service.subjectTypes` — the subject-type kinds 71 + /// (`account`, `record`, ...) this labeler accepts for reports. 72 + pub subject_types: Option<Vec<String>>, 73 + /// `app.bsky.labeler.service.subjectCollections` — NSIDs of record 74 + /// collections this labeler will accept reports about. Retained for 75 + /// future AC use (e.g., refined pollution-avoidance in Phase 7); not 76 + /// read by Phase 4. 77 + pub subject_collections: Option<Vec<String>>, 78 + ``` 79 + 80 + Update `fetch_labeler_record` (at `src/commands/test/labeler/identity.rs:893-951`) to return `(Arc<[u8]>, LabelerPolicies, Option<Vec<String>>, Option<Vec<String>>, Option<Vec<String>>)`. At the success path on line 923, extract: 81 + 82 + ```rust 83 + Ok(response) => { 84 + let reason_types = response 85 + .value 86 + .reason_types 87 + .as_ref() 88 + .map(|v| v.iter().map(|r| r.clone()).collect::<Vec<String>>()); 89 + let subject_types = response 90 + .value 91 + .subject_types 92 + .as_ref() 93 + .map(|v| v.iter().map(|s| s.clone()).collect::<Vec<String>>()); 94 + let subject_collections = response 95 + .value 96 + .subject_collections 97 + .as_ref() 98 + .map(|v| v.iter().map(|n| n.to_string()).collect::<Vec<String>>()); 99 + Ok((body_arc, response.value.policies, reason_types, subject_types, subject_collections)) 100 + } 101 + ``` 102 + 103 + **Notes for the implementor:** 104 + - `ReasonType` and `SubjectType` are type aliases (`type ReasonType = String`) in atrium-api 0.25.8 (`com/atproto/moderation/defs.rs:15,19`). They deserialize as plain strings; the `.clone()` above is the type-safe conversion even though they're already `String`s under the hood. 105 + - `Nsid` (for `subject_collections`) is a wrapper type `atrium_api::types::string::Nsid`. Use `.to_string()` to unwrap to `String` for the plain field shape in `IdentityFacts`. 106 + - Update the `Ok((bytes, policies)) =>` match arm at lines 719-723 to destructure the 5-tuple and bind all five values into `labeler_record_bytes`, `labeler_policies`, `reason_types`, `subject_types`, `subject_collections`. 107 + - Thread all three new fields into the `IdentityFacts` construction at lines 812-823. 108 + - The existing `LabelerServiceRecordData` import at line 10 is already correct — `RecordData` already carries these fields on the Rust side; this task only adds their propagation into `IdentityFacts`. 109 + 110 + **Testing:** 111 + 112 + Add a unit test in `src/commands/test/labeler/identity.rs` `#[cfg(test)]` module: 113 + 114 + ```rust 115 + #[tokio::test] 116 + async fn identity_retains_reason_and_subject_types() { 117 + // Use an existing fixture under tests/fixtures/labeler/identity/ that 118 + // has a labeler service record with reasonTypes/subjectTypes set. If 119 + // none exists, create a new fixture directory with a minimal 120 + // {uri, cid, value: {createdAt, policies: {...}, reasonTypes: [...], subjectTypes: [...]}} 121 + // JSON. Drive identity::run with a FakeHttpClient scripting the PDS 122 + // getRecord response. Assert facts.reason_types == Some(vec![...]) 123 + // and facts.subject_types == Some(vec!["account"]). 124 + } 125 + ``` 126 + 127 + The implementor should reuse an existing identity fixture (grep `tests/fixtures/labeler/identity/` for `reasonTypes` — one may already exist). If none do, create `tests/fixtures/labeler/identity/report_stage_contract_present/labeler_record.json` with the minimal shape. 128 + 129 + **Verification:** 130 + Run: `cargo test --lib commands::test::labeler::identity::tests::identity_retains_reason_and_subject_types` 131 + Expected: passes. 132 + 133 + Run: `cargo test --lib commands::test::labeler::identity` 134 + Expected: all pre-existing identity unit tests still pass. 135 + 136 + Run: `cargo test --test labeler_identity` 137 + Expected: all pre-existing identity integration tests still pass (the struct change is additive, so existing constructions break — fix them: any code constructing `IdentityFacts { ... }` must add `reason_types: None, subject_types: None, subject_collections: None` defaults). Grep the codebase for `IdentityFacts {` to locate construction sites — the only current one is at `src/commands/test/labeler/identity.rs:813`. 138 + 139 + **Commit:** `feat(identity): retain reason_types / subject_types / subject_collections in IdentityFacts` 140 + <!-- END_TASK_0 --> 141 + 142 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 143 + <!-- START_TASK_1 --> 144 + ### Task 1: Add `Report` variant to `Stage` enum 145 + 146 + **Verifies:** Supports AC7.2 (row order). 147 + 148 + **Files:** 149 + - Modify: `src/commands/test/labeler/report.rs` — add `Report` variant to `Stage` enum at line 85, update the `label()` match at line 98-105. 150 + 151 + **Implementation:** 152 + 153 + ```rust 154 + // In src/commands/test/labeler/report.rs, modify Stage enum: 155 + pub enum Stage { 156 + Identity, 157 + Http, 158 + Subscription, 159 + Crypto, 160 + /// `com.atproto.moderation.createReport` authenticated-write stage. 161 + Report, 162 + } 163 + 164 + // And Stage::label(): 165 + pub fn label(self) -> &'static str { 166 + match self { 167 + Stage::Identity => "Identity", 168 + Stage::Http => "HTTP", 169 + Stage::Subscription => "Subscription", 170 + Stage::Crypto => "Crypto", 171 + Stage::Report => "Report", 172 + } 173 + } 174 + ``` 175 + 176 + **Testing:** 177 + 178 + Add an assertion test in the same file's test module: 179 + 180 + ```rust 181 + #[test] 182 + fn report_stage_ordering_places_report_last() { 183 + assert!(Stage::Identity < Stage::Http); 184 + assert!(Stage::Http < Stage::Subscription); 185 + assert!(Stage::Subscription < Stage::Crypto); 186 + assert!(Stage::Crypto < Stage::Report); 187 + } 188 + ``` 189 + 190 + **Verification:** 191 + Run: `cargo test --lib commands::test::labeler::report::tests::report_stage_` 192 + Expected: passes. 193 + 194 + Run: `cargo build` 195 + Expected: clean build (other stages' `CheckResult` constructors unaffected). 196 + 197 + **Commit:** `feat(report): add Report variant to Stage enum` 198 + <!-- END_TASK_1 --> 199 + 200 + <!-- START_TASK_2 --> 201 + ### Task 2: Create `CreateReportStageOutput`, `CreateReportFacts`, and the `Check` enum 202 + 203 + **Verifies:** Supports all AC coverage (infrastructure for check construction). 204 + 205 + **Files:** 206 + - Modify: `src/commands/test/labeler/create_report.rs` — append after the Phase 3 types. 207 + 208 + **Implementation:** 209 + 210 + ```rust 211 + use std::borrow::Cow; 212 + 213 + use crate::commands::test::labeler::report::{CheckResult, CheckStatus, Stage}; 214 + 215 + /// Minimal per-check outcome facts for possible future consumer stages. 216 + /// All three `Option<bool>` fields are `None` unless the corresponding 217 + /// positive check ran and produced a concrete outcome. 218 + #[derive(Debug, Clone, Default)] 219 + pub struct CreateReportFacts { 220 + /// `self_mint_accepted` outcome: `Some(true)` on Pass, `Some(false)` on 221 + /// SpecViolation, `None` on Skipped/NetworkError. 222 + pub self_mint_succeeded: Option<bool>, 223 + /// `pds_service_auth_accepted` outcome (see above). 224 + pub pds_service_auth_succeeded: Option<bool>, 225 + /// `pds_proxied_accepted` outcome (see above). 226 + pub pds_proxied_succeeded: Option<bool>, 227 + } 228 + 229 + /// Stage output: facts (populated only when the stage produced meaningful 230 + /// outcome data) and the full 10-row results vector. 231 + #[derive(Debug)] 232 + pub struct CreateReportStageOutput { 233 + pub facts: Option<CreateReportFacts>, 234 + pub results: Vec<CheckResult>, 235 + } 236 + 237 + /// Stable check identifiers for the `report` stage. 238 + /// 239 + /// Order MUST match the DoD ordering (AC7.2): contract, unauth, malformed, 240 + /// wrong-aud, wrong-lxm, expired, rejected-shape, self-mint, pds-service-auth, 241 + /// pds-proxied. 242 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 243 + pub enum Check { 244 + ContractPublished, 245 + UnauthenticatedRejected, 246 + MalformedBearerRejected, 247 + WrongAudRejected, 248 + WrongLxmRejected, 249 + ExpiredRejected, 250 + RejectedShapeReturns400, 251 + SelfMintAccepted, 252 + PdsServiceAuthAccepted, 253 + PdsProxiedAccepted, 254 + } 255 + 256 + impl Check { 257 + /// Stable `CheckResult.id` string. 258 + pub fn id(self) -> &'static str { 259 + match self { 260 + Check::ContractPublished => "report::contract_published", 261 + Check::UnauthenticatedRejected => "report::unauthenticated_rejected", 262 + Check::MalformedBearerRejected => "report::malformed_bearer_rejected", 263 + Check::WrongAudRejected => "report::wrong_aud_rejected", 264 + Check::WrongLxmRejected => "report::wrong_lxm_rejected", 265 + Check::ExpiredRejected => "report::expired_rejected", 266 + Check::RejectedShapeReturns400 => "report::rejected_shape_returns_400", 267 + Check::SelfMintAccepted => "report::self_mint_accepted", 268 + Check::PdsServiceAuthAccepted => "report::pds_service_auth_accepted", 269 + Check::PdsProxiedAccepted => "report::pds_proxied_accepted", 270 + } 271 + } 272 + 273 + /// Canonical iteration order for the 10 checks, matching AC7.2. 274 + pub const ORDER: [Check; 10] = [ 275 + Check::ContractPublished, 276 + Check::UnauthenticatedRejected, 277 + Check::MalformedBearerRejected, 278 + Check::WrongAudRejected, 279 + Check::WrongLxmRejected, 280 + Check::ExpiredRejected, 281 + Check::RejectedShapeReturns400, 282 + Check::SelfMintAccepted, 283 + Check::PdsServiceAuthAccepted, 284 + Check::PdsProxiedAccepted, 285 + ]; 286 + 287 + /// Build a `Pass` result for this check with a default summary. 288 + pub fn pass(self) -> CheckResult { 289 + CheckResult { 290 + id: self.id(), 291 + stage: Stage::Report, 292 + status: CheckStatus::Pass, 293 + summary: Cow::Borrowed(self.default_summary_pass()), 294 + diagnostic: None, 295 + skipped_reason: None, 296 + } 297 + } 298 + 299 + /// Build a `SpecViolation` result for this check with an optional 300 + /// diagnostic. 301 + pub fn spec_violation( 302 + self, 303 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 304 + ) -> CheckResult { 305 + CheckResult { 306 + id: self.id(), 307 + stage: Stage::Report, 308 + status: CheckStatus::SpecViolation, 309 + summary: Cow::Borrowed(self.default_summary_fail()), 310 + diagnostic, 311 + skipped_reason: None, 312 + } 313 + } 314 + 315 + /// Build an `Advisory` result (used by `rejected_shape_returns_400` AC3.6). 316 + pub fn advisory( 317 + self, 318 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 319 + ) -> CheckResult { 320 + CheckResult { 321 + id: self.id(), 322 + stage: Stage::Report, 323 + status: CheckStatus::Advisory, 324 + summary: Cow::Borrowed(self.default_summary_fail()), 325 + diagnostic, 326 + skipped_reason: None, 327 + } 328 + } 329 + 330 + /// Build a `NetworkError` result (used by PDS-side failure modes). 331 + pub fn network_error(self, message: String) -> CheckResult { 332 + CheckResult { 333 + id: self.id(), 334 + stage: Stage::Report, 335 + status: CheckStatus::NetworkError, 336 + summary: Cow::Owned(format!("{}: {message}", self.default_summary_fail())), 337 + diagnostic: None, 338 + skipped_reason: None, 339 + } 340 + } 341 + 342 + /// Build a `Skipped` result with the supplied reason. 343 + pub fn skip(self, reason: &'static str) -> CheckResult { 344 + CheckResult { 345 + id: self.id(), 346 + stage: Stage::Report, 347 + status: CheckStatus::Skipped, 348 + summary: Cow::Borrowed(self.default_summary_pass()), 349 + diagnostic: None, 350 + skipped_reason: Some(Cow::Borrowed(reason)), 351 + } 352 + } 353 + 354 + fn default_summary_pass(self) -> &'static str { 355 + match self { 356 + Check::ContractPublished => "Labeler advertises reportable shape", 357 + Check::UnauthenticatedRejected => "Unauthenticated report rejected", 358 + Check::MalformedBearerRejected => "Malformed bearer rejected", 359 + Check::WrongAudRejected => "JWT with wrong `aud` rejected", 360 + Check::WrongLxmRejected => "JWT with wrong `lxm` rejected", 361 + Check::ExpiredRejected => "Expired JWT rejected", 362 + Check::RejectedShapeReturns400 => "Invalid shape returns 400 InvalidRequest", 363 + Check::SelfMintAccepted => "Self-mint report accepted", 364 + Check::PdsServiceAuthAccepted => "PDS-minted JWT accepted", 365 + Check::PdsProxiedAccepted => "PDS-proxied report accepted", 366 + } 367 + } 368 + 369 + fn default_summary_fail(self) -> &'static str { 370 + match self { 371 + Check::ContractPublished => "Labeler does not advertise a reportable shape", 372 + Check::UnauthenticatedRejected => "Unauthenticated report accepted (should have been rejected)", 373 + Check::MalformedBearerRejected => "Malformed bearer accepted (should have been rejected)", 374 + Check::WrongAudRejected => "JWT with wrong `aud` accepted", 375 + Check::WrongLxmRejected => "JWT with wrong `lxm` accepted", 376 + Check::ExpiredRejected => "Expired JWT accepted", 377 + Check::RejectedShapeReturns400 => "Rejection status was not 400 InvalidRequest", 378 + Check::SelfMintAccepted => "Self-mint report rejected", 379 + Check::PdsServiceAuthAccepted => "PDS-minted JWT rejected", 380 + Check::PdsProxiedAccepted => "PDS-proxied report rejected", 381 + } 382 + } 383 + } 384 + ``` 385 + 386 + **Testing:** 387 + 388 + ```rust 389 + #[cfg(test)] 390 + mod check_tests { 391 + use super::*; 392 + 393 + #[test] 394 + fn check_ids_are_unique_and_report_namespaced() { 395 + let mut seen = std::collections::HashSet::new(); 396 + for c in Check::ORDER { 397 + let id = c.id(); 398 + assert!(id.starts_with("report::"), "{id} not in report:: namespace"); 399 + assert!(seen.insert(id), "duplicate check id: {id}"); 400 + } 401 + assert_eq!(Check::ORDER.len(), 10, "DoD requires exactly 10 checks"); 402 + } 403 + } 404 + ``` 405 + 406 + **Verification:** 407 + Run: `cargo test --lib commands::test::labeler::create_report::check_tests` 408 + Expected: pass. 409 + 410 + **Commit:** `feat(create_report): Check enum and CreateReportStageOutput` 411 + <!-- END_TASK_2 --> 412 + 413 + <!-- START_TASK_3 --> 414 + ### Task 3: Contract-missing diagnostic type 415 + 416 + **Verifies:** AC1.3. 417 + 418 + **Files:** 419 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 420 + 421 + **Implementation:** 422 + 423 + ```rust 424 + /// Diagnostic for the `contract_missing` spec violation (AC1.3). 425 + /// 426 + /// Emitted when `--commit-report` is set and the identity-stage 427 + /// `labeler_policies` does not advertise a non-empty `reasonTypes` and 428 + /// `subjectTypes`. The body of the labeler record is attached as source 429 + /// so users can see what _was_ published. 430 + #[derive(Debug, Error, Diagnostic)] 431 + #[error("Labeler does not advertise a reportable `LabelerPolicies` shape")] 432 + #[diagnostic( 433 + code = "labeler::report::contract_missing", 434 + help = "`reasonTypes` and `subjectTypes` must both be present and non-empty on the labeler's published policies; the tool cannot verify reporting conformance without them." 435 + )] 436 + pub struct ContractMissing { 437 + /// `reasonTypes` present and non-empty? 438 + pub has_reason_types: bool, 439 + /// `subjectTypes` present and non-empty? 440 + pub has_subject_types: bool, 441 + } 442 + ``` 443 + 444 + **Testing:** No standalone test — exercised by AC1.3 integration test in Task 7. 445 + 446 + **Verification:** 447 + Run: `cargo build` 448 + Expected: clean. 449 + 450 + **Commit:** `feat(create_report): ContractMissing diagnostic for AC1.3` 451 + <!-- END_TASK_3 --> 452 + <!-- END_SUBCOMPONENT_A --> 453 + 454 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 455 + <!-- START_TASK_4 --> 456 + ### Task 4: Add the new CLI flags to `LabelerCmd` 457 + 458 + **Verifies:** AC8.2 (via `--self-mint-curve`); supports gating assertions in AC1/AC4. 459 + 460 + **Files:** 461 + - Modify: `src/commands/test/labeler.rs` — extend `LabelerCmd` struct with four new flags. 462 + 463 + **Implementation:** 464 + 465 + ```rust 466 + // Add imports at the top: 467 + use crate::commands::test::labeler::create_report::self_mint::SelfMintCurve; 468 + use crate::common::identity::Did; 469 + 470 + // In LabelerCmd struct, after existing fields: 471 + 472 + /// Commit: opt in to actually POSTing report bodies to the labeler and 473 + /// assert reporting conformance (missing `LabelerPolicies` becomes a 474 + /// SpecViolation rather than a stage-skip). 475 + #[arg(long)] 476 + pub commit_report: bool, 477 + 478 + /// Force self-mint checks to run even when the labeler endpoint is 479 + /// classified as non-local by the hostname heuristic. Use when 480 + /// running against a LAN-reachable labeler that the heuristic misses. 481 + #[arg(long)] 482 + pub force_self_mint: bool, 483 + 484 + /// Curve to use for self-mint JWTs. Default `es256k` (maximum overlap 485 + /// with real atproto accounts). 486 + #[arg(long, value_enum, default_value_t = SelfMintCurve::default())] 487 + pub self_mint_curve: SelfMintCurve, 488 + 489 + /// Override the default computed subject DID for committing checks. 490 + /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`, 491 + /// and `pds_proxied_accepted` bodies. 492 + #[arg(long)] 493 + pub report_subject_did: Option<String>, 494 + ``` 495 + 496 + In `LabelerCmd::run`, thread the new fields through to `LabelerOptions`. Build a `Did` from `report_subject_did` if provided: 497 + 498 + ```rust 499 + // In LabelerCmd::run, when constructing LabelerOptions: 500 + let report_subject_override = self.report_subject_did.clone().map(Did); 501 + 502 + // Build a SelfMintSigner lazily: only construct if the stage will actually 503 + // use it. For Phase 4, the stage never uses it — Phase 6 starts consuming it. 504 + // Phase 4 still needs LabelerOptions to accept Option<&SelfMintSigner>, so 505 + // we pass None here. Phase 6 adds the construction path. 506 + 507 + let opts = LabelerOptions { 508 + http: &http, 509 + dns: &dns, 510 + http_tee: pipeline::HttpTee::Real(&reqwest_client), 511 + ws_client: None, 512 + subscribe_timeout: self.subscribe_timeout, 513 + verbose: self.verbose, 514 + 515 + create_report_tee: pipeline::CreateReportTeeKind::Real(&reqwest_client), 516 + commit_report: self.commit_report, 517 + force_self_mint: self.force_self_mint, 518 + self_mint_curve: self.self_mint_curve, 519 + report_subject_override: report_subject_override.as_ref(), 520 + self_mint_signer: None, // plumbed in Phase 6/7 521 + pds_credentials: None, // plumbed in Phase 8 522 + }; 523 + ``` 524 + 525 + **Notes for the implementor:** 526 + - The new `self_mint_signer: None` and `pds_credentials: None` fields are *forward-declared* here even though Phase 4 code doesn't populate them. This means Phase 4's pipeline integration can accept those types without needing a second `LabelerOptions` refactor in Phase 6 or Phase 8. See Task 6 below for the full `LabelerOptions` shape. 527 + 528 + **Testing:** 529 + 530 + Extend `tests/labeler_cli.rs` with a new test verifying the help lists all the new flags. Existing `help_lists_all_flags()` pattern shows the approach — assert `--commit-report`, `--force-self-mint`, `--self-mint-curve`, `--report-subject-did` appear in `--help` output. 531 + 532 + **Verification:** 533 + Run: `cargo build` 534 + Expected: clean (may need to import `SelfMintCurve` correctly). 535 + 536 + Run: `cargo test --test labeler_cli help_lists_all_flags` 537 + Expected: pass after updating the expected flag list. 538 + 539 + Run: `cargo run -- test labeler --help` 540 + Expected: new flags appear. 541 + 542 + **Commit:** `feat(labeler): add report-stage CLI flags` 543 + <!-- END_TASK_4 --> 544 + 545 + <!-- START_TASK_5 --> 546 + ### Task 5: Extend `LabelerOptions` and `CreateReportTeeKind` 547 + 548 + **Verifies:** Infrastructure for AC1/AC7. 549 + 550 + **Files:** 551 + - Modify: `src/commands/test/labeler/pipeline.rs` — extend `LabelerOptions` and add new enum. 552 + 553 + **Implementation:** 554 + 555 + ```rust 556 + // In src/commands/test/labeler/pipeline.rs, add imports: 557 + use crate::commands::test::labeler::create_report::{self, CreateReportTee, RealCreateReportTee}; 558 + use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 559 + 560 + /// CreateReport tee selector. `Real` delegates to a shared reqwest client 561 + /// that the pipeline instantiates per-endpoint; `Test` lets integration 562 + /// tests inject a `FakeCreateReportTee`. 563 + pub enum CreateReportTeeKind<'a> { 564 + /// Shared reqwest client; pipeline constructs a `RealCreateReportTee`. 565 + Real(&'a reqwest::Client), 566 + /// Explicit control (for tests). 567 + Test(&'a dyn CreateReportTee), 568 + } 569 + 570 + /// Credentials for PDS-mediated modes (modes 2 and 3). Present when 571 + /// `--handle` and `--app-password` are both supplied. Phase 8 populates 572 + /// this; Phase 4 and earlier leave it as `None`. 573 + #[derive(Debug, Clone)] 574 + pub struct PdsCredentials { 575 + /// The user's handle (e.g., `alice.bsky.social`). 576 + pub handle: String, 577 + /// The user's app password. 578 + pub app_password: String, 579 + } 580 + 581 + // Modify LabelerOptions: 582 + pub struct LabelerOptions<'a> { 583 + pub http: &'a dyn HttpClient, 584 + pub dns: &'a dyn DnsResolver, 585 + pub http_tee: HttpTee<'a>, 586 + pub ws_client: Option<&'a dyn subscription::WebSocketClient>, 587 + pub subscribe_timeout: Duration, 588 + pub verbose: bool, 589 + 590 + // Report stage wiring: 591 + pub create_report_tee: CreateReportTeeKind<'a>, 592 + pub commit_report: bool, 593 + pub force_self_mint: bool, 594 + pub self_mint_curve: SelfMintCurve, 595 + pub report_subject_override: Option<&'a Did>, 596 + /// Self-mint signer. Populated in `LabelerCmd::run` only when the 597 + /// heuristic + `--force-self-mint` say self-mint is viable. Phase 4 598 + /// leaves this as `None` when the pipeline can't reach the labeler 599 + /// locally. 600 + pub self_mint_signer: Option<&'a SelfMintSigner>, 601 + /// PDS credentials for modes 2 and 3. Populated in Phase 8. 602 + pub pds_credentials: Option<&'a PdsCredentials>, 603 + } 604 + ``` 605 + 606 + **Notes for the implementor:** 607 + - Do NOT change `HttpTee<'a>` or any existing fields — this is additive only. 608 + - Tests that construct `LabelerOptions` will need the new fields; include a pattern in Task 6's integration tests. 609 + 610 + **Testing:** No standalone test; exercised by subsequent tasks. 611 + 612 + **Verification:** 613 + Run: `cargo build` 614 + Expected: clean. 615 + 616 + **Commit:** `feat(pipeline): extend LabelerOptions for report stage` 617 + <!-- END_TASK_5 --> 618 + <!-- END_SUBCOMPONENT_B --> 619 + 620 + <!-- START_SUBCOMPONENT_C (tasks 6-7) --> 621 + <!-- START_TASK_6 --> 622 + ### Task 6: Implement `create_report::run` with contract check and 9 stub skips 623 + 624 + **Verifies:** AC1.1, AC1.2, AC1.3, AC1.4, AC7.1, AC7.2. 625 + 626 + **Files:** 627 + - Modify: `src/commands/test/labeler/create_report.rs` — append the `run` function. 628 + 629 + **Implementation:** 630 + 631 + ```rust 632 + use crate::commands::test::labeler::identity::IdentityFacts; 633 + use crate::commands::test::labeler::pipeline::{CreateReportTeeKind, LabelerOptions}; 634 + 635 + /// Run the report stage. 636 + /// 637 + /// Stage inputs are passed via `LabelerOptions` (or directly as arguments 638 + /// here, to keep the signature mirroring the other stages). The stage 639 + /// always emits exactly 10 `report::*` CheckResults (AC7.1) in canonical 640 + /// order (AC7.2), regardless of gating decisions. 641 + pub async fn run( 642 + identity_facts: Option<&IdentityFacts>, 643 + report_tee: &dyn CreateReportTee, 644 + opts: &CreateReportRunOptions<'_>, 645 + ) -> CreateReportStageOutput { 646 + let mut results = Vec::with_capacity(10); 647 + 648 + // If identity didn't land, every check is blocked by the identity 649 + // stage. Emit 10 Skipped rows and return. 650 + let Some(id_facts) = identity_facts else { 651 + for c in Check::ORDER { 652 + results.push(c.skip("blocked by identity stage")); 653 + } 654 + return CreateReportStageOutput { 655 + facts: None, 656 + results, 657 + }; 658 + }; 659 + 660 + // Examine the published contract (from Task 0's extended IdentityFacts). 661 + let reason_types = id_facts.reason_types.as_ref(); 662 + let subject_types = id_facts.subject_types.as_ref(); 663 + let has_reason_types = reason_types.map(|v| !v.is_empty()).unwrap_or(false); 664 + let has_subject_types = subject_types.map(|v| !v.is_empty()).unwrap_or(false); 665 + let contract_advertised = has_reason_types && has_subject_types; 666 + 667 + // AC1: compute the contract_published row and the blocking reason for 668 + // all downstream checks if the contract is missing. 669 + // 670 + // Control-flow contract: each branch below pushes EXACTLY 10 rows 671 + // (1 contract row + 9 downstream) and returns. No fallthrough — the 672 + // "contract advertised" branch is the one that invokes Phases 5/6/7/8 673 + // logic (added incrementally; Phase 4 emits Skipped stubs for them). 674 + if !contract_advertised { 675 + if opts.commit_report { 676 + // AC1.3: commit requested, contract missing ⇒ SpecViolation + 677 + // every other check blocked by this one. 678 + let diag = Box::new(ContractMissing { 679 + has_reason_types, 680 + has_subject_types, 681 + }); 682 + results.push(Check::ContractPublished.spec_violation(Some(diag))); 683 + for c in Check::ORDER.iter().skip(1).copied() { 684 + results.push(c.skip("blocked by `report::contract_published`")); 685 + } 686 + } else { 687 + // AC1.2: no commit, contract missing ⇒ whole stage skipped. 688 + results.push(Check::ContractPublished.skip( 689 + "labeler does not advertise report acceptance", 690 + )); 691 + for c in Check::ORDER.iter().skip(1).copied() { 692 + results.push(c.skip("labeler does not advertise report acceptance")); 693 + } 694 + } 695 + return CreateReportStageOutput { 696 + facts: None, 697 + results, 698 + }; 699 + } 700 + 701 + // Contract advertised. Emit the Pass row and fall through into the 702 + // per-check logic that Phases 5-8 replace incrementally. 703 + results.push(Check::ContractPublished.pass()); 704 + 705 + // Phase 4 leaves all 9 downstream checks as Skipped stubs. Phases 706 + // 5-8 replace this block in place. The `_ = report_tee` suppresses 707 + // dead-code warnings until Phase 5. 708 + let _ = report_tee; 709 + 710 + for c in [ 711 + Check::UnauthenticatedRejected, 712 + Check::MalformedBearerRejected, 713 + ] { 714 + results.push(c.skip("not yet implemented (Phase 5)")); 715 + } 716 + for c in [ 717 + Check::WrongAudRejected, 718 + Check::WrongLxmRejected, 719 + Check::ExpiredRejected, 720 + Check::RejectedShapeReturns400, 721 + ] { 722 + results.push(c.skip("not yet implemented (Phase 6)")); 723 + } 724 + results.push(Check::SelfMintAccepted.skip("not yet implemented (Phase 7)")); 725 + results.push(Check::PdsServiceAuthAccepted.skip("not yet implemented (Phase 8)")); 726 + results.push(Check::PdsProxiedAccepted.skip("not yet implemented (Phase 8)")); 727 + 728 + CreateReportStageOutput { 729 + facts: None, 730 + results, 731 + } 732 + } 733 + 734 + /// Aggregate of the stage-relevant options, extracted from `LabelerOptions` 735 + /// by the pipeline and passed to `run`. Having a local, narrow shape 736 + /// avoids forcing `run`'s signature to take everything in `LabelerOptions`. 737 + #[derive(Debug)] 738 + pub struct CreateReportRunOptions<'a> { 739 + pub commit_report: bool, 740 + pub force_self_mint: bool, 741 + pub self_mint_curve: crate::commands::test::labeler::create_report::self_mint::SelfMintCurve, 742 + pub report_subject_override: Option<&'a crate::common::identity::Did>, 743 + pub self_mint_signer: Option<&'a crate::commands::test::labeler::create_report::self_mint::SelfMintSigner>, 744 + pub pds_credentials: Option<&'a crate::commands::test::labeler::pipeline::PdsCredentials>, 745 + } 746 + ``` 747 + 748 + **Notes for the implementor:** 749 + - The stage accepts `Option<&IdentityFacts>` rather than a required reference so the pipeline can pass `None` when identity didn't produce facts — matching how the crypto stage handles the same scenario. 750 + - The 9 post-contract "not yet implemented" `Skipped` rows are temporary — they become real checks in Phases 5-8. Keep the wording deliberately boring; these exact strings will appear in Phase 4's insta snapshots and later phases will overwrite them. 751 + 752 + **Testing:** Covered in Task 7's integration tests. 753 + 754 + **Verification:** 755 + Run: `cargo build` 756 + Expected: clean. 757 + 758 + **Commit:** `feat(create_report): stage run() with contract check and stubs` 759 + <!-- END_TASK_6 --> 760 + 761 + <!-- START_TASK_7 --> 762 + ### Task 7: Pipeline integration + first integration test 763 + 764 + **Verifies:** AC1.1–AC1.4, AC7.1, AC7.2. 765 + 766 + **Files:** 767 + - Modify: `src/commands/test/labeler/pipeline.rs` — call `create_report::run` after the crypto block (after line 377), before `report.finish()` at line 379. 768 + - Create: `tests/labeler_report.rs` — new integration test binary for per-stage tests. 769 + - Create: `tests/fixtures/labeler/report/.gitkeep` — fixture directory seed. 770 + - Modify: `tests/common/mod.rs` (already done in Phase 3) — `FakeCreateReportTee` is ready. 771 + 772 + **Implementation (pipeline.rs):** 773 + 774 + ```rust 775 + // At the end of run_pipeline, after the crypto block (around line 378), 776 + // before `report.finish()`: 777 + 778 + // Run the report stage. Uses `identity_output.facts.as_ref()` so the 779 + // stage can skip-with-reason when identity didn't produce facts. 780 + let create_report_run_opts = create_report::CreateReportRunOptions { 781 + commit_report: opts.commit_report, 782 + force_self_mint: opts.force_self_mint, 783 + self_mint_curve: opts.self_mint_curve, 784 + report_subject_override: opts.report_subject_override, 785 + self_mint_signer: opts.self_mint_signer, 786 + pds_credentials: opts.pds_credentials, 787 + }; 788 + let labeler_endpoint_for_report = labeler_endpoint.clone(); 789 + let report_output = match opts.create_report_tee { 790 + CreateReportTeeKind::Test(tee) => { 791 + create_report::run( 792 + identity_output.facts.as_ref(), 793 + tee, 794 + &create_report_run_opts, 795 + ) 796 + .await 797 + } 798 + CreateReportTeeKind::Real(client) => { 799 + // Resolve the labeler endpoint for the real tee. When identity 800 + // didn't supply an endpoint, we'd still want `run` to emit the 801 + // 10 `Skipped` rows — but construct a do-nothing tee with a 802 + // dummy URL to satisfy the type; the function short-circuits on 803 + // `identity_facts == None` before any tee method is called. 804 + let endpoint = labeler_endpoint_for_report.unwrap_or_else(|| { 805 + // Safe dummy: we know `run` won't issue any POSTs in this 806 + // path (identity_facts is None). 807 + url::Url::parse("http://127.0.0.1:0").expect("dummy URL parses") 808 + }); 809 + let real_tee = RealCreateReportTee::new(client.clone(), endpoint); 810 + create_report::run( 811 + identity_output.facts.as_ref(), 812 + &real_tee, 813 + &create_report_run_opts, 814 + ) 815 + .await 816 + } 817 + }; 818 + for result in report_output.results { 819 + report.record(result); 820 + } 821 + 822 + report.finish(); 823 + report 824 + ``` 825 + 826 + **Implementation (tests/labeler_report.rs):** 827 + 828 + ```rust 829 + //! Integration tests for the `report` stage. 830 + //! 831 + //! Uses `FakeCreateReportTee` from `tests/common/mod.rs` to drive each 832 + //! stage-gating scenario, then snapshots the rendered output to pin the 833 + //! exact 10-row sequence. 834 + 835 + mod common; 836 + 837 + use std::sync::Arc; 838 + use std::time::Duration; 839 + 840 + use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 841 + use atproto_devtool::commands::test::labeler::identity::IdentityFacts; 842 + use atproto_devtool::commands::test::labeler::pipeline::{ 843 + self, CreateReportTeeKind, LabelerOptions, 844 + }; 845 + use atproto_devtool::commands::test::labeler::report::{ 846 + CheckResult, CheckStatus, LabelerReport, RenderConfig, ReportHeader, Stage, 847 + }; 848 + use atproto_devtool::commands::test::labeler::create_report; 849 + use atproto_devtool::common::identity::Did; 850 + 851 + use atrium_api::app::bsky::labeler::defs::LabelerPolicies; 852 + use atrium_api::types::Object; 853 + 854 + use common::{FakeCreateReportTee, FakeRawHttpTee, FakeWebSocketClient}; 855 + 856 + /// Build a synthetic `IdentityFacts` with the requested contract shape. 857 + /// 858 + /// Populates every required field with stable, known-valid defaults so the 859 + /// fixture is safe to reuse across all AC tests. The labeler endpoint is a 860 + /// public HTTPS URL by default (non-local per the viability heuristic); 861 + /// tests that need a local endpoint override `facts.labeler_endpoint` 862 + /// directly before passing in. 863 + fn make_identity_facts( 864 + reason_types: Option<Vec<String>>, 865 + subject_types: Option<Vec<String>>, 866 + ) -> IdentityFacts { 867 + use std::sync::Arc; 868 + use atrium_api::app::bsky::labeler::defs::{LabelerPolicies, LabelerPoliciesData}; 869 + use atrium_api::types::Object; 870 + use atproto_devtool::common::identity::{ 871 + AnyVerifyingKey, Did, DidDocument, RawDidDocument, parse_multikey, 872 + }; 873 + 874 + // A stable secp256k1 multikey drawn from an existing test fixture. 875 + // The exact value is load-bearing only insofar as it must parse via 876 + // `parse_multikey` — any valid secp256k1 multikey works. 877 + let multikey = "zQ3shNcc9CfAhG1vLj3UEV3SA4VESNiJKJiFLgs6WfGo4qG7B"; 878 + let parsed = parse_multikey(multikey).expect("test multikey parses"); 879 + let verifying_key: AnyVerifyingKey = parsed.verifying_key; 880 + 881 + // Minimal DID document with the one verification method the stages 882 + // care about. Raw bytes must match the parsed form so `NamedSource` 883 + // diagnostics land correctly; the exact bytes aren't snapshotted in 884 + // Phase 4 tests, so a small JSON is fine. 885 + let did_string = "did:plc:aaa22222222222222222bbbbbb"; 886 + let doc_json = format!( 887 + r#"{{"id":"{did_string}","verificationMethod":[{{"id":"{did_string}#atproto_label","type":"Multikey","controller":"{did_string}","publicKeyMultibase":"{multikey}"}}],"service":[{{"id":"#atproto_labeler","type":"AtprotoLabeler","serviceEndpoint":"https://labeler.example.com"}},{{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}}]}}"# 888 + ); 889 + let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses"); 890 + let raw_did_doc = RawDidDocument { 891 + parsed: doc, 892 + source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()), 893 + source_name: "test DID document".to_string(), 894 + }; 895 + 896 + // Empty `LabelerPolicies` is always valid — the AC1 gate reads 897 + // `reason_types`/`subject_types` on `IdentityFacts` directly (Task 0), 898 + // not on `labeler_policies`. 899 + let labeler_policies: LabelerPolicies = Object { 900 + data: LabelerPoliciesData { 901 + label_value_definitions: None, 902 + label_values: vec![], 903 + }, 904 + extra_data: ipld_core::ipld::Ipld::Null, 905 + }; 906 + 907 + IdentityFacts { 908 + did: Did(did_string.to_string()), 909 + raw_did_doc, 910 + labeler_endpoint: url::Url::parse("https://labeler.example.com").unwrap(), 911 + pds_endpoint: url::Url::parse("https://pds.example.com").unwrap(), 912 + signing_key_id: format!("{did_string}#atproto_label"), 913 + signing_key_multikey: multikey.to_string(), 914 + signing_key: verifying_key, 915 + labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]), 916 + labeler_policies, 917 + reason_types, 918 + subject_types, 919 + subject_collections: None, 920 + } 921 + } 922 + 923 + /// Run the report stage directly (not through run_pipeline) with the 924 + /// given fake tee and options. Returns the 10 CheckResults. 925 + async fn run_report_stage( 926 + facts: &IdentityFacts, 927 + tee: &FakeCreateReportTee, 928 + opts: create_report::CreateReportRunOptions<'_>, 929 + ) -> Vec<CheckResult> { 930 + let out = create_report::run(Some(facts), tee, &opts).await; 931 + out.results 932 + } 933 + 934 + #[tokio::test] 935 + async fn ac1_1_contract_present_emits_pass() { 936 + let facts = make_identity_facts( 937 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 938 + Some(vec!["account".to_string()]), 939 + ); 940 + let tee = FakeCreateReportTee::new(); 941 + let opts = create_report::CreateReportRunOptions { 942 + commit_report: false, 943 + force_self_mint: false, 944 + self_mint_curve: SelfMintCurve::Es256k, 945 + report_subject_override: None, 946 + self_mint_signer: None, 947 + pds_credentials: None, 948 + }; 949 + let results = run_report_stage(&facts, &tee, opts).await; 950 + 951 + assert_eq!(results.len(), 10, "AC7.1 requires exactly 10 rows"); 952 + assert_eq!(results[0].id, "report::contract_published"); 953 + assert_eq!(results[0].status, CheckStatus::Pass); 954 + } 955 + 956 + #[tokio::test] 957 + async fn ac1_2_contract_missing_without_commit_skips_stage() { 958 + let facts = make_identity_facts(None, None); 959 + let tee = FakeCreateReportTee::new(); 960 + let opts = create_report::CreateReportRunOptions { 961 + commit_report: false, 962 + force_self_mint: false, 963 + self_mint_curve: SelfMintCurve::Es256k, 964 + report_subject_override: None, 965 + self_mint_signer: None, 966 + pds_credentials: None, 967 + }; 968 + let results = run_report_stage(&facts, &tee, opts).await; 969 + 970 + assert_eq!(results.len(), 10); 971 + for r in &results { 972 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 973 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 974 + assert_eq!(reason, "labeler does not advertise report acceptance"); 975 + } 976 + } 977 + 978 + #[tokio::test] 979 + async fn ac1_3_contract_missing_with_commit_is_spec_violation() { 980 + let facts = make_identity_facts(None, None); 981 + let tee = FakeCreateReportTee::new(); 982 + let opts = create_report::CreateReportRunOptions { 983 + commit_report: true, 984 + force_self_mint: false, 985 + self_mint_curve: SelfMintCurve::Es256k, 986 + report_subject_override: None, 987 + self_mint_signer: None, 988 + pds_credentials: None, 989 + }; 990 + let results = run_report_stage(&facts, &tee, opts).await; 991 + 992 + assert_eq!(results.len(), 10); 993 + assert_eq!(results[0].id, "report::contract_published"); 994 + assert_eq!(results[0].status, CheckStatus::SpecViolation); 995 + 996 + for r in &results[1..] { 997 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 998 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 999 + assert_eq!(reason, "blocked by `report::contract_published`"); 1000 + } 1001 + } 1002 + 1003 + #[tokio::test] 1004 + async fn ac1_4_empty_arrays_equivalent_to_absent() { 1005 + // Empty Vecs treated the same as None per AC1.4. 1006 + let facts = make_identity_facts(Some(vec![]), Some(vec![])); 1007 + let tee = FakeCreateReportTee::new(); 1008 + let opts = create_report::CreateReportRunOptions { 1009 + commit_report: false, 1010 + force_self_mint: false, 1011 + self_mint_curve: SelfMintCurve::Es256k, 1012 + report_subject_override: None, 1013 + self_mint_signer: None, 1014 + pds_credentials: None, 1015 + }; 1016 + let results = run_report_stage(&facts, &tee, opts).await; 1017 + assert_eq!(results[0].status, CheckStatus::Skipped); 1018 + assert_eq!( 1019 + results[0].skipped_reason.as_deref(), 1020 + Some("labeler does not advertise report acceptance"), 1021 + ); 1022 + } 1023 + 1024 + #[tokio::test] 1025 + async fn ac7_2_row_order_is_stable() { 1026 + let facts = make_identity_facts( 1027 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 1028 + Some(vec!["account".to_string()]), 1029 + ); 1030 + let tee = FakeCreateReportTee::new(); 1031 + let opts = create_report::CreateReportRunOptions { 1032 + commit_report: false, 1033 + force_self_mint: false, 1034 + self_mint_curve: SelfMintCurve::Es256k, 1035 + report_subject_override: None, 1036 + self_mint_signer: None, 1037 + pds_credentials: None, 1038 + }; 1039 + let results = run_report_stage(&facts, &tee, opts).await; 1040 + let ids: Vec<&str> = results.iter().map(|r| r.id).collect(); 1041 + assert_eq!( 1042 + ids, 1043 + vec![ 1044 + "report::contract_published", 1045 + "report::unauthenticated_rejected", 1046 + "report::malformed_bearer_rejected", 1047 + "report::wrong_aud_rejected", 1048 + "report::wrong_lxm_rejected", 1049 + "report::expired_rejected", 1050 + "report::rejected_shape_returns_400", 1051 + "report::self_mint_accepted", 1052 + "report::pds_service_auth_accepted", 1053 + "report::pds_proxied_accepted", 1054 + ], 1055 + ); 1056 + } 1057 + ``` 1058 + 1059 + **Notes for the implementor:** 1060 + 1061 + - The `make_identity_facts` helper above is a complete, runnable fixture-builder. All `IdentityFacts` fields are populated with stable defaults: `did` / `raw_did_doc` / `labeler_endpoint` / `pds_endpoint` / `signing_key_id` / `signing_key_multikey` / `signing_key` / `labeler_record_bytes` / `labeler_policies` / `reason_types` / `subject_types` / `subject_collections` (the last three added by Task 0). 1062 + - `LabelerPolicies` is an `Object<LabelerPoliciesData>` from atrium-api (version 0.25.8). `LabelerPoliciesData` has ONLY `label_value_definitions: Option<...>` and `label_values: Vec<...>` — verified via `cargo read atrium-api --api | grep -A 10 "LabelerPoliciesData"` during planning. The `reason_types`/`subject_types` fields are on the parent `app.bsky.labeler.service::RecordData`, not on `LabelerPolicies`; that's why Task 0 adds them to `IdentityFacts` directly. 1063 + - The four contract × commit snapshot files should be pinned with `insta::assert_snapshot!` after rendering the `LabelerReport` through `RenderConfig { no_color: true }` to get a stable byte-for-byte output. Extend tests to compute the full rendered output and snapshot it — the assertions above are structure checks; the snapshot pins the surface-level display. 1064 + 1065 + **Testing:** 1066 + 1067 + Also add snapshot tests: 1068 + 1069 + ```rust 1070 + async fn render_results_to_string(results: Vec<CheckResult>) -> String { 1071 + // Mirror the pipeline's header population (src/commands/test/labeler/ 1072 + // pipeline.rs:212-216) so snapshot output matches what a real CLI run 1073 + // would produce. The DID / PDS / labeler values match the 1074 + // `make_identity_facts` defaults. 1075 + let mut report = LabelerReport::new(ReportHeader { 1076 + target: "test-labeler".to_string(), 1077 + resolved_did: Some("did:plc:aaa22222222222222222bbbbbb".to_string()), 1078 + pds_endpoint: Some("https://pds.example.com/".to_string()), 1079 + labeler_endpoint: Some("https://labeler.example.com/".to_string()), 1080 + }); 1081 + for r in results { 1082 + report.record(r); 1083 + } 1084 + report.finish(); 1085 + let mut buf = Vec::new(); 1086 + report 1087 + .render(&mut buf, &RenderConfig { no_color: true }) 1088 + .expect("render"); 1089 + String::from_utf8(buf).expect("utf-8") 1090 + } 1091 + 1092 + #[tokio::test] 1093 + async fn snapshot_contract_present_no_commit() { 1094 + let facts = make_identity_facts( 1095 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 1096 + Some(vec!["account".to_string()]), 1097 + ); 1098 + let tee = FakeCreateReportTee::new(); 1099 + let opts = create_report::CreateReportRunOptions { 1100 + commit_report: false, 1101 + force_self_mint: false, 1102 + self_mint_curve: SelfMintCurve::Es256k, 1103 + report_subject_override: None, 1104 + self_mint_signer: None, 1105 + pds_credentials: None, 1106 + }; 1107 + let results = run_report_stage(&facts, &tee, opts).await; 1108 + insta::assert_snapshot!( 1109 + "report_contract_present_no_commit", 1110 + render_results_to_string(results).await 1111 + ); 1112 + } 1113 + 1114 + // And three more: contract_present_with_commit, contract_missing_no_commit, 1115 + // contract_missing_with_commit. Each pins its output. 1116 + ``` 1117 + 1118 + **Verification:** 1119 + Run: `cargo test --test labeler_report` 1120 + Expected: all 8 tests pass (4 assertions + 4 snapshot pins). On first run, `cargo insta review` prompts to accept the snapshots. 1121 + 1122 + Run: `cargo insta review` 1123 + Expected: review and accept the four new snapshot files. 1124 + 1125 + Run: `cargo test` 1126 + Expected: existing tests unaffected. 1127 + 1128 + **Commit:** `feat(pipeline): integrate report stage with AC1 coverage` 1129 + <!-- END_TASK_7 --> 1130 + <!-- END_SUBCOMPONENT_C --> 1131 + 1132 + <!-- START_TASK_8 --> 1133 + ### Task 8: Phase 4 integration check 1134 + 1135 + **Files:** None changed. 1136 + 1137 + **Implementation:** Gate. 1138 + 1139 + **Verification:** 1140 + Run: `cargo build` 1141 + Expected: clean. 1142 + 1143 + Run: `cargo test` 1144 + Expected: all Phase 1-3 tests pass; Phase 4 adds 8+ new tests + 4 snapshots. 1145 + 1146 + Run: `cargo clippy --all-targets -- -D warnings` 1147 + Expected: no warnings. 1148 + 1149 + Run: `cargo insta pending-snapshots` 1150 + Expected: no pending. All Phase 4 snapshots are accepted. 1151 + 1152 + **Commit:** No new commit unless fixes were needed. 1153 + <!-- END_TASK_8 --> 1154 + 1155 + --- 1156 + 1157 + ## Phase 4 complete when 1158 + 1159 + - `Stage::Report` exists with a `label()` entry; stage ordering places Report last. 1160 + - `create_report::Check` enum covers 10 IDs in the canonical order; `ORDER` array + builder helpers compile and work. 1161 + - `ContractMissing` diagnostic carries `code = "labeler::report::contract_missing"`. 1162 + - `LabelerCmd` exposes `--commit-report`, `--self-mint-curve`, `--force-self-mint`, `--report-subject-did`. 1163 + - `LabelerOptions` has the new fields; pipeline wires the report stage after crypto. 1164 + - `create_report::run` emits exactly 10 rows in canonical order for every run (AC7.1 + AC7.2). 1165 + - The four contract × commit combinations each pin an insta snapshot under `tests/snapshots/labeler_report__*`. 1166 + - **Acceptance criteria satisfied:** AC1.1, AC1.2, AC1.3, AC1.4, AC7.1, AC7.2.
+584
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_05.md
··· 1 + # Labeler report stage — Phase 5: No-JWT negative checks 2 + 3 + **Goal:** Turn the first two Phase-4 stub rows (`report::unauthenticated_rejected` and `report::malformed_bearer_rejected`) into real checks. Neither constructs a JWT — they simply POST to the labeler with no `Authorization` header or with an obviously invalid bearer, and verify the labeler rejects with 401 + a non-empty atproto error envelope. 4 + 5 + **Architecture:** Two small helpers inside `create_report.rs`: `parse_xrpc_error_envelope(body) -> Option<XrpcErrorEnvelope>` (loosely permissive, returns `None` rather than failing), and `assert_401_with_envelope(response) -> CheckOutcome` that classifies a `RawCreateReportResponse` as Pass, SpecViolation, or Advisory. The two checks call this helper with different request arguments. 6 + 7 + **Tech Stack:** `serde_json` (existing), the Phase 3 `CreateReportTee` seam, the Phase 4 `Check` enum. 8 + 9 + **Scope:** Phase 5 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 3/4 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Phase 3 `CreateReportTee::post_create_report(auth: Option<&str>, body: &serde_json::Value)` supports `auth: None` (omits Authorization header) and `auth: Some("not-a-jwt")` (sets the raw Bearer string). 15 + - ✓ Phase 4 stage structure: `create_report::run` has 9 "not yet implemented (Phase N)" Skipped stubs after the `contract_published` row; Phase 5 replaces the first two. 16 + - ✓ Phase 4 Check builder at `Check::UnauthenticatedRejected` / `MalformedBearerRejected` supplies `pass` / `spec_violation` / `advisory` shapes with the right summaries. 17 + - ✓ Diagnostic pattern: mirror `HttpDecodeFailure` at `src/commands/test/labeler/http.rs:151-163` — `#[derive(Debug, Error, Diagnostic)]`, `#[source_code]` NamedSource, `#[label]` SourceSpan, `code = "labeler::report::..."`. 18 + - ✓ The stage runs even for non-committing invocations — these two checks don't POST a *committing* report body (no real subject, no sentinel), just an empty/minimal body to exercise the auth path. A POST of `{}` gets rejected by auth before the labeler examines the shape; no pollution risk even on production labelers. 19 + - ⚠ **Pollution consideration:** a malformed-bearer POST to a real labeler is still a POST, though the labeler rejects it at the auth layer before any moderation record is created. These two checks are always safe to POST against a non-committing labeler; no `--commit-report` gate needed. 20 + 21 + **External dependency research findings:** 22 + - ✓ atproto XRPC error envelope per <https://atproto.com/specs/xrpc>: `{"error": "<pascal-case-name>", "message": "<human-readable>"}`. The `error` field is effectively required by the spec for error responses but in practice some implementations omit it (loose conformance). Per design "Error envelope assertion is deliberately loose" — we assert status 401 *alone*, with an Advisory if the envelope is missing or has an empty `error` field. 23 + - ✓ Ozone `@atproto/xrpc-server` emits `AuthenticationRequired` for missing bearer, `BadJwt` or similar for malformed bearer. These are informative but not required — our assertion is on status + envelope presence, not exact string. 24 + 25 + --- 26 + 27 + ## Acceptance criteria coverage 28 + 29 + This phase implements and tests: 30 + 31 + ### labeler-report-stage.AC2: No-JWT negative checks 32 + - **labeler-report-stage.AC2.1 Success:** `unauthenticated_rejected` emits `Pass` when labeler returns 401 with non-empty atproto error envelope for unauthenticated POST. 33 + - **labeler-report-stage.AC2.2 Failure:** `unauthenticated_rejected` emits `SpecViolation` (diagnostic `labeler::report::unauthenticated_accepted`) when labeler returns 2xx. 34 + - **labeler-report-stage.AC2.3 Success:** `malformed_bearer_rejected` emits `Pass` when labeler returns 401 for garbage Bearer. 35 + - **labeler-report-stage.AC2.4 Failure:** `malformed_bearer_rejected` emits `SpecViolation` (diagnostic `labeler::report::malformed_bearer_accepted`) when labeler accepts garbage Bearer. 36 + - **labeler-report-stage.AC2.5 Edge:** 401 with empty or missing `error` envelope field still treated as `Pass` on status alone; summary text notes the non-conformant response shape. 37 + 38 + --- 39 + 40 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 41 + <!-- START_TASK_1 --> 42 + ### Task 1: XRPC error envelope parser and 401 assertion helper 43 + 44 + **Verifies:** Supports AC2.1–AC2.5. 45 + 46 + **Files:** 47 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 48 + 49 + **Implementation:** 50 + 51 + ```rust 52 + use miette::{NamedSource, SourceSpan}; 53 + 54 + use crate::common::diagnostics::{pretty_json_for_display, span_for_quoted_literal}; 55 + 56 + /// A loosely-parsed atproto XRPC error envelope. Missing fields are 57 + /// rendered as `None` rather than failing the parse — the "loose 58 + /// assertion" philosophy in the design (see "Error envelope assertion 59 + /// is deliberately loose"). 60 + #[derive(Debug, Clone)] 61 + pub struct XrpcErrorEnvelope { 62 + /// The `error` field (PascalCase error name). `None` if absent or 63 + /// not a string. 64 + pub error: Option<String>, 65 + /// The `message` field. `None` if absent or not a string. 66 + pub message: Option<String>, 67 + } 68 + 69 + impl XrpcErrorEnvelope { 70 + /// Try to parse an atproto error envelope from the response body. 71 + /// Returns `None` only when the body is not valid JSON at all. 72 + /// Otherwise returns an envelope with whatever fields we could find. 73 + pub fn parse(body: &[u8]) -> Option<Self> { 74 + let v: serde_json::Value = serde_json::from_slice(body).ok()?; 75 + let obj = v.as_object()?; 76 + Some(Self { 77 + error: obj.get("error").and_then(|x| x.as_str()).map(String::from), 78 + message: obj.get("message").and_then(|x| x.as_str()).map(String::from), 79 + }) 80 + } 81 + 82 + /// `true` when the envelope has a non-empty `error` string. 83 + pub fn has_nonempty_error(&self) -> bool { 84 + self.error.as_deref().map(|s| !s.is_empty()).unwrap_or(false) 85 + } 86 + } 87 + 88 + /// Outcome of the 401-envelope assertion. 89 + pub enum RejectionShape { 90 + /// 401 with a non-empty `error` field — full-conformant. 91 + Conformant { 92 + /// The envelope for diagnostic rendering. 93 + envelope: XrpcErrorEnvelope, 94 + }, 95 + /// 401 but the envelope is missing or has an empty `error` field. 96 + /// Treated as Pass on status alone per AC2.5 but the summary 97 + /// notes the non-conformant response shape. 98 + ConformantStatusNonConformantShape, 99 + /// Any non-401 status. 100 + WrongStatus { 101 + /// The observed status code. 102 + status: reqwest::StatusCode, 103 + }, 104 + } 105 + 106 + impl RejectionShape { 107 + /// Classify a createReport response against the 401-envelope rubric. 108 + pub fn classify(resp: &RawCreateReportResponse) -> Self { 109 + if resp.status != reqwest::StatusCode::UNAUTHORIZED { 110 + return Self::WrongStatus { status: resp.status }; 111 + } 112 + match XrpcErrorEnvelope::parse(&resp.raw_body) { 113 + Some(env) if env.has_nonempty_error() => Self::Conformant { envelope: env }, 114 + _ => Self::ConformantStatusNonConformantShape, 115 + } 116 + } 117 + } 118 + 119 + /// Diagnostic surfaced when a labeler accepts (status 2xx) a request 120 + /// that should have been rejected. Carries the response body so users 121 + /// can see what the labeler returned instead. 122 + #[derive(Debug, Error, Diagnostic)] 123 + #[error("Labeler accepted a request that should have been rejected: {status_line}")] 124 + pub struct UnexpectedAcceptance { 125 + /// Status code line, e.g., "200 OK". 126 + pub status_line: String, 127 + /// Response body as a named source for miette rendering. 128 + #[source_code] 129 + pub body: NamedSource<Arc<[u8]>>, 130 + /// Span of the response body (empty span; the whole body is the context). 131 + #[label("labeler accepted here")] 132 + pub span: Option<SourceSpan>, 133 + } 134 + 135 + /// Construct an `UnexpectedAcceptance` diagnostic with the given stable 136 + /// miette code (so each check gets its own code per the DoD). 137 + pub fn unexpected_acceptance( 138 + diagnostic_code: &'static str, 139 + resp: &RawCreateReportResponse, 140 + ) -> Box<dyn miette::Diagnostic + Send + Sync> { 141 + let pretty_body = pretty_json_for_display(&resp.raw_body); 142 + Box::new(UnexpectedAcceptanceWithCode { 143 + code: diagnostic_code, 144 + status_line: format!("{} {}", resp.status.as_u16(), resp.status.canonical_reason().unwrap_or("")), 145 + source_code: NamedSource::new(resp.source_url.clone(), Arc::from(pretty_body)), 146 + }) 147 + } 148 + 149 + /// Internal shim that lets us parameterize the miette `code` per-check 150 + /// without needing one struct per check. `code` is assigned dynamically 151 + /// via `with_code`. 152 + /// 153 + /// NOTE: miette's Diagnostic trait derives use a static code in the 154 + /// `#[diagnostic(code = ...)]` attribute. To let each check emit its own 155 + /// stable code, define two separate tiny diagnostic structs — one per 156 + /// check ID — rather than a single dynamic one. See Task 2 below. 157 + #[derive(Debug, Error)] 158 + #[error("{status_line}")] 159 + struct UnexpectedAcceptanceWithCode { 160 + code: &'static str, 161 + status_line: String, 162 + source_code: NamedSource<Arc<[u8]>>, 163 + } 164 + 165 + // This shim is replaced by two per-check diagnostic structs in Task 2; 166 + // the Box<dyn Diagnostic> pattern above is retained as a helper signature 167 + // but the concrete type is supplied by the caller. 168 + ``` 169 + 170 + **Notes for the implementor:** 171 + 172 + The `UnexpectedAcceptanceWithCode` shim above is a red herring — miette `#[diagnostic(code = ...)]` is static. The right pattern is one concrete diagnostic struct per stable code. Task 2 defines two of them (one for `unauthenticated_accepted`, one for `malformed_bearer_accepted`). Delete the shim from the above block during implementation; keep `XrpcErrorEnvelope`, `RejectionShape`, and omit `UnexpectedAcceptance` + `unexpected_acceptance`. 173 + 174 + **Testing:** 175 + 176 + Unit tests in `create_report.rs`: 177 + 178 + ```rust 179 + #[cfg(test)] 180 + mod envelope_tests { 181 + use super::*; 182 + 183 + #[test] 184 + fn parse_well_formed_envelope() { 185 + let body = br#"{"error":"BadJwt","message":"invalid token"}"#; 186 + let env = XrpcErrorEnvelope::parse(body).expect("parses"); 187 + assert_eq!(env.error.as_deref(), Some("BadJwt")); 188 + assert_eq!(env.message.as_deref(), Some("invalid token")); 189 + assert!(env.has_nonempty_error()); 190 + } 191 + 192 + #[test] 193 + fn parse_empty_envelope() { 194 + let body = br#"{}"#; 195 + let env = XrpcErrorEnvelope::parse(body).expect("parses empty object"); 196 + assert_eq!(env.error, None); 197 + assert!(!env.has_nonempty_error()); 198 + } 199 + 200 + #[test] 201 + fn parse_non_json_returns_none() { 202 + assert!(XrpcErrorEnvelope::parse(b"<html>").is_none()); 203 + } 204 + 205 + #[test] 206 + fn parse_empty_error_field_treated_as_missing() { 207 + let body = br#"{"error":""}"#; 208 + let env = XrpcErrorEnvelope::parse(body).unwrap(); 209 + assert!(!env.has_nonempty_error()); 210 + } 211 + } 212 + ``` 213 + 214 + **Verification:** 215 + Run: `cargo test --lib commands::test::labeler::create_report::envelope_tests` 216 + Expected: all pass. 217 + 218 + **Commit:** `feat(create_report): XRPC error envelope parser` 219 + <!-- END_TASK_1 --> 220 + 221 + <!-- START_TASK_2 --> 222 + ### Task 2: Per-check diagnostics for `unauthenticated_accepted` and `malformed_bearer_accepted` 223 + 224 + **Verifies:** AC2.2, AC2.4. 225 + 226 + **Files:** 227 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 228 + 229 + **Implementation:** 230 + 231 + ```rust 232 + /// Diagnostic for AC2.2: labeler accepted an unauthenticated createReport POST. 233 + #[derive(Debug, Error, Diagnostic)] 234 + #[error("Labeler accepted unauthenticated createReport (status {status})")] 235 + #[diagnostic( 236 + code = "labeler::report::unauthenticated_accepted", 237 + help = "A labeler must reject createReport with 401 when no Authorization header is supplied." 238 + )] 239 + pub struct UnauthenticatedAccepted { 240 + /// Observed status code, e.g., 200. 241 + pub status: u16, 242 + /// Response body for context. 243 + #[source_code] 244 + pub source_code: NamedSource<Arc<[u8]>>, 245 + /// Pseudo-span over the whole body. 246 + #[label("accepted here")] 247 + pub span: Option<SourceSpan>, 248 + } 249 + 250 + /// Diagnostic for AC2.4: labeler accepted a malformed bearer token. 251 + #[derive(Debug, Error, Diagnostic)] 252 + #[error("Labeler accepted malformed Bearer token (status {status})")] 253 + #[diagnostic( 254 + code = "labeler::report::malformed_bearer_accepted", 255 + help = "A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string." 256 + )] 257 + pub struct MalformedBearerAccepted { 258 + /// Observed status code, e.g., 200. 259 + pub status: u16, 260 + /// Response body for context. 261 + #[source_code] 262 + pub source_code: NamedSource<Arc<[u8]>>, 263 + /// Pseudo-span over the whole body. 264 + #[label("accepted here")] 265 + pub span: Option<SourceSpan>, 266 + } 267 + 268 + /// Construct a `NamedSource` from the pretty-printed response body. 269 + /// Used for every `accepted_*` diagnostic here. 270 + fn body_as_named_source(resp: &RawCreateReportResponse) -> NamedSource<Arc<[u8]>> { 271 + let pretty = pretty_json_for_display(&resp.raw_body); 272 + NamedSource::new(resp.source_url.clone(), Arc::from(pretty)) 273 + } 274 + ``` 275 + 276 + **Notes for the implementor:** 277 + - The `help` field on `#[diagnostic]` is rendered by miette after the error message. Keep them to one informative sentence. 278 + - Both diagnostic structs have nearly identical shape — if a refactor helper (e.g., a generic `RejectionBodyDiagnostic<const CODE: &str>`) makes sense later, defer it. Copy-paste is fine for v1. 279 + 280 + **Testing:** Exercised via the integration tests in Task 3. 281 + 282 + **Verification:** `cargo build` clean. 283 + 284 + **Commit:** `feat(create_report): diagnostics for AC2.2/AC2.4 acceptance` 285 + <!-- END_TASK_2 --> 286 + <!-- END_SUBCOMPONENT_A --> 287 + 288 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 289 + <!-- START_TASK_3 --> 290 + ### Task 3: Wire the `unauthenticated_rejected` check 291 + 292 + **Verifies:** AC2.1, AC2.2, AC2.5. 293 + 294 + **Files:** 295 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase-4 `Check::UnauthenticatedRejected` stub emit in `run(...)` with real logic. 296 + 297 + **Implementation:** 298 + 299 + Replace this block in `run()`: 300 + 301 + ```rust 302 + for c in [Check::UnauthenticatedRejected, Check::MalformedBearerRejected] { 303 + results.push(c.skip("not yet implemented (Phase 5)")); 304 + } 305 + ``` 306 + 307 + with a full implementation for both checks: 308 + 309 + ```rust 310 + // Minimal body for negative checks. The labeler should reject at auth 311 + // before examining body shape; we nonetheless supply a plausible body so 312 + // a labeler that performs body validation first doesn't return 400 313 + // instead of 401, which would make the test ambiguous. 314 + let negative_body = build_minimal_report_body(id_facts); 315 + 316 + // AC2.1/AC2.2/AC2.5 — unauthenticated: 317 + match report_tee.post_create_report(None, &negative_body).await { 318 + Ok(resp) => match RejectionShape::classify(&resp) { 319 + RejectionShape::Conformant { .. } => { 320 + results.push(Check::UnauthenticatedRejected.pass()); 321 + } 322 + RejectionShape::ConformantStatusNonConformantShape => { 323 + results.push(CheckResult { 324 + summary: Cow::Borrowed( 325 + "Unauthenticated report rejected (status 401, non-conformant envelope)", 326 + ), 327 + ..Check::UnauthenticatedRejected.pass() 328 + }); 329 + } 330 + RejectionShape::WrongStatus { status } => { 331 + let status_u16 = status.as_u16(); 332 + let diag = Box::new(UnauthenticatedAccepted { 333 + status: status_u16, 334 + source_code: body_as_named_source(&resp), 335 + span: None, 336 + }); 337 + results.push(Check::UnauthenticatedRejected.spec_violation(Some(diag))); 338 + } 339 + }, 340 + Err(CreateReportStageError::Transport { message, .. }) => { 341 + results.push(Check::UnauthenticatedRejected.network_error(message)); 342 + } 343 + } 344 + 345 + // AC2.3/AC2.4 — malformed bearer: 346 + match report_tee 347 + .post_create_report(Some("not-a-jwt"), &negative_body) 348 + .await 349 + { 350 + Ok(resp) => match RejectionShape::classify(&resp) { 351 + RejectionShape::Conformant { .. } => { 352 + results.push(Check::MalformedBearerRejected.pass()); 353 + } 354 + RejectionShape::ConformantStatusNonConformantShape => { 355 + results.push(CheckResult { 356 + summary: Cow::Borrowed( 357 + "Malformed bearer rejected (status 401, non-conformant envelope)", 358 + ), 359 + ..Check::MalformedBearerRejected.pass() 360 + }); 361 + } 362 + RejectionShape::WrongStatus { status } => { 363 + let status_u16 = status.as_u16(); 364 + let diag = Box::new(MalformedBearerAccepted { 365 + status: status_u16, 366 + source_code: body_as_named_source(&resp), 367 + span: None, 368 + }); 369 + results.push(Check::MalformedBearerRejected.spec_violation(Some(diag))); 370 + } 371 + }, 372 + Err(CreateReportStageError::Transport { message, .. }) => { 373 + results.push(Check::MalformedBearerRejected.network_error(message)); 374 + } 375 + } 376 + 377 + // NOTE: the following helper lives at module scope in 378 + // src/commands/test/labeler/create_report.rs (NOT nested inside `run()`), 379 + // so Phases 6 and 7 can invoke it from their own branches of `run()`. 380 + // It reads the Phase 4 Task 0 extended `IdentityFacts` fields 381 + // (`id_facts.reason_types` / `.subject_types` / `.did`) directly, not 382 + // through `labeler_policies`. 383 + 384 + /// Build a minimal, plausible createReport body for negative tests. 385 + /// 386 + /// Chooses the lex-first advertised `reasonType` and the first advertised 387 + /// `subjectType`, pointing at a safe subject (the labeler's own DID — 388 + /// labelers never take action on themselves). The body is well-formed so 389 + /// any validation short-circuit returns auth-layer rejection rather than 390 + /// shape-layer rejection. 391 + pub(crate) fn build_minimal_report_body(facts: &IdentityFacts) -> serde_json::Value { 392 + // Unwrap the contract — run() has already guaranteed it's present 393 + // and non-empty before this function is reachable. 394 + let reason_type = facts 395 + .reason_types 396 + .as_ref() 397 + .and_then(|v| v.first()) 398 + .cloned() 399 + .unwrap_or_else(|| "com.atproto.moderation.defs#reasonOther".to_string()); 400 + 401 + let subject_types: &[String] = facts 402 + .subject_types 403 + .as_ref() 404 + .map(|v| v.as_slice()) 405 + .unwrap_or(&[]); 406 + let subject = if subject_types.iter().any(|t| t == "account") { 407 + serde_json::json!({ 408 + "$type": "com.atproto.admin.defs#repoRef", 409 + "did": facts.did.0, 410 + }) 411 + } else if subject_types.iter().any(|t| t == "record") { 412 + serde_json::json!({ 413 + "$type": "com.atproto.repo.strongRef", 414 + // Ghost AT-URI targeting the labeler's own DID. Negative-path 415 + // only; positive paths use real pollution-avoidance logic in 416 + // Phase 7. 417 + "uri": format!("at://{}/app.bsky.feed.post/not-real", facts.did.0), 418 + "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 419 + }) 420 + } else { 421 + // Fallback: account shape against the labeler itself. 422 + serde_json::json!({ 423 + "$type": "com.atproto.admin.defs#repoRef", 424 + "did": facts.did.0, 425 + }) 426 + }; 427 + 428 + serde_json::json!({ 429 + "reasonType": reason_type, 430 + "subject": subject, 431 + }) 432 + } 433 + ``` 434 + 435 + **Notes for the implementor:** 436 + - Use `Cow::Borrowed` for the AC2.5 non-conformant-shape summary strings (they're `'static`). The `CheckResult { summary: ..., ..Check::X.pass() }` struct-update pattern copies everything else from the builder. 437 + - `IdentityFacts::did` is the labeler's DID. Verify the exact field name by grepping `src/commands/test/labeler/identity.rs` for `pub struct IdentityFacts` — the design assumes `facts.did` but codebase may use `facts.labeler_did` or similar. If different, adjust `build_minimal_report_body` accordingly. 438 + - CID placeholder `bafyrei...` is a syntactically valid but unresolvable CID. It's only used in negative checks where the labeler rejects at auth; the CID is never looked up. 439 + 440 + **Testing:** 441 + 442 + Extend `tests/labeler_report.rs` with AC2-specific tests. Mirror the Phase-4 structure; add new test cases: 443 + 444 + ```rust 445 + #[tokio::test] 446 + async fn ac2_1_unauthenticated_401_with_envelope_passes() { 447 + let facts = make_identity_facts( 448 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 449 + Some(vec!["account".to_string()]), 450 + ); 451 + let tee = FakeCreateReportTee::new(); 452 + tee.enqueue(FakeCreateReportResponse::unauthorized( 453 + "AuthenticationRequired", 454 + "jwt required", 455 + )); 456 + tee.enqueue(FakeCreateReportResponse::unauthorized( 457 + "BadJwt", 458 + "invalid bearer", 459 + )); 460 + let results = run_report_stage(&facts, &tee, default_opts()).await; 461 + 462 + assert_eq!(results[1].id, "report::unauthenticated_rejected"); 463 + assert_eq!(results[1].status, CheckStatus::Pass); 464 + assert_eq!(results[2].id, "report::malformed_bearer_rejected"); 465 + assert_eq!(results[2].status, CheckStatus::Pass); 466 + } 467 + 468 + #[tokio::test] 469 + async fn ac2_2_unauthenticated_200_is_spec_violation() { 470 + let facts = make_identity_facts( 471 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 472 + Some(vec!["account".to_string()]), 473 + ); 474 + let tee = FakeCreateReportTee::new(); 475 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 476 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwt", "x")); 477 + let results = run_report_stage(&facts, &tee, default_opts()).await; 478 + 479 + assert_eq!(results[1].status, CheckStatus::SpecViolation); 480 + let diag = results[1].diagnostic.as_ref().expect("diagnostic present"); 481 + assert_eq!( 482 + diag.code().map(|c| c.to_string()), 483 + Some("labeler::report::unauthenticated_accepted".to_string()), 484 + ); 485 + } 486 + 487 + #[tokio::test] 488 + async fn ac2_4_malformed_bearer_200_is_spec_violation() { 489 + let facts = make_identity_facts( 490 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 491 + Some(vec!["account".to_string()]), 492 + ); 493 + let tee = FakeCreateReportTee::new(); 494 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 495 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 496 + let results = run_report_stage(&facts, &tee, default_opts()).await; 497 + 498 + assert_eq!(results[2].status, CheckStatus::SpecViolation); 499 + let diag = results[2].diagnostic.as_ref().expect("diagnostic present"); 500 + assert_eq!( 501 + diag.code().map(|c| c.to_string()), 502 + Some("labeler::report::malformed_bearer_accepted".to_string()), 503 + ); 504 + } 505 + 506 + #[tokio::test] 507 + async fn ac2_5_401_without_envelope_still_passes() { 508 + let facts = make_identity_facts( 509 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 510 + Some(vec!["account".to_string()]), 511 + ); 512 + let tee = FakeCreateReportTee::new(); 513 + // 401 with empty body — non-conformant envelope, but status still Pass per AC2.5. 514 + tee.enqueue(FakeCreateReportResponse::Response { 515 + status: 401, 516 + content_type: Some("application/json".to_string()), 517 + body: b"{}".to_vec(), 518 + }); 519 + tee.enqueue(FakeCreateReportResponse::Response { 520 + status: 401, 521 + content_type: None, 522 + body: b"<html>".to_vec(), 523 + }); 524 + let results = run_report_stage(&facts, &tee, default_opts()).await; 525 + 526 + assert_eq!(results[1].status, CheckStatus::Pass); 527 + assert!(results[1].summary.contains("non-conformant envelope")); 528 + assert_eq!(results[2].status, CheckStatus::Pass); 529 + assert!(results[2].summary.contains("non-conformant envelope")); 530 + } 531 + 532 + fn default_opts() -> create_report::CreateReportRunOptions<'static> { 533 + create_report::CreateReportRunOptions { 534 + commit_report: false, 535 + force_self_mint: false, 536 + self_mint_curve: SelfMintCurve::Es256k, 537 + report_subject_override: None, 538 + self_mint_signer: None, 539 + pds_credentials: None, 540 + } 541 + } 542 + ``` 543 + 544 + **Notes for the implementor:** 545 + - `default_opts()` is shared by AC2 and later ACs; extract it to the top of `tests/labeler_report.rs` to avoid duplication. 546 + - Add snapshot fixtures under `tests/fixtures/labeler/report/` as needed for each case — but AC2 doesn't need raw fixture JSON; the fakes hand-encode envelope bytes in test bodies, which is fine. 547 + 548 + **Verification:** 549 + Run: `cargo test --test labeler_report ac2_` 550 + Expected: all four AC2 tests pass. 551 + 552 + Run: `cargo insta pending-snapshots` 553 + Expected: none pending. 554 + 555 + **Commit:** `feat(create_report): wire unauthenticated_rejected and malformed_bearer_rejected` 556 + <!-- END_TASK_3 --> 557 + 558 + <!-- START_TASK_4 --> 559 + ### Task 4: Phase 5 integration check 560 + 561 + **Files:** None changed. 562 + 563 + **Implementation:** Gate. 564 + 565 + **Verification:** 566 + Run: `cargo test` 567 + Expected: all Phase 1-4 tests pass; Phase 5 adds 4+ new passing tests (AC2.1-AC2.5). 568 + 569 + Run: `cargo clippy --all-targets -- -D warnings` 570 + Expected: no warnings. 571 + 572 + **Commit:** No new commit unless fixes were needed. 573 + <!-- END_TASK_4 --> 574 + 575 + --- 576 + 577 + ## Phase 5 complete when 578 + 579 + - `XrpcErrorEnvelope::parse` handles well-formed, partial, and non-JSON bodies. 580 + - `RejectionShape::classify` distinguishes Conformant / ConformantStatusNonConformantShape / WrongStatus correctly. 581 + - `UnauthenticatedAccepted` and `MalformedBearerAccepted` diagnostics compile with the correct stable codes. 582 + - `report::unauthenticated_rejected` and `report::malformed_bearer_rejected` rows are real (no longer `Skipped` stubs). 583 + - Integration tests cover AC2.1, AC2.2, AC2.3, AC2.4, and AC2.5. 584 + - **Acceptance criteria satisfied:** AC2.1, AC2.2, AC2.3, AC2.4, AC2.5.
+607
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_06.md
··· 1 + # Labeler report stage — Phase 6: Self-mint negative checks 2 + 3 + **Goal:** Implement the four checks that require a valid-signature JWT with a specific claim or body mutation: `report::wrong_aud_rejected`, `report::wrong_lxm_rejected`, `report::expired_rejected`, and `report::rejected_shape_returns_400`. Gate all four on `self_mint_viable` (the Phase 1 heuristic + `--force-self-mint` override). 4 + 5 + **Architecture:** The stage, when `self_mint_viable` is true and a `SelfMintSigner` is present, lazily constructs JWTs from a valid-claims template with one field mutated per check. Each POST goes through the Phase 3 `CreateReportTee` with the mutated JWT in the `Authorization` header. Response classification reuses `RejectionShape` from Phase 5; `rejected_shape_returns_400` uses a different rubric (400 + `error == "InvalidRequest"` → Pass; 401/500 → Advisory). 6 + 7 + **Tech Stack:** Phase 1 `AnySigningKey`+ `jwt` module, Phase 2 `SelfMintSigner`, Phase 5 response classification. 8 + 9 + **Scope:** Phase 6 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 1-5 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ Phase 1 `JwtClaims` struct has mutable `iss`, `aud`, `exp`, `iat`, `lxm`, `jti` — sufficient to express all three claim mutations. 15 + - ✓ Phase 2 `SelfMintSigner::valid_claims_template(labeler_did, lxm, now, exp_after)` produces a fresh template whose `jti` is already a random nonce — mutations apply on top of this. 16 + - ✓ Phase 5 `RejectionShape::classify` returns `Conformant | ConformantStatusNonConformantShape | WrongStatus`. For AC3 it's the exact same rubric as AC2 — 401 + non-empty envelope is Pass, non-401 is SpecViolation. 17 + - ✓ Phase 4 `Check::RejectedShapeReturns400` builder has a distinct `advisory` helper for AC3.6 (400 with wrong status shape). 18 + - ✓ `Check::skip(reason)` handles the `self_mint_viable == false` case for all four. 19 + - ✓ `LabelerCmd::run` must, in Phase 6 or earlier, construct a `SelfMintSigner` when `self_mint_viable` AND (any self-mint check might run). Phase 4 left `self_mint_signer: None` in production; Phase 6 populates it. 20 + - ✓ `SystemTime::now()` + `UNIX_EPOCH` is available (no chrono dep); Phase 1's sentinel builder already uses it. Convert to i64 seconds for JWT claims. 21 + 22 + **External dependency research findings:** 23 + - ✓ Ozone JWT claim-validation order per `packages/xrpc-server/src/auth.ts` (inferred): signature → aud → exp → lxm → iat. Our tests don't depend on order; each check mutates exactly one field so the rejection is unambiguous. 24 + - ✓ "wrong aud" constant: `did:plc:0000000000000000000000000` is the atproto convention for a nonexistent-but-syntactically-valid did:plc. Using a bogus but well-formed DID avoids triggering "malformed aud" error paths. 25 + - ✓ "wrong lxm" substitute: `com.atproto.server.getSession` — an arbitrary but valid atproto NSID that is NOT `createReport`. Easy to read in diagnostic output. 26 + 27 + --- 28 + 29 + ## Acceptance criteria coverage 30 + 31 + This phase implements and tests: 32 + 33 + ### labeler-report-stage.AC3: Self-mint negative checks 34 + - **labeler-report-stage.AC3.1 Success:** `wrong_aud_rejected` emits `Pass` when labeler returns 401 for fresh-signed JWT with mutated `aud`. 35 + - **labeler-report-stage.AC3.2 Failure:** emits `SpecViolation` (diagnostic `labeler::report::wrong_aud_accepted`) when labeler returns 2xx for wrong aud. 36 + - **labeler-report-stage.AC3.3 Success/Failure pair:** `wrong_lxm_rejected` behaves analogously for mutated `lxm`; diagnostic `labeler::report::wrong_lxm_accepted`. 37 + - **labeler-report-stage.AC3.4 Success/Failure pair:** `expired_rejected` behaves analogously for past-expiry JWT; diagnostic `labeler::report::expired_accepted`. 38 + - **labeler-report-stage.AC3.5 Success:** `rejected_shape_returns_400` emits `Pass` when labeler returns 400 `InvalidRequest` for unadvertised `reasonType`. 39 + - **labeler-report-stage.AC3.6 Advisory:** `rejected_shape_returns_400` emits `Advisory` (diagnostic `labeler::report::shape_not_400`) when labeler returns 401 or 500 (rejection for wrong reason). 40 + - **labeler-report-stage.AC3.7 Skip:** Every self-mint negative check emits `Skipped` with reason naming the `--force-self-mint` override when heuristic classifies labeler non-local. 41 + - **labeler-report-stage.AC3.8 Override:** `--force-self-mint` bypasses the heuristic; all self-mint checks run regardless of hostname. 42 + 43 + --- 44 + 45 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 46 + <!-- START_TASK_1 --> 47 + ### Task 1: Per-check diagnostics for the self-mint negative family 48 + 49 + **Verifies:** AC3.2, AC3.3-failure, AC3.4-failure, AC3.6. 50 + 51 + **Files:** 52 + - Modify: `src/commands/test/labeler/create_report.rs` — append four diagnostic structs. 53 + 54 + **Implementation:** 55 + 56 + ```rust 57 + #[derive(Debug, Error, Diagnostic)] 58 + #[error("Labeler accepted JWT with wrong `aud` (status {status})")] 59 + #[diagnostic( 60 + code = "labeler::report::wrong_aud_accepted", 61 + help = "A labeler must reject JWTs whose `aud` claim does not match its own DID." 62 + )] 63 + pub struct WrongAudAccepted { 64 + pub status: u16, 65 + #[source_code] 66 + pub source_code: NamedSource<Arc<[u8]>>, 67 + #[label("accepted here")] 68 + pub span: Option<SourceSpan>, 69 + } 70 + 71 + #[derive(Debug, Error, Diagnostic)] 72 + #[error("Labeler accepted JWT with wrong `lxm` (status {status})")] 73 + #[diagnostic( 74 + code = "labeler::report::wrong_lxm_accepted", 75 + help = "A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method." 76 + )] 77 + pub struct WrongLxmAccepted { 78 + pub status: u16, 79 + #[source_code] 80 + pub source_code: NamedSource<Arc<[u8]>>, 81 + #[label("accepted here")] 82 + pub span: Option<SourceSpan>, 83 + } 84 + 85 + #[derive(Debug, Error, Diagnostic)] 86 + #[error("Labeler accepted expired JWT (status {status})")] 87 + #[diagnostic( 88 + code = "labeler::report::expired_accepted", 89 + help = "A labeler must reject JWTs whose `exp` claim is in the past." 90 + )] 91 + pub struct ExpiredAccepted { 92 + pub status: u16, 93 + #[source_code] 94 + pub source_code: NamedSource<Arc<[u8]>>, 95 + #[label("accepted here")] 96 + pub span: Option<SourceSpan>, 97 + } 98 + 99 + #[derive(Debug, Error, Diagnostic)] 100 + #[error("Unadvertised `reasonType` was rejected with status {status}, expected 400 InvalidRequest")] 101 + #[diagnostic( 102 + code = "labeler::report::shape_not_400", 103 + help = "A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes." 104 + )] 105 + pub struct ShapeNot400 { 106 + pub status: u16, 107 + pub error_name: Option<String>, 108 + #[source_code] 109 + pub source_code: NamedSource<Arc<[u8]>>, 110 + #[label("rejected with wrong status here")] 111 + pub span: Option<SourceSpan>, 112 + } 113 + ``` 114 + 115 + **Testing:** Exercised indirectly via Task 3 integration tests. 116 + 117 + **Verification:** `cargo build` clean. 118 + 119 + **Commit:** `feat(create_report): diagnostics for AC3 self-mint negative checks` 120 + <!-- END_TASK_1 --> 121 + 122 + <!-- START_TASK_2 --> 123 + ### Task 2: Populate `self_mint_signer` in `LabelerCmd::run` when viable 124 + 125 + **Verifies:** AC3.7, AC3.8, AC8.2. 126 + 127 + **Files:** 128 + - Modify: `src/commands/test/labeler.rs` — in `LabelerCmd::run`, compute `self_mint_viable` against the parsed target's endpoint, construct a `SelfMintSigner` when appropriate, and thread it through `LabelerOptions`. 129 + 130 + **Implementation:** 131 + 132 + ```rust 133 + // After the `target` is parsed in LabelerCmd::run, derive the labeler 134 + // endpoint (if available) for the self-mint-viability heuristic. When the 135 + // target is a DID or handle, we don't know the endpoint yet — identity 136 + // stage will discover it. For the self-mint signer construction we need 137 + // the endpoint now; in that case, we construct the signer pessimistically 138 + // (the stage still decides viability via the real heuristic) ONLY when 139 + // --force-self-mint is set. Otherwise we defer. 140 + 141 + use crate::commands::test::labeler::create_report::self_mint::SelfMintSigner; 142 + use crate::common::identity::is_local_labeler_hostname; 143 + 144 + // Determine tentative endpoint for the locality check. 145 + let tentative_endpoint: Option<Url> = match &target { 146 + pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()), 147 + // Identified targets: endpoint known only after identity stage. 148 + // For self-mint-viable decisions the stage will re-check using 149 + // the actual endpoint; here we only pre-bind the signer if the 150 + // user forced it. 151 + pipeline::LabelerTarget::Identified { .. } => None, 152 + }; 153 + 154 + let tentative_local = tentative_endpoint 155 + .as_ref() 156 + .map(is_local_labeler_hostname) 157 + .unwrap_or(false); 158 + 159 + // Pre-construct the self-mint signer (binds the DidDocServer) when: 160 + // - --force-self-mint is set, OR 161 + // - tentative endpoint is known and classified local. 162 + // Otherwise we skip the allocation and let the stage see 163 + // `self_mint_signer == None` → skip all self-mint checks. 164 + let self_mint_signer_opt = if self.force_self_mint || tentative_local { 165 + Some( 166 + SelfMintSigner::spawn(self.self_mint_curve) 167 + .await 168 + .map_err(|e| miette::miette!("Failed to bind self-mint DID server: {e}"))?, 169 + ) 170 + } else { 171 + None 172 + }; 173 + let self_mint_signer_ref = self_mint_signer_opt.as_ref(); 174 + 175 + // ...construct LabelerOptions with self_mint_signer: self_mint_signer_ref. 176 + ``` 177 + 178 + **Notes for the implementor:** 179 + - The signer construction is async because `DidDocServer::spawn` is async. Ensure `LabelerCmd::run` is itself async (it is). 180 + - Hold the `SelfMintSigner` in a local variable in `LabelerCmd::run` so the `DidDocServer` stays alive for the entire pipeline run. The `_self_mint_signer_opt` binding dropping at the end of `run` matches the server's lifetime to the run's lifetime. 181 + - When the target is Identified (handle/DID), the stage re-checks viability using the actual endpoint discovered by identity. If the stage decides self-mint is NOT viable in that case, all four negative checks Skipped. The pre-allocated signer is wasted but not incorrect. 182 + 183 + **Testing:** 184 + 185 + Add `tests/labeler_cli.rs` CLI flag tests: 186 + - `--self-mint-curve es256` + `--help` prints the expected flag value. 187 + - `--force-self-mint` without arguments is accepted. 188 + 189 + For AC3.8 (override) — covered via a runtime test in Phase 7 or 8 with a non-local endpoint + `--force-self-mint=true`. For Phase 6, add a test that exercises the stage directly with `self_mint_viable = false` AND `self_mint_signer = Some(...)` but `force_self_mint = true` to prove the override flag takes precedence inside the stage (not just in CLI parsing). 190 + 191 + **Verification:** 192 + Run: `cargo build` 193 + Expected: clean. 194 + 195 + Run: `cargo test --test labeler_cli` 196 + Expected: existing + new tests pass. 197 + 198 + **Commit:** `feat(labeler): construct SelfMintSigner based on locality + --force-self-mint` 199 + <!-- END_TASK_2 --> 200 + <!-- END_SUBCOMPONENT_A --> 201 + 202 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 203 + <!-- START_TASK_3 --> 204 + ### Task 3: Implement the four self-mint negative checks 205 + 206 + **Verifies:** AC3.1–AC3.6. 207 + 208 + **Files:** 209 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase-4 "Phase 6" stubs with full implementations. 210 + 211 + **Implementation:** 212 + 213 + Replace this block in `run()`: 214 + 215 + ```rust 216 + for c in [ 217 + Check::WrongAudRejected, 218 + Check::WrongLxmRejected, 219 + Check::ExpiredRejected, 220 + Check::RejectedShapeReturns400, 221 + ] { 222 + results.push(c.skip("not yet implemented (Phase 6)")); 223 + } 224 + ``` 225 + 226 + with real logic: 227 + 228 + ```rust 229 + // Recompute self_mint_viable using the *actual* labeler endpoint now 230 + // that identity has run. 231 + let self_mint_viable = opts.force_self_mint 232 + || crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint); 233 + 234 + let signer_for_negative = if self_mint_viable { 235 + opts.self_mint_signer 236 + } else { 237 + None 238 + }; 239 + 240 + // CRITICAL: this block either emits 4 Skipped rows OR emits 4 real-check 241 + // rows, then falls through to Phase 7/8 logic for SelfMintAccepted, 242 + // PdsServiceAuthAccepted, PdsProxiedAccepted. Do NOT `return` here — the 243 + // stage always emits 10 rows total, and the later checks need to run 244 + // regardless of self-mint viability. 245 + if let Some(signer) = signer_for_negative { 246 + // Mint per-check tokens from the valid-claims template. All four 247 + // checks share the same `now`, `lxm`, and `template`. Each check 248 + // inlines its own `match` rather than using a shared helper — 249 + // nested `async fn` is unsupported in stable Rust, and a closure 250 + // returning `Box<dyn Diagnostic>` across `await` would force `Send` 251 + // bounds that complicate the call site. 252 + let now = std::time::SystemTime::now() 253 + .duration_since(std::time::UNIX_EPOCH) 254 + .map(|d| d.as_secs() as i64) 255 + .unwrap_or(0); 256 + let lxm = "com.atproto.moderation.createReport"; 257 + let template = signer.valid_claims_template( 258 + &id_facts.did, 259 + lxm, 260 + now, 261 + std::time::Duration::from_secs(60), 262 + ); 263 + 264 + let negative_body = build_minimal_report_body(id_facts); 265 + 266 + // AC3.1/AC3.2 — wrong aud: 267 + { 268 + let mut claims = template.clone(); 269 + claims.aud = "did:plc:0000000000000000000000000".to_string(); 270 + let token = signer.sign_jwt(claims); 271 + match report_tee.post_create_report(Some(&token), &negative_body).await { 272 + Ok(resp) => match RejectionShape::classify(&resp) { 273 + RejectionShape::Conformant { .. } => results.push(Check::WrongAudRejected.pass()), 274 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 275 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 276 + ..Check::WrongAudRejected.pass() 277 + }), 278 + RejectionShape::WrongStatus { .. } => { 279 + let diag = Box::new(WrongAudAccepted { 280 + status: resp.status.as_u16(), 281 + source_code: body_as_named_source(&resp), 282 + span: None, 283 + }); 284 + results.push(Check::WrongAudRejected.spec_violation(Some(diag))); 285 + } 286 + }, 287 + Err(CreateReportStageError::Transport { message, .. }) => { 288 + results.push(Check::WrongAudRejected.network_error(message)); 289 + } 290 + } 291 + } 292 + 293 + // AC3.3 — wrong lxm: 294 + { 295 + let mut claims = template.clone(); 296 + claims.lxm = "com.atproto.server.getSession".to_string(); 297 + let token = signer.sign_jwt(claims); 298 + match report_tee.post_create_report(Some(&token), &negative_body).await { 299 + Ok(resp) => match RejectionShape::classify(&resp) { 300 + RejectionShape::Conformant { .. } => results.push(Check::WrongLxmRejected.pass()), 301 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 302 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 303 + ..Check::WrongLxmRejected.pass() 304 + }), 305 + RejectionShape::WrongStatus { .. } => { 306 + let diag = Box::new(WrongLxmAccepted { 307 + status: resp.status.as_u16(), 308 + source_code: body_as_named_source(&resp), 309 + span: None, 310 + }); 311 + results.push(Check::WrongLxmRejected.spec_violation(Some(diag))); 312 + } 313 + }, 314 + Err(CreateReportStageError::Transport { message, .. }) => { 315 + results.push(Check::WrongLxmRejected.network_error(message)); 316 + } 317 + } 318 + } 319 + 320 + // AC3.4 — expired: 321 + { 322 + let mut claims = template.clone(); 323 + claims.exp = now - 300; 324 + claims.iat = now - 360; 325 + let token = signer.sign_jwt(claims); 326 + match report_tee.post_create_report(Some(&token), &negative_body).await { 327 + Ok(resp) => match RejectionShape::classify(&resp) { 328 + RejectionShape::Conformant { .. } => results.push(Check::ExpiredRejected.pass()), 329 + RejectionShape::ConformantStatusNonConformantShape => results.push(CheckResult { 330 + summary: Cow::Borrowed("Rejected with 401 but envelope is non-conformant"), 331 + ..Check::ExpiredRejected.pass() 332 + }), 333 + RejectionShape::WrongStatus { .. } => { 334 + let diag = Box::new(ExpiredAccepted { 335 + status: resp.status.as_u16(), 336 + source_code: body_as_named_source(&resp), 337 + span: None, 338 + }); 339 + results.push(Check::ExpiredRejected.spec_violation(Some(diag))); 340 + } 341 + }, 342 + Err(CreateReportStageError::Transport { message, .. }) => { 343 + results.push(Check::ExpiredRejected.network_error(message)); 344 + } 345 + } 346 + } 347 + 348 + // AC3.5/AC3.6 — rejected shape: 349 + { 350 + let claims = template.clone(); 351 + let token = signer.sign_jwt(claims); 352 + // Invalid body: a reasonType that is NOT in id_facts.reason_types. 353 + let bogus_reason_type = synth_unadvertised_reason_type(id_facts); 354 + let invalid_body = { 355 + let mut body = negative_body.clone(); 356 + if let Some(obj) = body.as_object_mut() { 357 + obj.insert( 358 + "reasonType".to_string(), 359 + serde_json::Value::String(bogus_reason_type), 360 + ); 361 + } 362 + body 363 + }; 364 + match report_tee.post_create_report(Some(&token), &invalid_body).await { 365 + Ok(resp) => { 366 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 367 + let error_name = envelope.as_ref().and_then(|e| e.error.clone()); 368 + if resp.status == reqwest::StatusCode::BAD_REQUEST 369 + && error_name.as_deref() == Some("InvalidRequest") 370 + { 371 + // AC3.5: 400 InvalidRequest → Pass. 372 + results.push(Check::RejectedShapeReturns400.pass()); 373 + } else if resp.status == reqwest::StatusCode::UNAUTHORIZED 374 + || resp.status.is_server_error() 375 + { 376 + // AC3.6: 401 or 5xx → Advisory with shape_not_400. 377 + let diag = Box::new(ShapeNot400 { 378 + status: resp.status.as_u16(), 379 + error_name: error_name.clone(), 380 + source_code: body_as_named_source(&resp), 381 + span: None, 382 + }); 383 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 384 + } else if resp.status == reqwest::StatusCode::BAD_REQUEST { 385 + // 400 but not `InvalidRequest` name → Advisory. 386 + let diag = Box::new(ShapeNot400 { 387 + status: 400, 388 + error_name: error_name.clone(), 389 + source_code: body_as_named_source(&resp), 390 + span: None, 391 + }); 392 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 393 + } else { 394 + // Catch-all: 200 accepted → Advisory. A 200 for an invalid 395 + // shape is a labeler looseness issue, not the same category 396 + // as the `self_mint_accepted` SpecViolation (which expects 397 + // a *valid* shape to be accepted). 398 + let diag = Box::new(ShapeNot400 { 399 + status: resp.status.as_u16(), 400 + error_name, 401 + source_code: body_as_named_source(&resp), 402 + span: None, 403 + }); 404 + results.push(Check::RejectedShapeReturns400.advisory(Some(diag))); 405 + } 406 + } 407 + Err(CreateReportStageError::Transport { message, .. }) => { 408 + results.push(Check::RejectedShapeReturns400.network_error(message)); 409 + } 410 + } 411 + } 412 + } else { 413 + let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)"; 414 + for c in [ 415 + Check::WrongAudRejected, 416 + Check::WrongLxmRejected, 417 + Check::ExpiredRejected, 418 + Check::RejectedShapeReturns400, 419 + ] { 420 + results.push(c.skip(reason)); 421 + } 422 + } 423 + 424 + // Fallthrough to Phase 7/8 check logic below. In Phase 6 (before Phases 425 + // 7 and 8 replace their stubs), the Phase 4 stubs for SelfMintAccepted / 426 + // PdsServiceAuthAccepted / PdsProxiedAccepted still fire here. Keeping 427 + // this block fallthrough-safe is why the `if let Some(signer)` above 428 + // does NOT `return`. 429 + 430 + /// Synthesize a `reasonType` string that is definitely NOT in the 431 + /// labeler's advertised `reason_types`. NSID syntax (segments alphanumeric + 432 + /// period only, fragment after `#`) is strictly valid so the labeler does 433 + /// not reject for wrong reason (malformed NSID) before checking membership. 434 + fn synth_unadvertised_reason_type(facts: &IdentityFacts) -> String { 435 + let empty = Vec::new(); 436 + let advertised: &[String] = facts 437 + .reason_types 438 + .as_ref() 439 + .unwrap_or(&empty); 440 + for i in 0..1000 { 441 + let candidate = format!("xyz.atprotodevtool.conformance.defs#unadvertised{i:03}"); 442 + if !advertised.iter().any(|r| r == &candidate) { 443 + return candidate; 444 + } 445 + } 446 + // Unreachable in practice. 447 + "xyz.atprotodevtool.conformance.defs#unadvertisedFallback".to_string() 448 + } 449 + ``` 450 + 451 + **Notes for the implementor:** 452 + - Each of the four checks inlines its own `match` block. A shared async helper was considered and rejected: nested `async fn` is unsupported in stable Rust, and a module-scope helper taking a `FnOnce` closure returning `Box<dyn Diagnostic + Send + Sync>` across an `await` forces `Send` bounds that complicate the call sites more than the duplication. 453 + - `Check::RejectedShapeReturns400` has slightly different handling because Pass requires both status 400 AND `error == "InvalidRequest"`; it's inlined separately. 454 + - `IdentityFacts.did`, `IdentityFacts.labeler_endpoint`, `IdentityFacts.reason_types` — all field names verified against `src/commands/test/labeler/identity.rs` (post Phase 4 Task 0 extension). `.did` is the labeler's DID. 455 + - `build_minimal_report_body` is the `pub(crate)` module-scope helper introduced in Phase 5 Task 3. It reads `id_facts.reason_types`, `.subject_types`, and `.did` directly from the Phase 4 Task 0 extended `IdentityFacts`. 456 + - `synth_unadvertised_reason_type` uses `xyz.atprotodevtool.conformance.defs#unadvertisedNNN`. The `xyz.*` top-level is a reserved-for-experimental namespace in atproto NSID convention; domain segments have no hyphens (valid NSID syntax). 457 + 458 + **Testing:** 459 + 460 + Extend `tests/labeler_report.rs` with AC3 tests: 461 + 462 + ```rust 463 + /// Helper: an IdentityFacts fixture whose labeler_endpoint is a 464 + /// localhost URL (self_mint_viable = true). 465 + fn local_identity_facts() -> IdentityFacts { 466 + let mut facts = make_identity_facts( 467 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 468 + Some(vec!["account".to_string()]), 469 + ); 470 + // Override endpoint to localhost. Exact field name per 471 + // the IdentityFacts struct. 472 + facts.labeler_endpoint = url::Url::parse("http://localhost:8080").unwrap(); 473 + facts 474 + } 475 + 476 + #[tokio::test] 477 + async fn ac3_1_wrong_aud_401_passes() { 478 + let facts = local_identity_facts(); 479 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 480 + let tee = FakeCreateReportTee::new(); 481 + // Six POSTs expected: unauthenticated, malformed, wrong_aud, 482 + // wrong_lxm, expired, rejected_shape. Enqueue each: 483 + for _ in 0..2 { 484 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); // phase 5 checks 485 + } 486 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtAudience", "aud mismatch")); 487 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtLexiconMethod", "lxm mismatch")); 488 + tee.enqueue(FakeCreateReportResponse::unauthorized("JwtExpired", "expired")); 489 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "unadvertised reasonType")); 490 + 491 + let mut opts = default_opts(); 492 + opts.self_mint_signer = Some(&signer); 493 + let results = run_report_stage(&facts, &tee, opts).await; 494 + 495 + // Rows 3-5 are AC3.1-AC3.4, row 6 is AC3.5. 496 + assert_eq!(results[3].id, "report::wrong_aud_rejected"); 497 + assert_eq!(results[3].status, CheckStatus::Pass); 498 + assert_eq!(results[4].status, CheckStatus::Pass); 499 + assert_eq!(results[5].status, CheckStatus::Pass); 500 + assert_eq!(results[6].id, "report::rejected_shape_returns_400"); 501 + assert_eq!(results[6].status, CheckStatus::Pass); 502 + } 503 + 504 + #[tokio::test] 505 + async fn ac3_2_wrong_aud_200_is_spec_violation() { 506 + // ... similar, but enqueue OK for the wrong_aud call; assert SpecViolation + 507 + // diagnostic code "labeler::report::wrong_aud_accepted". 508 + } 509 + 510 + #[tokio::test] 511 + async fn ac3_6_shape_not_400_emits_advisory() { 512 + // enqueue 401 for the rejected_shape request; assert Advisory + diagnostic 513 + // code "labeler::report::shape_not_400". 514 + } 515 + 516 + #[tokio::test] 517 + async fn ac3_7_non_local_labeler_skips_self_mint_checks() { 518 + let mut facts = make_identity_facts( 519 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 520 + Some(vec!["account".to_string()]), 521 + ); 522 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 523 + let tee = FakeCreateReportTee::new(); 524 + // Only two Phase 5 POSTs expected (unauth + malformed). 525 + for _ in 0..2 { 526 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 527 + } 528 + let mut opts = default_opts(); 529 + opts.self_mint_signer = None; 530 + let results = run_report_stage(&facts, &tee, opts).await; 531 + for i in 3..=6 { 532 + assert_eq!(results[i].status, CheckStatus::Skipped, "row {} ({})", i, results[i].id); 533 + assert!( 534 + results[i].skipped_reason.as_deref().unwrap().contains("--force-self-mint"), 535 + "row {}: {:?}", i, results[i].skipped_reason, 536 + ); 537 + } 538 + } 539 + 540 + #[tokio::test] 541 + async fn ac3_8_force_self_mint_overrides_non_local() { 542 + let mut facts = make_identity_facts( 543 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 544 + Some(vec!["account".to_string()]), 545 + ); 546 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 547 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 548 + let tee = FakeCreateReportTee::new(); 549 + for _ in 0..2 { 550 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 551 + } 552 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtAudience", "x")); 553 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwtLexiconMethod", "x")); 554 + tee.enqueue(FakeCreateReportResponse::unauthorized("JwtExpired", "x")); 555 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 556 + let mut opts = default_opts(); 557 + opts.self_mint_signer = Some(&signer); 558 + opts.force_self_mint = true; 559 + let results = run_report_stage(&facts, &tee, opts).await; 560 + assert_eq!(results[3].status, CheckStatus::Pass); 561 + assert_eq!(results[6].status, CheckStatus::Pass); 562 + } 563 + ``` 564 + 565 + **Verification:** 566 + Run: `cargo test --test labeler_report ac3_` 567 + Expected: all AC3 tests pass. Add at least one test per AC case (AC3.1 through AC3.8). 568 + 569 + Run: `cargo insta pending-snapshots` 570 + Expected: any new snapshots are reviewed. 571 + 572 + **Commit:** `feat(create_report): wire self-mint negative checks (AC3.1-AC3.8)` 573 + <!-- END_TASK_3 --> 574 + 575 + <!-- START_TASK_4 --> 576 + ### Task 4: Phase 6 integration check 577 + 578 + **Files:** None changed. 579 + 580 + **Implementation:** Gate + snapshot churn acceptance. 581 + 582 + **Verification:** 583 + Run: `cargo test` 584 + Expected: most tests pass, but Phase 4's insta snapshots that pinned Phase-4 stub strings (`"not yet implemented (Phase 6)"`) will now fail because Phase 6 replaces those rows with real Pass/SpecViolation outcomes. This is expected churn, not a regression. 585 + 586 + Run: `cargo insta review` 587 + Expected: step through every pending snapshot from Phase 6 changes. Accept each snapshot that shows real AC3 rows instead of Phase-4 stubs. Reject any snapshot that shows unexpected content (e.g., a row in the wrong position or a diagnostic code that doesn't match the design). 588 + 589 + Run: `cargo test` (again, after accepting snapshots) 590 + Expected: all tests pass; Phase 6 adds ~8 new tests (AC3.1-AC3.8) plus updates to pre-existing Phase 4/5 snapshots. 591 + 592 + Run: `cargo clippy --all-targets -- -D warnings` 593 + Expected: no warnings. 594 + 595 + **Commit:** `test(create_report): accept Phase 6 snapshot churn` 596 + <!-- END_TASK_4 --> 597 + 598 + --- 599 + 600 + ## Phase 6 complete when 601 + 602 + - Four new diagnostics (`WrongAudAccepted`, `WrongLxmAccepted`, `ExpiredAccepted`, `ShapeNot400`) with stable codes compile and are used by the stage. 603 + - `LabelerCmd::run` constructs a `SelfMintSigner` when the heuristic or `--force-self-mint` warrants it. 604 + - `report::wrong_aud_rejected`, `report::wrong_lxm_rejected`, `report::expired_rejected`, `report::rejected_shape_returns_400` are all live (no longer `Skipped` stubs), each with their own mutation and classifier. 605 + - Self-mint-viable gating emits `Skipped` with the exact `--force-self-mint` override hint. 606 + - `--force-self-mint` bypasses the heuristic. 607 + - **Acceptance criteria satisfied:** AC3.1, AC3.2, AC3.3, AC3.4, AC3.5, AC3.6, AC3.7, AC3.8.
+613
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_07.md
··· 1 + # Labeler report stage — Phase 7: Self-mint positive check + pollution avoidance 2 + 3 + **Goal:** Implement `report::self_mint_accepted`: the first committing check. POST a well-formed report body (advertised `reasonType`, advertised subject shape, sentinel reason) with a valid self-mint JWT, expect 2xx + a `createReport#output`-shaped response body. Apply pollution-avoidance rules based on labeler locality. 4 + 5 + **Architecture:** A new `pollution.rs` helper module under `create_report/` houses the `choose_reason_type` and `choose_subject` helpers. The `self_mint_accepted` check composes these with the sentinel-reason builder from Phase 1 and the JWT minting from Phase 2. 6 + 7 + **Tech Stack:** Phases 1-6 artifacts. No new externals. 8 + 9 + **Scope:** Phase 7 of 8. 10 + 11 + **Codebase verified:** 2026-04-17 (relies on Phase 1-6 artifacts). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `LabelerPolicies.reason_types: Option<Vec<String>>` and `subject_types: Option<Vec<String>>`, already proven non-empty by Phase 4's `contract_advertised` gate (reachable code paths). 15 + - ✓ Phase 1's `sentinel::build(run_id, now)` produces the "atproto-devtool conformance test <RFC3339> <run-id>" string; `sentinel::new_run_id` produces a 16-hex-char id. 16 + - ✓ Phase 6's `build_minimal_report_body(facts)` is a *negative-path* helper — reuses the labeler's own DID as the subject. Phase 7 needs a distinct *positive-path* helper that respects pollution-avoidance. 17 + - ✓ Phase 5's AC2.5 handling (Pass-with-non-conformant-envelope) is not reused — self_mint_accepted expects 2xx, not 401. 18 + 19 + **External dependency research findings:** 20 + - ✓ `com.atproto.moderation.createReport#output` response body per the lexicon: 21 + ```json 22 + { 23 + "id": <integer>, 24 + "reasonType": "<string NSID>", 25 + "subject": { "$type": "com.atproto.admin.defs#repoRef", "did": "did:..." }, 26 + "reportedBy": "did:...", 27 + "createdAt": "<ISO 8601 UTC>" 28 + } 29 + ``` 30 + Required fields: `id`, `reasonType`, `subject`, `reportedBy`, `createdAt`. `reason` is echoed back optionally. The stage's Pass criterion is "2xx + body parses as JSON containing at minimum `id` (number) and `reportedBy` (string)". We don't need a strict atrium-api decode; a loose `serde_json::Value` check is enough. 31 + - ✓ `com.atproto.moderation.defs#reasonType` enum membership: `reasonSpam`, `reasonViolation`, `reasonMisleading`, `reasonSexual`, `reasonRude`, `reasonOther` (canonical order). For pollution-avoidance the stage prefers `reasonOther` when it's in the advertised set; else lex-first. 32 + - ✓ `subjectTypes`: `"account"` and `"record"` are the two canonical values. 33 + - ✓ AT-URI syntax: `at://<did-or-handle>/<collection>/<rkey>`. For strongRef, also needs a `cid`. For v1 the constants are placeholders — the release-gate adds real values. 34 + 35 + --- 36 + 37 + ## Acceptance criteria coverage 38 + 39 + This phase implements and tests: 40 + 41 + ### labeler-report-stage.AC4: Self-mint positive check 42 + - **labeler-report-stage.AC4.1 Success (local labeler):** `self_mint_accepted` emits `Pass` using lex-first `reasonType` and account subject = reporter DID when labeler returns 2xx. 43 + - **labeler-report-stage.AC4.2 Success (non-local labeler):** `self_mint_accepted` emits `Pass` using `reasonOther` (if advertised) and `record` subject with hard-coded AT-URI (if advertised) when labeler returns 2xx. 44 + - **labeler-report-stage.AC4.3 Failure:** emits `SpecViolation` (diagnostic `labeler::report::self_mint_rejected`) when labeler returns non-2xx. 45 + - **labeler-report-stage.AC4.4 Skip (no commit):** emits `Skipped` with reason naming the `--commit-report` gate. 46 + - **labeler-report-stage.AC4.5 Skip (not viable):** emits `Skipped` with the self-mint-unreachable reason when heuristic trips. 47 + - **labeler-report-stage.AC4.6 Sentinel:** The `reason` field in the submitted POST body contains the stable sentinel string `"atproto-devtool conformance test <RFC3339> <run-id>"`. 48 + 49 + --- 50 + 51 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 52 + <!-- START_TASK_1 --> 53 + ### Task 1: `pollution` helper module 54 + 55 + **Verifies:** Supports AC4.1, AC4.2, AC4.6. 56 + 57 + **Files:** 58 + - Create: `src/commands/test/labeler/create_report/pollution.rs`. 59 + - Modify: `src/commands/test/labeler/create_report.rs` — add `pub mod pollution;`. 60 + 61 + **Implementation:** 62 + 63 + ```rust 64 + //! Pollution-avoidance helpers for committing report checks. 65 + //! 66 + //! When the tool actually POSTs a report (positive path only), we have 67 + //! two obligations: 68 + //! 69 + //! 1. Avoid contaminating real moderation queues on public labelers. 70 + //! 2. Be easy to identify and dismiss for operators who see the report. 71 + //! 72 + //! Obligation 1 is satisfied by preferring: 73 + //! - `reasonOther` reasonType when advertised (signals "this is a test 74 + //! or edge case, review leisurely"), falling back to the lex-first 75 + //! advertised value. 76 + //! - `record` subject type with a hardcoded AT-URI pointing at an 77 + //! explanation post the tool author publishes (release-gate item), 78 + //! falling back to `account` subject pointing at the *reporter's* 79 + //! own DID (the self-mint DID, which is always safe). 80 + //! 81 + //! Obligation 2 is satisfied by the sentinel `reason` string from the 82 + //! `sentinel` module — it's stable and greppable. 83 + //! 84 + //! On *local* labelers the safety constraint relaxes: the queue is the 85 + //! developer's own, so we use lex-first `reasonType` + `account` subject 86 + //! (pointing at the reporter's own DID) to exercise the simplest working 87 + //! shape. This makes the test deterministic for round-trip debugging. 88 + 89 + use serde_json::{Value, json}; 90 + 91 + use crate::common::identity::Did; 92 + 93 + /// Placeholder constant for the record-subject AT-URI used in 94 + /// non-local pollution-safe POSTs. **Must be replaced with a real 95 + /// published post's AT-URI before v1 ships** — see 96 + /// `docs/design-plans/2026-04-17-labeler-report-stage.md` § "Pre-release 97 + /// TODO". 98 + pub const CONFORMANCE_REPORT_SUBJECT_URI: &str = 99 + "<TBD: at://did:plc:... — release-gate>"; 100 + 101 + /// Placeholder constant for the record-subject CID. Same release-gate 102 + /// story: capture when the explanation post is published. 103 + pub const CONFORMANCE_REPORT_SUBJECT_CID: &str = 104 + "<TBD: bafyrei... — release-gate>"; 105 + 106 + /// Choose the `reasonType` for a committing POST. 107 + /// 108 + /// Returns the full NSID string (e.g., 109 + /// `"com.atproto.moderation.defs#reasonOther"`). Preconditions: 110 + /// `advertised` is non-empty (the stage's contract-published gate 111 + /// guarantees this). 112 + pub fn choose_reason_type(advertised: &[String], is_local: bool) -> String { 113 + let prefer_other = "com.atproto.moderation.defs#reasonOther"; 114 + if !is_local && advertised.iter().any(|r| r == prefer_other) { 115 + return prefer_other.to_string(); 116 + } 117 + advertised 118 + .first() 119 + .cloned() 120 + .unwrap_or_else(|| prefer_other.to_string()) 121 + } 122 + 123 + /// Choose the `subject` JSON for a committing POST. 124 + /// 125 + /// - `advertised_types`: non-empty (contract-published guarantees). 126 + /// - `reporter_did`: the self-mint DID (for the "own reporter" fallback). 127 + /// - `override_did`: if `Some`, always use an `account` subject with this 128 + /// DID (for `--report-subject-did`). 129 + /// - `is_local`: local labelers use the simplest shape for debugging. 130 + pub fn choose_subject( 131 + advertised_types: &[String], 132 + reporter_did: &Did, 133 + override_did: Option<&Did>, 134 + is_local: bool, 135 + ) -> Value { 136 + if let Some(did) = override_did { 137 + return json!({ 138 + "$type": "com.atproto.admin.defs#repoRef", 139 + "did": did.0, 140 + }); 141 + } 142 + if !is_local && advertised_types.iter().any(|s| s == "record") { 143 + return json!({ 144 + "$type": "com.atproto.repo.strongRef", 145 + "uri": CONFORMANCE_REPORT_SUBJECT_URI, 146 + "cid": CONFORMANCE_REPORT_SUBJECT_CID, 147 + }); 148 + } 149 + // Local, override absent, or `record` not advertised → account subject 150 + // pointing at the reporter's own DID (always safe). 151 + json!({ 152 + "$type": "com.atproto.admin.defs#repoRef", 153 + "did": reporter_did.0, 154 + }) 155 + } 156 + ``` 157 + 158 + **Testing:** 159 + 160 + ```rust 161 + #[cfg(test)] 162 + mod tests { 163 + use super::*; 164 + 165 + #[test] 166 + fn choose_reason_type_prefers_other_when_advertised_and_non_local() { 167 + let advertised = vec![ 168 + "com.atproto.moderation.defs#reasonSpam".to_string(), 169 + "com.atproto.moderation.defs#reasonOther".to_string(), 170 + ]; 171 + assert_eq!( 172 + choose_reason_type(&advertised, false), 173 + "com.atproto.moderation.defs#reasonOther" 174 + ); 175 + } 176 + 177 + #[test] 178 + fn choose_reason_type_uses_lex_first_when_local() { 179 + let advertised = vec![ 180 + "com.atproto.moderation.defs#reasonSpam".to_string(), 181 + "com.atproto.moderation.defs#reasonOther".to_string(), 182 + ]; 183 + assert_eq!( 184 + choose_reason_type(&advertised, true), 185 + "com.atproto.moderation.defs#reasonSpam" 186 + ); 187 + } 188 + 189 + #[test] 190 + fn choose_reason_type_falls_back_to_first_when_other_absent() { 191 + let advertised = vec!["com.atproto.moderation.defs#reasonSpam".to_string()]; 192 + assert_eq!( 193 + choose_reason_type(&advertised, false), 194 + "com.atproto.moderation.defs#reasonSpam" 195 + ); 196 + } 197 + 198 + #[test] 199 + fn choose_subject_local_returns_account_on_reporter() { 200 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 201 + let subj = choose_subject(&["account".to_string(), "record".to_string()], &reporter, None, true); 202 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 203 + assert_eq!(subj["did"], reporter.0); 204 + } 205 + 206 + #[test] 207 + fn choose_subject_non_local_prefers_record_when_advertised() { 208 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 209 + let subj = choose_subject(&["account".to_string(), "record".to_string()], &reporter, None, false); 210 + assert_eq!(subj["$type"], "com.atproto.repo.strongRef"); 211 + assert_eq!(subj["uri"], CONFORMANCE_REPORT_SUBJECT_URI); 212 + } 213 + 214 + #[test] 215 + fn choose_subject_non_local_falls_back_to_account_when_record_absent() { 216 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 217 + let subj = choose_subject(&["account".to_string()], &reporter, None, false); 218 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 219 + assert_eq!(subj["did"], reporter.0); 220 + } 221 + 222 + #[test] 223 + fn choose_subject_override_always_wins() { 224 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 225 + let override_did = Did("did:plc:target".to_string()); 226 + let subj = choose_subject( 227 + &["record".to_string()], 228 + &reporter, 229 + Some(&override_did), 230 + false, // non-local; without override this would be record/strongRef 231 + ); 232 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 233 + assert_eq!(subj["did"], "did:plc:target"); 234 + } 235 + } 236 + ``` 237 + 238 + **Verification:** 239 + Run: `cargo test --lib commands::test::labeler::create_report::pollution::tests` 240 + Expected: 7 tests pass. 241 + 242 + **Commit:** `feat(create_report): pollution-avoidance helpers` 243 + <!-- END_TASK_1 --> 244 + 245 + <!-- START_TASK_2 --> 246 + ### Task 2: Stable run-id threaded through the stage 247 + 248 + **Verifies:** AC4.6 (sentinel string). 249 + 250 + **Files:** 251 + - Modify: `src/commands/test/labeler/create_report.rs` — add `run_id: &'a str` to `CreateReportRunOptions` (see refined shape below). 252 + - Modify: `src/commands/test/labeler.rs` — construct a run-id in `LabelerCmd::run` and thread it through. 253 + 254 + **Implementation:** 255 + 256 + Extend `CreateReportRunOptions` (introduced in Phase 4 Task 6) with one new field: `pub run_id: &'a str`. The reference-typed field avoids ownership games — the caller (`LabelerCmd::run` in production, test helpers in tests) holds the `String` and passes a borrow. 257 + 258 + ```rust 259 + // Final shape of CreateReportRunOptions: 260 + pub struct CreateReportRunOptions<'a> { 261 + pub commit_report: bool, 262 + pub force_self_mint: bool, 263 + pub self_mint_curve: SelfMintCurve, 264 + pub report_subject_override: Option<&'a Did>, 265 + pub self_mint_signer: Option<&'a SelfMintSigner>, 266 + pub pds_credentials: Option<&'a PdsCredentials>, 267 + pub run_id: &'a str, // NEW in Phase 7 268 + } 269 + ``` 270 + 271 + In `LabelerCmd::run`, construct the run-id before building `LabelerOptions`: 272 + 273 + ```rust 274 + let run_id = crate::commands::test::labeler::create_report::sentinel::new_run_id(); 275 + // ... construct LabelerOptions with run_id: &run_id (the `run_id` String 276 + // lives for the duration of LabelerCmd::run, which exceeds the lifetime 277 + // of the LabelerOptions borrow). 278 + ``` 279 + 280 + Phase 4 tests must add `run_id: "test-run-id-0000"` (or similar) to every `CreateReportRunOptions` they construct. Update `default_opts()` in `tests/labeler_report.rs` to include this field. 281 + 282 + **Notes for the implementor:** 283 + - The `run_id` field must lifetime-tie to a variable whose `Drop` happens after the pipeline completes. Putting `let run_id = ...` at the top of `LabelerCmd::run` is sufficient. 284 + 285 + **Testing:** No dedicated test; Task 3's integration test asserts the sentinel string in the POSTed body. 286 + 287 + **Verification:** 288 + Run: `cargo build` 289 + Expected: clean. 290 + 291 + **Commit:** `feat(create_report): thread run_id through CreateReportRunOptions` 292 + <!-- END_TASK_2 --> 293 + <!-- END_SUBCOMPONENT_A --> 294 + 295 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 296 + <!-- START_TASK_3 --> 297 + ### Task 3: Implement `self_mint_accepted` with the sentinel + pollution policy 298 + 299 + **Verifies:** AC4.1–AC4.6. 300 + 301 + **Files:** 302 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase 4/6 stub for `Check::SelfMintAccepted` with real logic; add the `SelfMintRejected` diagnostic. 303 + 304 + **Implementation:** 305 + 306 + ```rust 307 + #[derive(Debug, Error, Diagnostic)] 308 + #[error("Self-mint report rejected (status {status})")] 309 + #[diagnostic( 310 + code = "labeler::report::self_mint_rejected", 311 + help = "A labeler that advertises reportable shape should accept a well-formed, authenticated createReport. Check the labeler's service-auth validation and its acceptance of the advertised reasonType/subject shape." 312 + )] 313 + pub struct SelfMintRejected { 314 + pub status: u16, 315 + #[source_code] 316 + pub source_code: NamedSource<Arc<[u8]>>, 317 + #[label("rejected here")] 318 + pub span: Option<SourceSpan>, 319 + } 320 + ``` 321 + 322 + Replace this block in `run()`: 323 + 324 + ```rust 325 + results.push(Check::SelfMintAccepted.skip("not yet implemented (Phase 7)")); 326 + ``` 327 + 328 + with: 329 + 330 + ```rust 331 + // AC4.4 — gate on commit_report. 332 + if !opts.commit_report { 333 + results.push(Check::SelfMintAccepted.skip( 334 + "commit gated behind --commit-report", 335 + )); 336 + } else { 337 + // AC4.1/AC4.2 — construct a positive POST with pollution-avoidance. 338 + // Reads contract from the Phase 4 Task 0 extended IdentityFacts 339 + // fields (`reason_types` / `subject_types`). 340 + let reason_type = pollution::choose_reason_type( 341 + id_facts.reason_types.as_deref().unwrap_or(&[]), 342 + crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint), 343 + ); 344 + let subject = pollution::choose_subject( 345 + id_facts.subject_types.as_deref().unwrap_or(&[]), 346 + signer.issuer_did(), 347 + opts.report_subject_override, 348 + crate::common::identity::is_local_labeler_hostname(&id_facts.labeler_endpoint), 349 + ); 350 + let sentinel = sentinel::build(opts.run_id, std::time::SystemTime::now()); 351 + let positive_body = serde_json::json!({ 352 + "reasonType": reason_type, 353 + "subject": subject, 354 + "reason": sentinel, 355 + }); 356 + 357 + // AC4.6 — the built body carries the sentinel; the integration test 358 + // in Task 4 asserts it via FakeCreateReportTee::last_request(). 359 + 360 + let claims = signer.valid_claims_template( 361 + &id_facts.did, 362 + "com.atproto.moderation.createReport", 363 + now, 364 + std::time::Duration::from_secs(60), 365 + ); 366 + let token = signer.sign_jwt(claims); 367 + 368 + match report_tee.post_create_report(Some(&token), &positive_body).await { 369 + Ok(resp) if resp.status.is_success() => { 370 + // AC4.1/AC4.2: Pass. Optionally inspect body for createReport#output 371 + // shape — loose check: `id` is a number. 372 + let body_ok = serde_json::from_slice::<serde_json::Value>(&resp.raw_body) 373 + .ok() 374 + .and_then(|v| v.as_object().map(|o| o.contains_key("id"))) 375 + .unwrap_or(false); 376 + if body_ok { 377 + results.push(Check::SelfMintAccepted.pass()); 378 + } else { 379 + // 2xx but body doesn't look like createReport#output. Accept as 380 + // Pass per design (status alone suffices), but note the 381 + // non-conformant body in the summary. 382 + results.push(CheckResult { 383 + summary: std::borrow::Cow::Borrowed( 384 + "Self-mint report accepted (2xx), body did not match createReport#output shape", 385 + ), 386 + ..Check::SelfMintAccepted.pass() 387 + }); 388 + } 389 + } 390 + Ok(resp) => { 391 + // AC4.3: non-2xx ⇒ SpecViolation. 392 + let diag = Box::new(SelfMintRejected { 393 + status: resp.status.as_u16(), 394 + source_code: body_as_named_source(&resp), 395 + span: None, 396 + }); 397 + results.push(Check::SelfMintAccepted.spec_violation(Some(diag))); 398 + } 399 + Err(CreateReportStageError::Transport { message, .. }) => { 400 + results.push(Check::SelfMintAccepted.network_error(message)); 401 + } 402 + } 403 + } 404 + ``` 405 + 406 + **Notes for the implementor:** 407 + - AC4.5 (`Skipped` with the viability reason when heuristic trips) is already handled by the earlier `Some(signer) = signer else { ... }` block in Phase 6. When we reach `self_mint_accepted` in Phase 7, either we have a signer (viable or `--force-self-mint`) or we've already emitted `Skipped` and returned. The `else` branch above handles commit-off; the matched body handles the accepted case. 408 + 409 + **Testing:** 410 + 411 + Extend `tests/labeler_report.rs` with AC4 tests: 412 + 413 + ```rust 414 + #[tokio::test] 415 + async fn ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject() { 416 + let facts = local_identity_facts(); 417 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 418 + let tee = FakeCreateReportTee::new(); 419 + // Enqueue responses for Phase 5 (2), Phase 6 (4), then AC4 positive. 420 + for _ in 0..2 { 421 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 422 + } 423 + for _ in 0..3 { 424 + tee.enqueue(FakeCreateReportResponse::unauthorized("BadJwt*", "x")); 425 + } 426 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 427 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 428 + 429 + let mut opts = default_opts(); 430 + opts.self_mint_signer = Some(&signer); 431 + opts.commit_report = true; 432 + let run_id = "test-run-1234567890".to_string(); 433 + opts.run_id = &run_id; 434 + let results = run_report_stage(&facts, &tee, opts).await; 435 + 436 + assert_eq!(results[7].id, "report::self_mint_accepted"); 437 + assert_eq!(results[7].status, CheckStatus::Pass); 438 + 439 + // AC4.6: last_request() body contains the sentinel. 440 + let last_req = tee.last_request(); 441 + let body_reason = last_req.body["reason"].as_str().unwrap_or(""); 442 + assert!(body_reason.starts_with("atproto-devtool conformance test")); 443 + assert!(body_reason.ends_with(&run_id)); 444 + 445 + // AC4.1: reasonType is lex-first (reasonSpam), subject is account. 446 + let body = &last_req.body; 447 + assert_eq!(body["reasonType"], "com.atproto.moderation.defs#reasonSpam"); 448 + assert_eq!(body["subject"]["$type"], "com.atproto.admin.defs#repoRef"); 449 + } 450 + 451 + #[tokio::test] 452 + async fn ac4_2_non_local_labeler_prefers_other_and_record() { 453 + let mut facts = make_identity_facts( 454 + Some(vec![ 455 + "com.atproto.moderation.defs#reasonSpam".to_string(), 456 + "com.atproto.moderation.defs#reasonOther".to_string(), 457 + ]), 458 + Some(vec!["account".to_string(), "record".to_string()]), 459 + ); 460 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 461 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 462 + let tee = FakeCreateReportTee::new(); 463 + for _ in 0..2 { 464 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 465 + } 466 + // Phase 6 self-mint checks all Skipped (non-local); only 2 Phase 5 467 + // POSTs happen, then the AC4 positive POST. 468 + tee.enqueue(FakeCreateReportResponse::ok_empty()); 469 + let mut opts = default_opts(); 470 + opts.self_mint_signer = Some(&signer); 471 + opts.commit_report = true; 472 + opts.force_self_mint = true; // so the Phase 6 checks do run 473 + // Re-queue Phase 6 responses since force_self_mint is on: 474 + // ... actually, if force_self_mint is on, Phase 6 POSTs happen (4 more) 475 + // before AC4. Adjust the queue accordingly. 476 + // ... [implementor: work out the exact FIFO order]. 477 + 478 + let results = run_report_stage(&facts, &tee, opts).await; 479 + assert_eq!(results[7].status, CheckStatus::Pass); 480 + 481 + let last_req = tee.last_request(); 482 + assert_eq!( 483 + last_req.body["reasonType"], 484 + "com.atproto.moderation.defs#reasonOther" 485 + ); 486 + assert_eq!( 487 + last_req.body["subject"]["$type"], 488 + "com.atproto.repo.strongRef" 489 + ); 490 + } 491 + 492 + #[tokio::test] 493 + async fn ac4_3_non_2xx_is_spec_violation() { 494 + let facts = local_identity_facts(); 495 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 496 + let tee = FakeCreateReportTee::new(); 497 + // 2 Phase 5 + 4 Phase 6 + 1 AC4. 498 + for _ in 0..2 { 499 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 500 + } 501 + for _ in 0..3 { 502 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 503 + } 504 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 505 + tee.enqueue(FakeCreateReportResponse::Response { 506 + status: 400, 507 + content_type: Some("application/json".to_string()), 508 + body: br#"{"error":"InvalidRequest","message":"nope"}"#.to_vec(), 509 + }); 510 + 511 + let mut opts = default_opts(); 512 + opts.self_mint_signer = Some(&signer); 513 + opts.commit_report = true; 514 + let run_id = "x".to_string(); 515 + opts.run_id = &run_id; 516 + let results = run_report_stage(&facts, &tee, opts).await; 517 + 518 + assert_eq!(results[7].status, CheckStatus::SpecViolation); 519 + assert_eq!( 520 + results[7].diagnostic.as_ref().unwrap().code().map(|c| c.to_string()), 521 + Some("labeler::report::self_mint_rejected".to_string()), 522 + ); 523 + } 524 + 525 + #[tokio::test] 526 + async fn ac4_4_commit_false_skips() { 527 + let facts = local_identity_facts(); 528 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 529 + let tee = FakeCreateReportTee::new(); 530 + for _ in 0..2 { 531 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 532 + } 533 + for _ in 0..3 { 534 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 535 + } 536 + tee.enqueue(FakeCreateReportResponse::bad_request("InvalidRequest", "x")); 537 + // No POST for self_mint_accepted because commit is false. 538 + 539 + let mut opts = default_opts(); 540 + opts.self_mint_signer = Some(&signer); 541 + opts.commit_report = false; 542 + let results = run_report_stage(&facts, &tee, opts).await; 543 + 544 + assert_eq!(results[7].status, CheckStatus::Skipped); 545 + assert!( 546 + results[7].skipped_reason.as_deref().unwrap().contains("--commit-report"), 547 + "expected skip reason to mention --commit-report", 548 + ); 549 + } 550 + 551 + #[tokio::test] 552 + async fn ac4_5_non_viable_skip_matches_phase_6_reason() { 553 + // When self_mint_viable=false AND commit_report=true, self_mint_accepted 554 + // is Skipped with the Phase-6 viability reason (already tested, retest 555 + // that the row is Skipped here for completeness). 556 + let mut facts = make_identity_facts( 557 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 558 + Some(vec!["account".to_string()]), 559 + ); 560 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 561 + let tee = FakeCreateReportTee::new(); 562 + for _ in 0..2 { 563 + tee.enqueue(FakeCreateReportResponse::unauthorized("x", "y")); 564 + } 565 + let mut opts = default_opts(); 566 + opts.self_mint_signer = None; 567 + opts.commit_report = true; 568 + let results = run_report_stage(&facts, &tee, opts).await; 569 + assert_eq!(results[7].status, CheckStatus::Skipped); 570 + } 571 + ``` 572 + 573 + **Verification:** 574 + Run: `cargo test --test labeler_report ac4_` 575 + Expected: 5 AC4 tests pass. 576 + 577 + **Commit:** `feat(create_report): self_mint_accepted with pollution avoidance (AC4)` 578 + <!-- END_TASK_3 --> 579 + 580 + <!-- START_TASK_4 --> 581 + ### Task 4: Phase 7 integration check 582 + 583 + **Files:** None changed. 584 + 585 + **Implementation:** Gate. 586 + 587 + **Verification:** 588 + Run: `cargo test` 589 + Expected: all Phase 1-6 tests pass; Phase 7 adds ~7 new tests (pollution helpers + AC4.1-AC4.6). 590 + 591 + Run: `cargo clippy --all-targets -- -D warnings` 592 + Expected: no warnings. 593 + 594 + **Commit:** No new commit unless fixes were needed. 595 + <!-- END_TASK_4 --> 596 + 597 + --- 598 + 599 + ## Phase 7 complete when 600 + 601 + - `pollution::choose_reason_type` and `choose_subject` apply the local vs non-local pollution rules correctly. 602 + - `CONFORMANCE_REPORT_SUBJECT_URI` and `_CID` placeholder constants exist with the release-gate comment. 603 + - `report::self_mint_accepted` uses pollution helpers + sentinel + valid JWT to POST, classifies 2xx vs non-2xx correctly. 604 + - The `reason` field in committing POSTs carries the sentinel string. 605 + - `SelfMintRejected` diagnostic has stable code `labeler::report::self_mint_rejected`. 606 + - **Acceptance criteria satisfied:** AC4.1, AC4.2, AC4.3, AC4.4, AC4.5, AC4.6. 607 + 608 + ## Release-gate TODO (not code work) 609 + 610 + Before v1 ships: 611 + 1. Publish a post on an atproto account explaining what `atproto-devtool` is and that reports with the sentinel string are test submissions. 612 + 2. Capture the AT-URI and CID of that post. 613 + 3. Replace `CONFORMANCE_REPORT_SUBJECT_URI` / `CONFORMANCE_REPORT_SUBJECT_CID` in `src/commands/test/labeler/create_report/pollution.rs` with the real values.
+1038
docs/implementation-plans/2026-04-17-labeler-report-stage/phase_08.md
··· 1 + # Labeler report stage — Phase 8: PDS modes and end-to-end integration 2 + 3 + **Goal:** Land the last two positive checks (`report::pds_service_auth_accepted`, `report::pds_proxied_accepted`), wire the remaining CLI flags (`--handle`, `--app-password` with clap `requires`), finish Phase 8 end-to-end snapshot fixtures, and re-verify AC7.1 (always-10-rows) and AC8 (CLI-flag validation) via `tests/labeler_cli.rs` extensions. 4 + 5 + **Architecture:** Two new helper types — `PdsJwtFetcher` (issues `com.atproto.server.createSession` + `com.atproto.server.getServiceAuth`) and `PdsProxiedPoster` (POSTs createReport to the PDS with the `atproto-proxy` header). Because the existing `HttpClient` trait is GET-only (`src/common/identity.rs:282-284`), Phase 8 introduces a new narrow seam `PdsXrpcClient` for the POSTs these checks need. Mirror-exact fakes go in `tests/common/mod.rs`. 6 + 7 + **Tech Stack:** `reqwest::Client` (existing), `serde_json`, Phase 3-7 artifacts. No new crate deps. 8 + 9 + **Scope:** Phase 8 of 8 — final phase. 10 + 11 + **Codebase verified:** 2026-04-17 (directly read `src/common/identity.rs:278-294`, `src/commands/test/labeler/pipeline.rs` up through full file, `src/commands/test/labeler.rs`, `tests/labeler_cli.rs`). 12 + 13 + **Codebase verification findings:** 14 + - ✓ `HttpClient` trait at `src/common/identity.rs:282-284` has a single method `async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>`. **GET-only. Cannot POST.** 15 + - ✓ **DISCREPANCY from design:** the design says "`http: &dyn HttpClient` (reused for PDS calls in modes 2 and 3)" but the existing `HttpClient` is GET-only. **Resolution:** introduce a new trait `PdsXrpcClient` (narrow: one method for POST, one for GET with bearer and query params; the GET is needed because `getServiceAuth` is a GET). Keep the existing `HttpClient` untouched. The new trait gets its own `Real*` production impl and `Fake*` test helper, following the same seam-per-concern pattern already in the codebase. 16 + - ✓ `getServiceAuth` is a GET with query params (`aud`, `lxm`, `exp`) per atproto lexicon — confirmed in external research. 17 + - ✓ `createSession` is a POST with JSON body per atproto lexicon. 18 + - ✓ The PDS-proxied flow needs a POST with two extra headers: `Authorization: Bearer <access_jwt>` and `atproto-proxy: <labeler-did>#atproto_labeler`. The labeler's DID (without fragment) comes from `IdentityFacts.labeler_did`. 19 + - ✓ `clap` `requires` attribute at `Cargo.toml:27` is available. The existing `LabelerCmd` does not use `requires`; Phase 8 is the first adopter. 20 + - ✓ `tests/labeler_cli.rs` uses `assert_cmd::Command::cargo_bin("atproto-devtool")` — existing pattern for CLI tests. 21 + - ✓ `tests/labeler_endtoend.rs` uses `insta::assert_snapshot!("name", normalized)` for whole-pipeline snapshots. Phase 8 adds three more: `all_pass_local_labeler`, `all_pass_full_suite`, `all_fail_misconfigured_labeler`. 22 + 23 + **External dependency research findings:** 24 + - ✓ `com.atproto.server.createSession`: POST to `/xrpc/com.atproto.server.createSession` with JSON body `{"identifier": "<handle or email>", "password": "<app_password>"}`. Response JSON has `accessJwt`, `refreshJwt`, `did`, `handle` (and optionally `didDoc`). Content-Type: `application/json`. 25 + - ✓ `com.atproto.server.getServiceAuth`: GET to `/xrpc/com.atproto.server.getServiceAuth?aud=<did>&lxm=<nsid>&exp=<unix-seconds-absolute>`. Returns `{"token": "<jwt>"}`. Requires `Authorization: Bearer <access_jwt>`. Note: atproto lexicon uses `exp` = absolute UNIX seconds, not duration. Use `now + 60`. 26 + - ✓ `atproto-proxy` header: format `<did>#<service-id>`. For labelers, the service-id fragment is `atproto_labeler`. Example: `atproto-proxy: did:plc:xxx#atproto_labeler`. 27 + - ✓ PDS endpoint discovery: the user's PDS is discovered via their DID document's `#atproto_pds` service entry. The tool's existing `IdentityFacts.pds_endpoint` is populated during the identity stage when a handle/DID target is used — reuse it. 28 + 29 + --- 30 + 31 + ## Acceptance criteria coverage 32 + 33 + This phase implements and tests: 34 + 35 + ### labeler-report-stage.AC5: PDS `getServiceAuth` mode 36 + - **labeler-report-stage.AC5.1 Success:** `pds_service_auth_accepted` emits `Pass` when `createSession` + `getServiceAuth` + labeler POST all succeed. 37 + - **labeler-report-stage.AC5.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_service_auth_rejected`) when labeler returns non-2xx for the PDS-minted JWT. 38 + - **labeler-report-stage.AC5.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable, credentials are rejected, or `getServiceAuth` returns an error. 39 + - **labeler-report-stage.AC5.4 Skip:** emits `Skipped` with reason "requires --handle, --app-password, and --commit-report" when any of the three are missing. 40 + 41 + ### labeler-report-stage.AC6: PDS-proxied mode 42 + - **labeler-report-stage.AC6.1 Success:** `pds_proxied_accepted` emits `Pass` when the proxied POST returns 2xx from the PDS. 43 + - **labeler-report-stage.AC6.2 Failure (labeler-side):** emits `SpecViolation` (diagnostic `labeler::report::pds_proxied_rejected`) when the PDS surfaces a labeler-side rejection (status/error envelope indicating downstream 4xx/5xx). 44 + - **labeler-report-stage.AC6.3 Failure (PDS-side):** emits `NetworkError` when PDS is unreachable or rejects the proxy attempt itself. 45 + - **labeler-report-stage.AC6.4 Skip:** emits `Skipped` ("requires --handle, --app-password, and --commit-report") when any of the three are missing. 46 + 47 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants (re-verified) 48 + - **labeler-report-stage.AC7.1 Row count:** exactly 10 rows in every run (re-verified via end-to-end snapshot tests in Phase 8). 49 + 50 + ### labeler-report-stage.AC8: CLI flag handling 51 + - **labeler-report-stage.AC8.1 Both-or-neither:** `--handle` without `--app-password` (and vice versa) produces a clap parse error before any stage runs. 52 + - **labeler-report-stage.AC8.3 Subject override:** `--report-subject-did <did>` replaces the computed default subject in the body of committing checks. 53 + - **labeler-report-stage.AC8.4 Exit codes:** Exit 1 on any `SpecViolation`; exit 2 on any `NetworkError` absent `SpecViolation`; exit 0 otherwise. 54 + 55 + --- 56 + 57 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 58 + <!-- START_TASK_1 --> 59 + ### Task 1: Add `--handle` and `--app-password` with clap `requires` 60 + 61 + **Verifies:** AC8.1. 62 + 63 + **Files:** 64 + - Modify: `src/commands/test/labeler.rs` — extend `LabelerCmd` with two new flags using `requires`. 65 + 66 + **Implementation:** 67 + 68 + ```rust 69 + #[derive(Debug, Args)] 70 + pub struct LabelerCmd { 71 + // ... existing fields ... 72 + 73 + /// User handle for PDS-mediated report modes. Must be supplied together 74 + /// with --app-password; enables `pds_service_auth_accepted` and 75 + /// `pds_proxied_accepted` checks when combined with --commit-report. 76 + #[arg(long, requires = "app_password")] 77 + pub handle: Option<String>, 78 + 79 + /// App password for PDS-mediated report modes. Must be supplied 80 + /// together with --handle. 81 + #[arg(long, requires = "handle")] 82 + pub app_password: Option<String>, 83 + } 84 + ``` 85 + 86 + **Notes for the implementor:** 87 + - clap converts the field name to kebab-case automatically: `handle` → `--handle`, `app_password` → `--app-password`. The `requires = "..."` string references the *struct field name* (snake_case), not the flag name. 88 + - clap's `requires` is symmetric: both `--handle --app-password alice` and `--app-password alice --handle foo` parse; either one alone fails with a parse error. 89 + 90 + **Testing:** 91 + 92 + Add to `tests/labeler_cli.rs`: 93 + 94 + ```rust 95 + #[test] 96 + fn ac8_1_handle_without_app_password_fails() { 97 + let output = Command::cargo_bin("atproto-devtool") 98 + .expect("bin") 99 + .args([ 100 + "test", 101 + "labeler", 102 + "alice.bsky.social", 103 + "--handle", 104 + "alice.bsky.social", 105 + ]) 106 + .output() 107 + .expect("run"); 108 + assert!(!output.status.success(), "expected parse failure"); 109 + let stderr = String::from_utf8_lossy(&output.stderr); 110 + assert!( 111 + stderr.contains("--app-password") || stderr.contains("app_password"), 112 + "stderr should mention missing --app-password, got: {stderr}" 113 + ); 114 + } 115 + 116 + #[test] 117 + fn ac8_1_app_password_without_handle_fails() { 118 + let output = Command::cargo_bin("atproto-devtool") 119 + .expect("bin") 120 + .args([ 121 + "test", 122 + "labeler", 123 + "alice.bsky.social", 124 + "--app-password", 125 + "xxxx-xxxx-xxxx-xxxx", 126 + ]) 127 + .output() 128 + .expect("run"); 129 + assert!(!output.status.success(), "expected parse failure"); 130 + } 131 + ``` 132 + 133 + **Verification:** 134 + Run: `cargo test --test labeler_cli ac8_1_` 135 + Expected: both tests pass. 136 + 137 + **Commit:** `feat(labeler): add --handle and --app-password with clap requires` 138 + <!-- END_TASK_1 --> 139 + 140 + <!-- START_TASK_2 --> 141 + ### Task 2: `PdsXrpcClient` trait + production `RealPdsXrpcClient` + test `FakePdsXrpcClient` 142 + 143 + **Verifies:** AC5.1, AC5.3, AC6.1, AC6.3 (infrastructure). 144 + 145 + **Files:** 146 + - Modify: `src/commands/test/labeler/create_report.rs` — add the trait and real impl. 147 + - Modify: `tests/common/mod.rs` — add `FakePdsXrpcClient`. 148 + 149 + **Implementation:** 150 + 151 + ```rust 152 + /// A response from the PDS XRPC seam. 153 + #[derive(Debug)] 154 + pub struct RawPdsXrpcResponse { 155 + pub status: reqwest::StatusCode, 156 + pub raw_body: Arc<[u8]>, 157 + pub content_type: Option<String>, 158 + pub source_url: String, 159 + } 160 + 161 + /// Narrow seam for POSTing/GETting against the user's PDS. 162 + /// 163 + /// The existing `HttpClient` in `src/common/identity.rs` is GET-only and 164 + /// does not support bearer headers or request bodies. This trait exists 165 + /// to keep those capabilities out of the identity-resolution seam. 166 + #[async_trait] 167 + pub trait PdsXrpcClient: Send + Sync { 168 + /// POST `body` (JSON-serialized) to the PDS endpoint at the given path 169 + /// (e.g., `"xrpc/com.atproto.server.createSession"`). Optional bearer 170 + /// and `atproto-proxy` headers. 171 + async fn post( 172 + &self, 173 + path: &str, 174 + bearer: Option<&str>, 175 + atproto_proxy: Option<&str>, 176 + body: &serde_json::Value, 177 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 178 + 179 + /// GET the PDS endpoint at the given path with optional bearer and 180 + /// URL-encoded query pairs. 181 + async fn get( 182 + &self, 183 + path: &str, 184 + bearer: Option<&str>, 185 + query: &[(&str, &str)], 186 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 187 + } 188 + 189 + pub struct RealPdsXrpcClient { 190 + client: reqwest::Client, 191 + base: url::Url, 192 + } 193 + 194 + impl RealPdsXrpcClient { 195 + pub fn new(client: reqwest::Client, base: url::Url) -> Self { 196 + Self { client, base } 197 + } 198 + } 199 + 200 + #[async_trait] 201 + impl PdsXrpcClient for RealPdsXrpcClient { 202 + async fn post( 203 + &self, 204 + path: &str, 205 + bearer: Option<&str>, 206 + atproto_proxy: Option<&str>, 207 + body: &serde_json::Value, 208 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 209 + let mut url = self.base.clone(); 210 + url.set_path(path); 211 + let source_url = url.to_string(); 212 + let mut req = self 213 + .client 214 + .post(url.as_str()) 215 + .header("Content-Type", "application/json") 216 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 217 + if let Some(b) = bearer { 218 + req = req.header("Authorization", format!("Bearer {b}")); 219 + } 220 + if let Some(p) = atproto_proxy { 221 + req = req.header("atproto-proxy", p); 222 + } 223 + let resp = req 224 + .send() 225 + .await 226 + .map_err(|e| CreateReportStageError::Transport { 227 + message: e.to_string(), 228 + source: Some(Box::new(e)), 229 + })?; 230 + let status = resp.status(); 231 + let content_type = resp 232 + .headers() 233 + .get(reqwest::header::CONTENT_TYPE) 234 + .and_then(|h| h.to_str().ok()) 235 + .map(|s| s.to_ascii_lowercase()); 236 + let body = resp 237 + .bytes() 238 + .await 239 + .map_err(|e| CreateReportStageError::Transport { 240 + message: e.to_string(), 241 + source: Some(Box::new(e)), 242 + })?; 243 + Ok(RawPdsXrpcResponse { 244 + status, 245 + raw_body: Arc::from(body.as_ref()), 246 + content_type, 247 + source_url, 248 + }) 249 + } 250 + 251 + async fn get( 252 + &self, 253 + path: &str, 254 + bearer: Option<&str>, 255 + query: &[(&str, &str)], 256 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 257 + let mut url = self.base.clone(); 258 + url.set_path(path); 259 + { 260 + let mut pairs = url.query_pairs_mut(); 261 + for (k, v) in query { 262 + pairs.append_pair(k, v); 263 + } 264 + } 265 + let source_url = url.to_string(); 266 + let mut req = self.client.get(url.as_str()); 267 + if let Some(b) = bearer { 268 + req = req.header("Authorization", format!("Bearer {b}")); 269 + } 270 + let resp = req 271 + .send() 272 + .await 273 + .map_err(|e| CreateReportStageError::Transport { 274 + message: e.to_string(), 275 + source: Some(Box::new(e)), 276 + })?; 277 + let status = resp.status(); 278 + let content_type = resp 279 + .headers() 280 + .get(reqwest::header::CONTENT_TYPE) 281 + .and_then(|h| h.to_str().ok()) 282 + .map(|s| s.to_ascii_lowercase()); 283 + let body = resp 284 + .bytes() 285 + .await 286 + .map_err(|e| CreateReportStageError::Transport { 287 + message: e.to_string(), 288 + source: Some(Box::new(e)), 289 + })?; 290 + Ok(RawPdsXrpcResponse { 291 + status, 292 + raw_body: Arc::from(body.as_ref()), 293 + content_type, 294 + source_url, 295 + }) 296 + } 297 + } 298 + ``` 299 + 300 + For `tests/common/mod.rs`, add a `FakePdsXrpcClient` that follows the same scripted-FIFO + request-capture pattern as `FakeCreateReportTee`. Key difference: script-keyed-by-path OR sequential. Go sequential (FIFO) for consistency with the other fakes. 301 + 302 + ```rust 303 + // In tests/common/mod.rs: 304 + 305 + use atproto_devtool::commands::test::labeler::create_report::{ 306 + PdsXrpcClient, RawPdsXrpcResponse, 307 + }; 308 + 309 + #[derive(Debug, Clone)] 310 + pub enum FakePdsXrpcResponse { 311 + Transport { message: String }, 312 + Response { status: u16, body: Vec<u8> }, 313 + } 314 + 315 + #[derive(Debug, Clone)] 316 + pub struct RecordedPdsRequest { 317 + pub method: &'static str, // "POST" or "GET" 318 + pub path: String, 319 + pub bearer: Option<String>, 320 + pub atproto_proxy: Option<String>, 321 + pub body: Option<serde_json::Value>, 322 + pub query: Vec<(String, String)>, 323 + } 324 + 325 + pub struct FakePdsXrpcClient { 326 + scripts: Arc<Mutex<Vec<FakePdsXrpcResponse>>>, 327 + recorded: Arc<Mutex<Vec<RecordedPdsRequest>>>, 328 + } 329 + 330 + // ... impl new, enqueue, recorded_requests, last_request matching FakeCreateReportTee ... 331 + 332 + #[async_trait] 333 + impl PdsXrpcClient for FakePdsXrpcClient { 334 + async fn post( 335 + &self, 336 + path: &str, 337 + bearer: Option<&str>, 338 + atproto_proxy: Option<&str>, 339 + body: &serde_json::Value, 340 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 341 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 342 + method: "POST", 343 + path: path.to_string(), 344 + bearer: bearer.map(String::from), 345 + atproto_proxy: atproto_proxy.map(String::from), 346 + body: Some(body.clone()), 347 + query: Vec::new(), 348 + }); 349 + self.dispatch_next() 350 + } 351 + async fn get( 352 + &self, 353 + path: &str, 354 + bearer: Option<&str>, 355 + query: &[(&str, &str)], 356 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 357 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 358 + method: "GET", 359 + path: path.to_string(), 360 + bearer: bearer.map(String::from), 361 + atproto_proxy: None, 362 + body: None, 363 + query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), 364 + }); 365 + self.dispatch_next() 366 + } 367 + } 368 + 369 + // dispatch_next pulls the head of scripts, returns matching Result. 370 + ``` 371 + 372 + **Testing:** 373 + 374 + Smoke tests in `tests/common_fakes.rs` (or wherever FakeCreateReportTee tests live) — two quick asserts that enqueue + call + recorded work. 375 + 376 + **Verification:** 377 + Run: `cargo build` 378 + Expected: clean. 379 + 380 + **Commit:** `feat(create_report): PdsXrpcClient trait + Real impl` 381 + `feat(test-common): FakePdsXrpcClient` 382 + (two commits is fine; one is fine too — implementor's call) 383 + <!-- END_TASK_2 --> 384 + 385 + <!-- START_TASK_3 --> 386 + ### Task 3: `PdsJwtFetcher` and `PdsProxiedPoster` concrete types 387 + 388 + **Verifies:** AC5.1, AC5.3, AC6.1, AC6.3 (infrastructure). 389 + 390 + **Files:** 391 + - Modify: `src/commands/test/labeler/create_report.rs` — append. 392 + 393 + **Implementation:** 394 + 395 + ```rust 396 + /// Fetches a service-auth JWT from a PDS by first creating a session and 397 + /// then calling `getServiceAuth`. Used in mode-2 (`pds_service_auth_accepted`). 398 + pub struct PdsJwtFetcher<'a> { 399 + client: &'a dyn PdsXrpcClient, 400 + } 401 + 402 + #[derive(Debug)] 403 + pub enum PdsJwtFetchError { 404 + /// Any failure at the PDS boundary — unreachable, auth rejected, etc. 405 + Pds { message: String }, 406 + } 407 + 408 + impl<'a> PdsJwtFetcher<'a> { 409 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 410 + Self { client } 411 + } 412 + 413 + /// Run `createSession` then `getServiceAuth`, returning the minted 414 + /// service-auth JWT. 415 + pub async fn fetch( 416 + &self, 417 + handle: &str, 418 + app_password: &str, 419 + aud: &str, 420 + lxm: &str, 421 + exp_absolute_unix: i64, 422 + ) -> Result<String, PdsJwtFetchError> { 423 + // 1. createSession. 424 + let body = serde_json::json!({ 425 + "identifier": handle, 426 + "password": app_password, 427 + }); 428 + let resp = self 429 + .client 430 + .post("xrpc/com.atproto.server.createSession", None, None, &body) 431 + .await 432 + .map_err(|e| PdsJwtFetchError::Pds { 433 + message: format!("createSession transport: {e}"), 434 + })?; 435 + if !resp.status.is_success() { 436 + return Err(PdsJwtFetchError::Pds { 437 + message: format!( 438 + "createSession returned status {}", 439 + resp.status 440 + ), 441 + }); 442 + } 443 + let session: serde_json::Value = serde_json::from_slice(&resp.raw_body) 444 + .map_err(|e| PdsJwtFetchError::Pds { 445 + message: format!("createSession body not JSON: {e}"), 446 + })?; 447 + let access_jwt = session["accessJwt"] 448 + .as_str() 449 + .ok_or_else(|| PdsJwtFetchError::Pds { 450 + message: "createSession response missing accessJwt".to_string(), 451 + })? 452 + .to_string(); 453 + 454 + // 2. getServiceAuth (GET with query params). 455 + let exp_s = exp_absolute_unix.to_string(); 456 + let resp = self 457 + .client 458 + .get( 459 + "xrpc/com.atproto.server.getServiceAuth", 460 + Some(&access_jwt), 461 + &[("aud", aud), ("lxm", lxm), ("exp", &exp_s)], 462 + ) 463 + .await 464 + .map_err(|e| PdsJwtFetchError::Pds { 465 + message: format!("getServiceAuth transport: {e}"), 466 + })?; 467 + if !resp.status.is_success() { 468 + return Err(PdsJwtFetchError::Pds { 469 + message: format!( 470 + "getServiceAuth returned status {}", 471 + resp.status 472 + ), 473 + }); 474 + } 475 + let auth: serde_json::Value = serde_json::from_slice(&resp.raw_body) 476 + .map_err(|e| PdsJwtFetchError::Pds { 477 + message: format!("getServiceAuth body not JSON: {e}"), 478 + })?; 479 + let token = auth["token"] 480 + .as_str() 481 + .ok_or_else(|| PdsJwtFetchError::Pds { 482 + message: "getServiceAuth response missing token".to_string(), 483 + })? 484 + .to_string(); 485 + 486 + Ok(token) 487 + } 488 + } 489 + 490 + /// Posts `com.atproto.moderation.createReport` to the PDS (not the 491 + /// labeler) with the `atproto-proxy` header, letting the PDS mint and 492 + /// forward the JWT itself. 493 + pub struct PdsProxiedPoster<'a> { 494 + client: &'a dyn PdsXrpcClient, 495 + } 496 + 497 + impl<'a> PdsProxiedPoster<'a> { 498 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 499 + Self { client } 500 + } 501 + 502 + /// Post the createReport body through the PDS with the given user 503 + /// access JWT. Returns the `RawPdsXrpcResponse` so the caller can 504 + /// classify success / labeler-side rejection / PDS-side rejection. 505 + pub async fn post( 506 + &self, 507 + labeler_did: &str, 508 + access_jwt: &str, 509 + body: &serde_json::Value, 510 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 511 + self.client 512 + .post( 513 + "xrpc/com.atproto.moderation.createReport", 514 + Some(access_jwt), 515 + Some(&format!("{labeler_did}#atproto_labeler")), 516 + body, 517 + ) 518 + .await 519 + } 520 + } 521 + ``` 522 + 523 + **Notes for the implementor:** 524 + - `PdsJwtFetchError` is a single-variant enum because the stage treats *every* PDS-side failure as `NetworkError` (AC5.3 / AC6.3). We carry a human-readable message; no miette diagnostic is wired (NetworkError rows don't display diagnostics per `src/commands/test/labeler/report.rs:274-276`). 525 + 526 + **Testing:** Covered by Task 4 integration tests. 527 + 528 + **Verification:** 529 + Run: `cargo build` 530 + Expected: clean. 531 + 532 + **Commit:** `feat(create_report): PdsJwtFetcher and PdsProxiedPoster` 533 + <!-- END_TASK_3 --> 534 + <!-- END_SUBCOMPONENT_A --> 535 + 536 + <!-- START_SUBCOMPONENT_B (tasks 4-6) --> 537 + <!-- START_TASK_4 --> 538 + ### Task 4: Wire `report::pds_service_auth_accepted` and `report::pds_proxied_accepted` checks 539 + 540 + **Verifies:** AC5.1-AC5.4, AC6.1-AC6.4. 541 + 542 + **Files:** 543 + - Modify: `src/commands/test/labeler/pipeline.rs` — add `pds_xrpc_client: Option<&'a dyn PdsXrpcClient>` to `LabelerOptions`. (Alternatively a `PdsXrpcKind::Real/Test` enum matching `HttpTee`; pick the kind-enum pattern for consistency.) 544 + - Modify: `src/commands/test/labeler/create_report.rs` — replace the Phase 4 Phase-8 stubs with real check logic. 545 + - Modify: `src/commands/test/labeler.rs` — construct `RealPdsXrpcClient` and `PdsCredentials` in `LabelerCmd::run` when `handle` + `app_password` are set; populate `LabelerOptions` fields. 546 + 547 + **Implementation (stage check logic):** 548 + 549 + Replace this block in `run()`: 550 + 551 + ```rust 552 + results.push(Check::PdsServiceAuthAccepted.skip("not yet implemented (Phase 8)")); 553 + results.push(Check::PdsProxiedAccepted.skip("not yet implemented (Phase 8)")); 554 + ``` 555 + 556 + with: 557 + 558 + ```rust 559 + // Compute the gating precondition common to both PDS checks. 560 + let pds_gate_reason: &'static str = 561 + "requires --handle, --app-password, and --commit-report"; 562 + let pds_ready = 563 + opts.commit_report && opts.pds_credentials.is_some() && opts.pds_xrpc_client.is_some(); 564 + 565 + if !pds_ready { 566 + results.push(Check::PdsServiceAuthAccepted.skip(pds_gate_reason)); 567 + results.push(Check::PdsProxiedAccepted.skip(pds_gate_reason)); 568 + } else { 569 + // Safe to unwrap thanks to pds_ready. 570 + let creds = opts.pds_credentials.expect("pds_ready implies creds"); 571 + let pds_client = opts.pds_xrpc_client.expect("pds_ready implies client"); 572 + 573 + // Recompute locality for pollution-avoidance. 574 + let is_local = crate::common::identity::is_local_labeler_hostname( 575 + &id_facts.labeler_endpoint, 576 + ); 577 + let reason_type = pollution::choose_reason_type( 578 + id_facts.reason_types.as_deref().unwrap_or(&[]), 579 + is_local, 580 + ); 581 + // For PDS modes the reporter DID is the PDS-resolved user DID, not 582 + // the self-mint DID. We use the session's DID after createSession; 583 + // for body-shaping before the session is known we tentatively use 584 + // the override (if any) or defer — simplest: run createSession first 585 + // and use its `did` field. 586 + 587 + // AC5.1-AC5.4 — PDS getServiceAuth direct-POST. 588 + let fetcher = PdsJwtFetcher::new(pds_client); 589 + // createSession implicitly happens inside fetcher.fetch; we also 590 + // need the user DID from it for subject shaping. Refactor: 591 + match fetch_session_and_did(pds_client, &creds.handle, &creds.app_password).await { 592 + Err(message) => { 593 + results.push(Check::PdsServiceAuthAccepted.network_error(message.clone())); 594 + // AC6.3: if session fetch fails, proxied mode also fails at PDS. 595 + results.push(Check::PdsProxiedAccepted.network_error(message)); 596 + } 597 + Ok(session) => { 598 + let user_did = Did(session.did); 599 + let access_jwt = session.access_jwt; 600 + let subject = pollution::choose_subject( 601 + id_facts.subject_types.as_deref().unwrap_or(&[]), 602 + &user_did, 603 + opts.report_subject_override, 604 + is_local, 605 + ); 606 + let sentinel = sentinel::build(opts.run_id, std::time::SystemTime::now()); 607 + let pds_body = serde_json::json!({ 608 + "reasonType": reason_type, 609 + "subject": subject, 610 + "reason": sentinel, 611 + }); 612 + 613 + // Mode 2: getServiceAuth direct-POST. 614 + let exp_abs = now + 60; 615 + match fetcher 616 + .fetch( 617 + &creds.handle, 618 + &creds.app_password, 619 + &id_facts.did.0, 620 + "com.atproto.moderation.createReport", 621 + exp_abs, 622 + ) 623 + .await 624 + { 625 + Err(PdsJwtFetchError::Pds { message }) => { 626 + results.push(Check::PdsServiceAuthAccepted.network_error(message)); 627 + } 628 + Ok(service_jwt) => { 629 + match report_tee 630 + .post_create_report(Some(&service_jwt), &pds_body) 631 + .await 632 + { 633 + Ok(resp) if resp.status.is_success() => { 634 + results.push(Check::PdsServiceAuthAccepted.pass()); 635 + } 636 + Ok(resp) => { 637 + let diag = Box::new(PdsServiceAuthRejected { 638 + status: resp.status.as_u16(), 639 + source_code: body_as_named_source(&resp), 640 + span: None, 641 + }); 642 + results.push( 643 + Check::PdsServiceAuthAccepted.spec_violation(Some(diag)), 644 + ); 645 + } 646 + Err(CreateReportStageError::Transport { message, .. }) => { 647 + // Labeler-side transport failure during direct POST. 648 + results.push( 649 + Check::PdsServiceAuthAccepted.network_error(message), 650 + ); 651 + } 652 + } 653 + } 654 + } 655 + 656 + // Mode 3: PDS-proxied. 657 + let proxier = PdsProxiedPoster::new(pds_client); 658 + match proxier 659 + .post(&id_facts.did.0, &access_jwt, &pds_body) 660 + .await 661 + { 662 + Err(CreateReportStageError::Transport { message, .. }) => { 663 + // Transport to the PDS itself; classify PDS-side. 664 + results.push(Check::PdsProxiedAccepted.network_error(message)); 665 + } 666 + Ok(resp) if resp.status.is_success() => { 667 + results.push(Check::PdsProxiedAccepted.pass()); 668 + } 669 + Ok(resp) => { 670 + // PDS surfaced a non-2xx. Interpret per envelope to 671 + // distinguish PDS-side vs labeler-side: 672 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 673 + let err_name = envelope.as_ref().and_then(|e| e.error.clone()); 674 + let is_upstream_label_error = matches!( 675 + err_name.as_deref(), 676 + Some("UpstreamError") | Some("UpstreamFailure") 677 + ) || resp.status.as_u16() == 502 678 + || resp.status.as_u16() == 504; 679 + if is_upstream_label_error { 680 + // AC6.2: labeler-side rejection surfaced by PDS. 681 + let diag = Box::new(PdsProxiedRejected { 682 + status: resp.status.as_u16(), 683 + source_code: body_as_named_source_from_pds(&resp), 684 + span: None, 685 + }); 686 + results.push( 687 + Check::PdsProxiedAccepted.spec_violation(Some(diag)), 688 + ); 689 + } else { 690 + // AC6.3: PDS-side rejection of the proxy attempt. 691 + results.push(Check::PdsProxiedAccepted.network_error( 692 + format!("PDS rejected proxy attempt with status {}", resp.status), 693 + )); 694 + } 695 + } 696 + } 697 + } 698 + } 699 + } 700 + 701 + /// Convenience wrapper that does createSession and returns both the DID 702 + /// and the accessJwt. Mirrors part of `PdsJwtFetcher::fetch` but also 703 + /// exposes the user DID. 704 + struct SessionResult { 705 + did: String, 706 + access_jwt: String, 707 + } 708 + 709 + async fn fetch_session_and_did( 710 + client: &dyn PdsXrpcClient, 711 + handle: &str, 712 + app_password: &str, 713 + ) -> Result<SessionResult, String> { 714 + let body = serde_json::json!({ "identifier": handle, "password": app_password }); 715 + let resp = client 716 + .post("xrpc/com.atproto.server.createSession", None, None, &body) 717 + .await 718 + .map_err(|e| format!("createSession transport: {e}"))?; 719 + if !resp.status.is_success() { 720 + return Err(format!("createSession returned {}", resp.status)); 721 + } 722 + let session: serde_json::Value = 723 + serde_json::from_slice(&resp.raw_body).map_err(|e| format!("createSession body: {e}"))?; 724 + let did = session["did"] 725 + .as_str() 726 + .ok_or("createSession missing did")? 727 + .to_string(); 728 + let access_jwt = session["accessJwt"] 729 + .as_str() 730 + .ok_or("createSession missing accessJwt")? 731 + .to_string(); 732 + Ok(SessionResult { did, access_jwt }) 733 + } 734 + 735 + /// Named source for a PDS response (source_url is the PDS URL). 736 + fn body_as_named_source_from_pds(resp: &RawPdsXrpcResponse) -> NamedSource<Arc<[u8]>> { 737 + let pretty = pretty_json_for_display(&resp.raw_body); 738 + NamedSource::new(resp.source_url.clone(), Arc::from(pretty)) 739 + } 740 + ``` 741 + 742 + Add the two new diagnostics: 743 + 744 + ```rust 745 + #[derive(Debug, Error, Diagnostic)] 746 + #[error("Labeler rejected PDS-minted service-auth JWT (status {status})")] 747 + #[diagnostic( 748 + code = "labeler::report::pds_service_auth_rejected", 749 + help = "The PDS issued a service-auth JWT for this user bound to the labeler's DID and the createReport NSID; the labeler should have accepted it." 750 + )] 751 + pub struct PdsServiceAuthRejected { 752 + pub status: u16, 753 + #[source_code] 754 + pub source_code: NamedSource<Arc<[u8]>>, 755 + #[label("rejected here")] 756 + pub span: Option<SourceSpan>, 757 + } 758 + 759 + #[derive(Debug, Error, Diagnostic)] 760 + #[error("Labeler rejected PDS-proxied createReport (status {status})")] 761 + #[diagnostic( 762 + code = "labeler::report::pds_proxied_rejected", 763 + help = "The PDS forwarded the createReport call on the user's behalf; the downstream labeler reached it but rejected the submission." 764 + )] 765 + pub struct PdsProxiedRejected { 766 + pub status: u16, 767 + #[source_code] 768 + pub source_code: NamedSource<Arc<[u8]>>, 769 + #[label("rejected here")] 770 + pub span: Option<SourceSpan>, 771 + } 772 + ``` 773 + 774 + **Notes for the implementor:** 775 + - The `UpstreamError` / 502 / 504 heuristic for distinguishing labeler-side vs PDS-side is an approximation — PDSes don't have a uniform error vocabulary yet. Document it loosely in the code comment. The tool should *err toward* `SpecViolation` when the status looks like a downstream problem (matches the design's "PDS surfaces a labeler-side rejection" phrasing in AC6.2). 776 + - The `now` variable is already in scope from Phase 6's template construction. Keep using it. 777 + 778 + **Implementation (pipeline + CLI plumbing):** 779 + 780 + ```rust 781 + // In pipeline.rs LabelerOptions: 782 + pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>, 783 + 784 + // In LabelerCmd::run: 785 + use crate::commands::test::labeler::create_report::RealPdsXrpcClient; 786 + 787 + // After identity stage (for handle targets) or inline for explicit PDS: 788 + let pds_credentials = match (self.handle.as_deref(), self.app_password.as_deref()) { 789 + (Some(h), Some(p)) => Some(pipeline::PdsCredentials { 790 + handle: h.to_string(), 791 + app_password: p.to_string(), 792 + }), 793 + _ => None, 794 + }; 795 + let pds_credentials_ref = pds_credentials.as_ref(); 796 + 797 + // For the PDS client URL: use identity_facts.pds_endpoint once identity 798 + // has run. But LabelerCmd::run constructs the options BEFORE running the 799 + // pipeline. Resolution: construct RealPdsXrpcClient lazily inside 800 + // run_pipeline, after identity. That means threading the raw shared 801 + // reqwest::Client through options. 802 + // 803 + // Simpler: pass the shared client as-is and let the report stage 804 + // construct the RealPdsXrpcClient from `identity_facts.pds_endpoint` at 805 + // stage entry. Amend LabelerOptions to carry an `Option<&reqwest::Client>` 806 + // for that purpose — or reuse the existing HttpTee::Real(client) reference 807 + // (which already carries it). 808 + // 809 + // Pipeline-side change: when pds_credentials is Some and HttpTee::Real, in 810 + // pipeline.rs's report-stage branch, construct RealPdsXrpcClient pointing 811 + // at identity_facts.pds_endpoint and pass it as the `pds_xrpc_client` 812 + // opt. 813 + ``` 814 + 815 + **Pipeline edit (revisited):** 816 + 817 + ```rust 818 + // In run_pipeline, in the report-stage branch: 819 + let pds_xrpc_client_owned: Option<RealPdsXrpcClient> = match (&opts.pds_credentials, &opts.create_report_tee) { 820 + (Some(_), CreateReportTeeKind::Real(client)) => { 821 + identity_output.facts.as_ref().map(|f| { 822 + // `client` is `&reqwest::Client`; dereference once and clone. 823 + RealPdsXrpcClient::new((*client).clone(), f.pds_endpoint.clone()) 824 + }) 825 + } 826 + _ => None, 827 + }; 828 + let pds_xrpc_client_ref: Option<&dyn PdsXrpcClient> = pds_xrpc_client_owned 829 + .as_ref() 830 + .map(|c| c as &dyn PdsXrpcClient); 831 + ``` 832 + 833 + Plus a corresponding `CreateReportTeeKind::Test` path where tests supply a `FakePdsXrpcClient` directly via a new field on `LabelerOptions`: 834 + 835 + ```rust 836 + pub pds_xrpc_client_override: Option<&'a dyn PdsXrpcClient>, 837 + ``` 838 + 839 + If `pds_xrpc_client_override.is_some()`, use it; else construct `RealPdsXrpcClient`. This keeps the test path explicit and the production path simple. 840 + 841 + **Testing:** 842 + 843 + Add AC5 and AC6 tests to `tests/labeler_report.rs`. Follow the same pattern as AC4 — enqueue responses on both the `FakeCreateReportTee` (for labeler POSTs) AND the `FakePdsXrpcClient` (for PDS calls), assert on row status and diagnostic codes. 844 + 845 + Tests to add: 846 + - `ac5_1_full_flow_passes` — createSession OK, getServiceAuth OK, labeler POST OK → Pass. 847 + - `ac5_2_labeler_rejects_service_auth_jwt` — createSession + getServiceAuth OK; labeler POST returns 401 → SpecViolation + `pds_service_auth_rejected`. 848 + - `ac5_3_pds_unreachable` — createSession transport error → NetworkError on the `pds_service_auth_accepted` row. 849 + - `ac5_4_missing_creds_or_commit_skips` — with/without each of handle+app_password+commit-report → Skipped with the gate reason. 850 + - `ac6_1_proxied_pass` — createSession OK; proxied POST returns 200 → Pass. 851 + - `ac6_2_labeler_side_rejection_via_proxy` — proxied POST returns 502 `UpstreamError` → SpecViolation + `pds_proxied_rejected`. 852 + - `ac6_3_pds_rejects_proxy` — proxied POST returns 400 `InvalidRequest` from PDS (not downstream-flavored) → NetworkError. 853 + - `ac6_4_missing_creds_or_commit_skips` — same as AC5.4 but for the proxied row. 854 + 855 + **Verification:** 856 + Run: `cargo test --test labeler_report ac5_ ac6_` 857 + Expected: all AC5 + AC6 tests pass. 858 + 859 + **Commit:** `feat(create_report): wire PDS service-auth and PDS-proxied checks (AC5, AC6)` 860 + <!-- END_TASK_4 --> 861 + 862 + <!-- START_TASK_5 --> 863 + ### Task 5: `--report-subject-did` integration test (AC8.3) 864 + 865 + **Verifies:** AC8.3. 866 + 867 + **Files:** 868 + - Modify: `tests/labeler_report.rs` — add a test that passes `report_subject_override = Some(&Did("did:plc:override".to_string()))` through options, drives the self_mint_accepted check, and asserts `tee.last_request().body["subject"]["did"]` equals `"did:plc:override"`. 869 + 870 + **Implementation:** Test pattern follows AC4.1. Implementation of `report_subject_override` is already complete in Phase 7 via `pollution::choose_subject`. This phase just verifies. 871 + 872 + **Verification:** 873 + Run: `cargo test --test labeler_report ac8_3` 874 + Expected: passes. 875 + 876 + **Commit:** `test(create_report): AC8.3 subject override` 877 + <!-- END_TASK_5 --> 878 + 879 + <!-- START_TASK_6 --> 880 + ### Task 6: End-to-end snapshot fixtures + AC7.1/AC8.4 881 + 882 + **Verifies:** AC7.1, AC8.4. 883 + 884 + **Files:** 885 + - Create: `tests/fixtures/labeler/report/all_pass_local_labeler/.gitkeep`, `all_pass_full_suite/.gitkeep`, `all_fail_misconfigured_labeler/.gitkeep`. 886 + - Modify: `tests/labeler_report.rs` or extend `tests/labeler_endtoend.rs` — add three whole-pipeline snapshot tests that run `run_pipeline` end-to-end with specific fake wirings and snapshot the rendered report. 887 + - Modify: `tests/labeler_cli.rs` — add AC8.4 exit-code tests. 888 + 889 + **Implementation (snapshot tests):** 890 + 891 + ```rust 892 + // In tests/labeler_endtoend.rs (or a new file): 893 + 894 + #[tokio::test] 895 + async fn report_all_pass_local_labeler_snapshot() { 896 + // Compose: local labeler endpoint, self-mint signer, commit=true, 897 + // no PDS credentials. 7 of 10 checks Pass (contract + 2 Phase 5 + 898 + // 4 Phase 6 + self_mint_accepted); PDS checks Skipped. 899 + // ... set up fakes, run run_pipeline ... 900 + let rendered: String = // render through RenderConfig { no_color: true } 901 + insta::assert_snapshot!("report_all_pass_local_labeler", rendered); 902 + } 903 + 904 + #[tokio::test] 905 + async fn report_all_pass_full_suite_snapshot() { 906 + // Compose: local labeler + self-mint + PDS creds + commit=true. 907 + // All 10 checks Pass. 908 + insta::assert_snapshot!("report_all_pass_full_suite", rendered); 909 + } 910 + 911 + #[tokio::test] 912 + async fn report_all_fail_misconfigured_labeler_snapshot() { 913 + // Compose: labeler that accepts everything (2xx for every POST). 914 + // Phase 5 + Phase 6 SpecViolations; Phase 7 + Phase 8 Pass. 915 + insta::assert_snapshot!("report_all_fail_misconfigured_labeler", rendered); 916 + } 917 + 918 + #[tokio::test] 919 + async fn report_stage_always_emits_10_rows() { 920 + // AC7.1: run with various flag combinations and assert the count. 921 + for (contract, commit, pds, self_mint_viable) in [ 922 + (true, false, false, false), 923 + (true, false, false, true), 924 + (true, true, false, true), 925 + (true, true, true, true), 926 + (false, false, false, false), 927 + (false, true, false, false), 928 + ] { 929 + let report = run_with_config(contract, commit, pds, self_mint_viable).await; 930 + let report_rows: Vec<_> = report.results.iter() 931 + .filter(|r| r.id.starts_with("report::")) 932 + .collect(); 933 + assert_eq!(report_rows.len(), 10, "case ({contract}, {commit}, {pds}, {self_mint_viable})"); 934 + } 935 + } 936 + ``` 937 + 938 + **Implementation (CLI exit-code tests):** 939 + 940 + ```rust 941 + // In tests/labeler_cli.rs: 942 + 943 + #[test] 944 + fn ac8_4_spec_violation_exits_1() { 945 + // Drive the CLI with a mocked target that produces a known 946 + // SpecViolation. Simplest: construct a LabelerReport programmatically 947 + // in a unit test inside src/commands/test/labeler/report.rs and 948 + // assert exit_code() — but that's already tested at 949 + // src/commands/test/labeler/report.rs:361-406. Keep AC8.4 coverage 950 + // there plus a light CLI smoke test. 951 + // 952 + // Here, spawn the CLI against a non-existent endpoint to trigger a 953 + // NetworkError path (exit 2) and assert the exit code. 954 + let out = Command::cargo_bin("atproto-devtool") 955 + .unwrap() 956 + .args(["test", "labeler", "https://doesnt-exist.example.test"]) 957 + .output() 958 + .unwrap(); 959 + // Depending on DNS availability, this may exit 2 (network) — don't 960 + // over-constrain; just assert exit is != 0 and != 1. 961 + assert_ne!(out.status.code(), Some(0)); 962 + } 963 + ``` 964 + 965 + **Notes for the implementor:** 966 + - AC8.4 is already well covered by the unit tests in `src/commands/test/labeler/report.rs:320-406`. The new CLI-level test just smokes the end-to-end exit-code path. 967 + - The all_fail_misconfigured_labeler snapshot exercises the "negatives Pass, positives Fail" matrix from the design — a useful reference test for regression detection. 968 + 969 + **Verification:** 970 + Run: `cargo test --test labeler_endtoend report_all_ report_stage_always` 971 + Expected: pass; accept new snapshots via `cargo insta review`. 972 + 973 + Run: `cargo test --test labeler_cli ac8_4_` 974 + Expected: pass. 975 + 976 + Run: `cargo test` (full suite) 977 + Expected: all Phase 1-8 tests pass; no regressions in pre-existing tests. 978 + 979 + **Commit:** `feat(create_report): end-to-end snapshots + AC7.1 + AC8.4` 980 + <!-- END_TASK_6 --> 981 + <!-- END_SUBCOMPONENT_B --> 982 + 983 + <!-- START_TASK_7 --> 984 + ### Task 7: Phase 8 integration check 985 + 986 + **Files:** None changed. 987 + 988 + **Implementation:** Final gate. 989 + 990 + **Verification:** 991 + Run: `cargo test` 992 + Expected: all Phase 1-8 tests pass. 993 + 994 + Run: `cargo insta pending-snapshots` 995 + Expected: none pending. 996 + 997 + Run: `cargo clippy --all-targets -- -D warnings` 998 + Expected: no warnings. 999 + 1000 + Run: `cargo fmt --check` 1001 + Expected: clean. 1002 + 1003 + Run: `cargo build --release` 1004 + Expected: clean release build. 1005 + 1006 + **Commit:** No new commit unless fixes were needed. 1007 + <!-- END_TASK_7 --> 1008 + 1009 + --- 1010 + 1011 + ## Phase 8 complete when 1012 + 1013 + - `--handle` / `--app-password` land with `requires` (AC8.1). 1014 + - `PdsXrpcClient` trait + `RealPdsXrpcClient` + `FakePdsXrpcClient` exist. 1015 + - `PdsJwtFetcher` and `PdsProxiedPoster` exist and drive the two PDS checks. 1016 + - `report::pds_service_auth_accepted` and `report::pds_proxied_accepted` are live (no longer stubs) with correct Pass/SpecViolation/NetworkError/Skipped classification. 1017 + - Three end-to-end snapshot fixtures pin the 10-row output contract for representative configurations. 1018 + - AC7.1 row-count invariant re-verified. 1019 + - AC8.3 subject override exercised. 1020 + - AC8.4 exit codes re-verified at CLI level. 1021 + - **Acceptance criteria satisfied:** AC5.1, AC5.2, AC5.3, AC5.4, AC6.1, AC6.2, AC6.3, AC6.4, AC7.1, AC7.2, AC8.1, AC8.2, AC8.3, AC8.4. 1022 + 1023 + ## Full AC coverage (post-Phase-8) 1024 + 1025 + | AC | Phase verifying | 1026 + |---|---| 1027 + | AC1.1–AC1.4 | Phase 4 | 1028 + | AC2.1–AC2.5 | Phase 5 | 1029 + | AC3.1–AC3.8 | Phase 6 | 1030 + | AC4.1–AC4.6 | Phase 7 | 1031 + | AC5.1–AC5.4 | Phase 8 | 1032 + | AC6.1–AC6.4 | Phase 8 | 1033 + | AC7.1 | Phases 4 + 8 | 1034 + | AC7.2 | Phase 4 | 1035 + | AC8.1 | Phase 8 | 1036 + | AC8.2 | Phase 2 + Phase 4 | 1037 + | AC8.3 | Phase 8 | 1038 + | AC8.4 | Phase 8 (existing coverage in `report.rs` tests) |
+142
docs/implementation-plans/2026-04-17-labeler-report-stage/test-requirements.md
··· 1 + # Labeler report stage — test requirements 2 + 3 + Last verified: 2026-04-17 4 + 5 + Generated from: 6 + - Design: docs/design-plans/2026-04-17-labeler-report-stage.md 7 + - Implementation: docs/implementation-plans/2026-04-17-labeler-report-stage/ 8 + 9 + ## Automated coverage 10 + 11 + ### labeler-report-stage.AC1: `report::contract_published` behavior 12 + 13 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 14 + |---|---|---|---|---|---| 15 + | AC1.1 | tests/labeler_report.rs | `ac1_1_contract_present_emits_pass` | 4 | (inline `make_identity_facts(Some(vec!["...#reasonSpam"]), Some(vec!["account"]))`) | `results[0].id == "report::contract_published"` and `results[0].status == CheckStatus::Pass`; row count is exactly 10 (AC7.1 sanity). | 16 + | AC1.2 | tests/labeler_report.rs | `ac1_2_contract_missing_without_commit_skips_stage` | 4 | (inline `make_identity_facts(None, None)`) | All 10 rows `CheckStatus::Skipped` with `skipped_reason == "labeler does not advertise report acceptance"`. | 17 + | AC1.3 | tests/labeler_report.rs | `ac1_3_contract_missing_with_commit_is_spec_violation` | 4 | (inline `make_identity_facts(None, None)`, `commit_report = true`) | `results[0].status == CheckStatus::SpecViolation`; attached diagnostic code equals `"labeler::report::contract_missing"`; `results[1..]` each `Skipped` with `"blocked by \`report::contract_published\`"`. | 18 + | AC1.4 | tests/labeler_report.rs | `ac1_4_empty_arrays_equivalent_to_absent` | 4 | (inline `make_identity_facts(Some(vec![]), Some(vec![]))`) | Behavior matches AC1.2 — `results[0]` is `Skipped` with the `"labeler does not advertise report acceptance"` reason. | 19 + | AC1.1–AC1.4 (rendered) | tests/labeler_report.rs | `snapshot_contract_present_no_commit`, `snapshot_contract_present_with_commit`, `snapshot_contract_missing_no_commit`, `snapshot_contract_missing_with_commit` | 4 | (inline synthetic `IdentityFacts`) | Four insta snapshots (`tests/snapshots/labeler_report__report_contract_present_no_commit.snap`, `..._with_commit.snap`, `..._missing_no_commit.snap`, `..._missing_with_commit.snap`) pin the full rendered report bytes including each check ID, status glyph, skipped reason string, and the `labeler::report::contract_missing` diagnostic code. | 20 + 21 + ### labeler-report-stage.AC2: No-JWT negative checks 22 + 23 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 24 + |---|---|---|---|---|---| 25 + | AC2.1 | tests/labeler_report.rs | `ac2_1_unauthenticated_401_with_envelope_passes` | 5 | (inline: `FakeCreateReportResponse::unauthorized("AuthenticationRequired", ...)` + `unauthorized("BadJwt", ...)`) | `results[1].id == "report::unauthenticated_rejected"` and `status == Pass`; `results[2].id == "report::malformed_bearer_rejected"` and `status == Pass` (co-asserts AC2.3 happy path). | 26 + | AC2.2 | tests/labeler_report.rs | `ac2_2_unauthenticated_200_is_spec_violation` | 5 | (inline: first `FakeCreateReportResponse::ok_empty()`, second `unauthorized`) | `results[1].status == SpecViolation`; diagnostic code `"labeler::report::unauthenticated_accepted"`. | 27 + | AC2.3 | tests/labeler_report.rs | `ac2_1_unauthenticated_401_with_envelope_passes` (same test as AC2.1) | 5 | (inline) | Second enqueued response is `unauthorized("BadJwt", ...)`; asserts `results[2].status == Pass`. | 28 + | AC2.4 | tests/labeler_report.rs | `ac2_4_malformed_bearer_200_is_spec_violation` | 5 | (inline: first `unauthorized`, second `ok_empty`) | `results[2].status == SpecViolation`; diagnostic code `"labeler::report::malformed_bearer_accepted"`. | 29 + | AC2.5 | tests/labeler_report.rs | `ac2_5_401_without_envelope_still_passes` | 5 | (inline: `FakeCreateReportResponse::Response { status: 401, body: b"{}" }` + a `401` with `<html>` body) | `results[1].status == Pass` and `results[2].status == Pass`; each `summary` contains the substring `"non-conformant envelope"`. | 30 + 31 + ### labeler-report-stage.AC3: Self-mint negative checks 32 + 33 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 34 + |---|---|---|---|---|---| 35 + | AC3.1 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` | 6 | (inline: enqueues 2 Phase-5 unauthorized, then `unauthorized("BadJwtAudience", ...)`, `unauthorized("BadJwtLexiconMethod", ...)`, `unauthorized("JwtExpired", ...)`, `bad_request("InvalidRequest", ...)`; `local_identity_facts()` with `labeler_endpoint = http://localhost:8080`) | `results[3].id == "report::wrong_aud_rejected"` and `status == Pass`. Co-asserts AC3.3 (`results[4].status == Pass`), AC3.4 (`results[5].status == Pass`), AC3.5 (`results[6].status == Pass`). | 36 + | AC3.2 | tests/labeler_report.rs | `ac3_2_wrong_aud_200_is_spec_violation` | 6 | (inline: `FakeCreateReportResponse::ok_empty()` at the `wrong_aud_rejected` slot) | `results[3].status == SpecViolation`; diagnostic code `"labeler::report::wrong_aud_accepted"`. Verifies the JWT submitted in the recorded request has `aud == "did:plc:0000000000000000000000000"` (mutation applied correctly). | 37 + | AC3.3 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (success leg) + `ac3_3_wrong_lxm_200_is_spec_violation` (failure leg) | 6 | (inline) | Failure-leg test: `results[4].status == SpecViolation`; diagnostic code `"labeler::report::wrong_lxm_accepted"`; recorded JWT's `lxm == "com.atproto.server.getSession"`. | 38 + | AC3.4 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (success leg) + `ac3_4_expired_200_is_spec_violation` (failure leg) | 6 | (inline) | Failure-leg test: `results[5].status == SpecViolation`; diagnostic code `"labeler::report::expired_accepted"`; recorded JWT's `exp == now - 300`. | 39 + | AC3.5 | tests/labeler_report.rs | `ac3_1_wrong_aud_401_passes` (co-asserts) | 6 | (inline) | `results[6].id == "report::rejected_shape_returns_400"` and `status == Pass` when the labeler returns `bad_request("InvalidRequest", ...)`; recorded body's `reasonType` is not in `id_facts.reason_types`. | 40 + | AC3.6 | tests/labeler_report.rs | `ac3_6_shape_not_400_emits_advisory` | 6 | (inline: enqueue a `401` at the `rejected_shape_returns_400` slot) | `results[6].status == Advisory`; diagnostic code `"labeler::report::shape_not_400"`. Second variant (parametrized or sibling test) exercises `500` response. | 41 + | AC3.7 | tests/labeler_report.rs | `ac3_7_non_local_labeler_skips_self_mint_checks` | 6 | (inline: `facts.labeler_endpoint = https://labeler.example.com`; `self_mint_signer = None`; `force_self_mint = false`) | For `i in 3..=6`, `results[i].status == Skipped` and `skipped_reason` contains `"--force-self-mint"`. | 42 + | AC3.8 | tests/labeler_report.rs | `ac3_8_force_self_mint_overrides_non_local` | 6 | (inline: non-local endpoint; `self_mint_signer = Some(&signer)`; `force_self_mint = true`) | `results[3].status == Pass` and `results[6].status == Pass` despite the non-local hostname — i.e., the four self-mint negative checks ran against the fake. | 43 + 44 + ### labeler-report-stage.AC4: Self-mint positive check 45 + 46 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 47 + |---|---|---|---|---|---| 48 + | AC4.1 | tests/labeler_report.rs | `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject` | 7 | (inline: `local_identity_facts()` — `reasonSpam` + `account`; `commit_report = true`; `self_mint_signer = Some(...)`; `FakeCreateReportResponse::ok_empty()` at the positive-POST slot) | `results[7].id == "report::self_mint_accepted"` and `status == Pass`. `tee.last_request().body["reasonType"] == "com.atproto.moderation.defs#reasonSpam"` and `body["subject"]["$type"] == "com.atproto.admin.defs#repoRef"` (account subject, reporter DID = self-mint issuer DID). | 49 + | AC4.2 | tests/labeler_report.rs | `ac4_2_non_local_labeler_prefers_other_and_record` | 7 | (inline: facts advertise both `reasonSpam` + `reasonOther` and both `account` + `record`; `labeler_endpoint = https://labeler.example.com`; `force_self_mint = true`) | `results[7].status == Pass`. `last_request().body["reasonType"] == "com.atproto.moderation.defs#reasonOther"` and `body["subject"]["$type"] == "com.atproto.repo.strongRef"` pointing at `CONFORMANCE_REPORT_SUBJECT_URI`. | 50 + | AC4.3 | tests/labeler_report.rs | `ac4_3_non_2xx_is_spec_violation` | 7 | (inline: enqueue `Response { status: 400, body: {"error":"InvalidRequest","message":"nope"} }` at the positive-POST slot) | `results[7].status == SpecViolation`; diagnostic code `"labeler::report::self_mint_rejected"`. | 51 + | AC4.4 | tests/labeler_report.rs | `ac4_4_commit_false_skips` | 7 | (inline: `commit_report = false`; no positive POST enqueued) | `results[7].status == Skipped`; `skipped_reason` contains `"--commit-report"`. | 52 + | AC4.5 | tests/labeler_report.rs | `ac4_5_non_viable_skip_matches_phase_6_reason` | 7 | (inline: `labeler_endpoint = https://labeler.example.com`; `self_mint_signer = None`; `commit_report = true`) | `results[7].status == Skipped` — reason is the self-mint-unviable message matching the `--force-self-mint` hint. | 53 + | AC4.6 | tests/labeler_report.rs | `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject` (same test as AC4.1) | 7 | (inline; test sets `run_id = "test-run-1234567890"`) | `tee.last_request().body["reason"]` starts with `"atproto-devtool conformance test"` and ends with the supplied run-id; also exercised by a dedicated assertion in the same test body. | 54 + 55 + ### labeler-report-stage.AC5: PDS `getServiceAuth` mode 56 + 57 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 58 + |---|---|---|---|---|---| 59 + | AC5.1 | tests/labeler_report.rs | `ac5_1_full_flow_passes` | 8 | (inline: `FakePdsXrpcClient` scripts: `200` for `createSession` (body carries `accessJwt` + `did`), `200` for `getServiceAuth` (body carries `token`), then a second `createSession` for the fetcher's second call; `FakeCreateReportTee` enqueues `ok_empty()` at the mode-2 POST slot; `commit_report = true`, `pds_credentials = Some(...)`, `pds_xrpc_client_override = Some(&fake_pds)`) | `results[8].id == "report::pds_service_auth_accepted"` and `status == Pass`. Recorded labeler POST's `Authorization` bearer equals the `token` returned by the fake `getServiceAuth`. | 60 + | AC5.2 | tests/labeler_report.rs | `ac5_2_labeler_rejects_service_auth_jwt` | 8 | (inline: PDS script is as AC5.1; `FakeCreateReportTee` enqueues `unauthorized("BadJwt", ...)` at the mode-2 POST slot) | `results[8].status == SpecViolation`; diagnostic code `"labeler::report::pds_service_auth_rejected"`. | 61 + | AC5.3 | tests/labeler_report.rs | `ac5_3_pds_unreachable` | 8 | (inline: PDS first script entry is `FakePdsXrpcResponse::Transport { message }` for `createSession`) | `results[8].status == NetworkError`; row summary contains the transport message. (Also verifies AC6.3 sibling: `results[9].status == NetworkError` because the shared `createSession` precondition failed.) | 62 + | AC5.4 | tests/labeler_report.rs | `ac5_4_missing_creds_or_commit_skips` | 8 | (inline: three parametrized sub-cases — missing handle, missing app_password, missing `commit_report`) | For each sub-case: `results[8].status == Skipped` with `skipped_reason == "requires --handle, --app-password, and --commit-report"`. | 63 + 64 + ### labeler-report-stage.AC6: PDS-proxied mode 65 + 66 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 67 + |---|---|---|---|---|---| 68 + | AC6.1 | tests/labeler_report.rs | `ac6_1_proxied_pass` | 8 | (inline: PDS scripts `createSession` 200, `getServiceAuth` 200, and `createReport` POST to PDS returns 200) | `results[9].id == "report::pds_proxied_accepted"` and `status == Pass`. Recorded PDS request has `atproto-proxy` header equal to `"<labeler-did>#atproto_labeler"` and `Authorization` bearer equal to the session `accessJwt`. | 69 + | AC6.2 | tests/labeler_report.rs | `ac6_2_labeler_side_rejection_via_proxy` | 8 | (inline: PDS's proxied createReport returns `502` with body `{"error":"UpstreamError","message":"..."}`) | `results[9].status == SpecViolation`; diagnostic code `"labeler::report::pds_proxied_rejected"`. Also verified for `status == 504` and body `{"error":"UpstreamFailure"}` via a sibling case. | 70 + | AC6.3 | tests/labeler_report.rs | `ac6_3_pds_rejects_proxy` | 8 | (inline: PDS's proxied createReport returns `400` `{"error":"InvalidRequest","message":"..."}` — not a downstream-flavored error) | `results[9].status == NetworkError`; row summary indicates PDS rejected the proxy attempt. Separate sibling test also covers AC5.3's shared `createSession` transport error producing `NetworkError` on this row. | 71 + | AC6.4 | tests/labeler_report.rs | `ac6_4_missing_creds_or_commit_skips` | 8 | (inline: three parametrized sub-cases as AC5.4) | For each sub-case: `results[9].status == Skipped` with `skipped_reason == "requires --handle, --app-password, and --commit-report"`. | 72 + 73 + ### labeler-report-stage.AC7: Never-short-circuit and row-count invariants 74 + 75 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 76 + |---|---|---|---|---|---| 77 + | AC7.1 | tests/labeler_report.rs | Every AC1–AC6 test (asserts `results.len() == 10`) | 4–8 | (inline) | Row count invariant enforced per-test. | 78 + | AC7.1 (matrix) | tests/labeler_endtoend.rs (or tests/labeler_report.rs) | `report_stage_always_emits_10_rows` | 8 | (inline parametrization over `(contract, commit, pds, self_mint_viable)` tuples: `(true,false,false,false)`, `(true,false,false,true)`, `(true,true,false,true)`, `(true,true,true,true)`, `(false,false,false,false)`, `(false,true,false,false)`) | For each configuration, filter `report.results` to `id.starts_with("report::")` and assert `len() == 10`. | 79 + | AC7.1 (snapshots) | tests/labeler_endtoend.rs | `report_all_pass_local_labeler_snapshot`, `report_all_pass_full_suite_snapshot`, `report_all_fail_misconfigured_labeler_snapshot` | 8 | `tests/fixtures/labeler/report/all_pass_local_labeler/`, `..._full_suite/`, `..._misconfigured_labeler/` (each with `.gitkeep` + any case-specific JSON) | Rendered-report insta snapshots include all 10 `report::*` rows in the canonical order; snapshot review confirms row count on every accepted diff. | 80 + | AC7.2 | tests/labeler_report.rs | `ac7_2_row_order_is_stable` | 4 | (inline) | Collected `results.iter().map(|r| r.id)` equals the 10-element canonical sequence starting with `"report::contract_published"` and ending with `"report::pds_proxied_accepted"`. Row order also re-pinned by every AC1–AC6 snapshot. | 81 + 82 + ### labeler-report-stage.AC8: CLI flag handling 83 + 84 + | AC case | Test file | Test name | Phase | Fixture | What it asserts | 85 + |---|---|---|---|---|---| 86 + | AC8.1 | tests/labeler_cli.rs | `ac8_1_handle_without_app_password_fails`, `ac8_1_app_password_without_handle_fails` | 8 | (none — `assert_cmd` invocation) | `assert_cmd` run of `atproto-devtool test labeler <target> --handle alice.bsky.social` exits non-zero and stderr mentions `--app-password` (or `app_password`); symmetric test for `--app-password` alone. Clap's `requires = "..."` makes this a parse-time error before any stage runs. | 87 + | AC8.2 (unit) | src/commands/test/labeler/create_report/self_mint.rs | `self_mint_signer_es256k_round_trips`, `self_mint_signer_es256_round_trips` | 2 | (in-test randomly generated keys + spawned DidDocServer) | For each curve, the published DID document's `verificationMethod[0].publicKeyMultibase` round-trips through `parse_multikey` to the expected `AnyVerifyingKey` variant (`K256` or `P256`). A token signed with `SelfMintSigner::sign_jwt` decodes via `verify_compact` with `header.alg == "ES256K"` (for `Es256k`) or `"ES256"` (for `Es256`). | 88 + | AC8.2 (CLI help) | tests/labeler_cli.rs | `help_lists_all_flags` (extended in Phase 4) | 4 | (none) | `--help` output includes `--self-mint-curve` with a default of `es256k`. | 89 + | AC8.3 | tests/labeler_report.rs | `ac8_3_subject_override_replaces_computed_default` | 8 | (inline: `report_subject_override = Some(&Did("did:plc:override"))`; commit-report = true; self-mint signer present; labeler returns `ok_empty()` for the positive POST) | `tee.last_request().body["subject"]["$type"] == "com.atproto.admin.defs#repoRef"` and `body["subject"]["did"] == "did:plc:override"`, regardless of what `subject_types` the identity facts advertise. | 90 + | AC8.4 (unit precedence) | src/commands/test/labeler/report.rs (existing `#[cfg(test)]` module, extended to cover Report-stage rows) | existing exit-code unit tests at `report.rs:361-406` | 8 | (inline `LabelerReport` construction) | Programmatically constructed reports with a `report::*` `SpecViolation` return `exit_code() == 1`; with only a `NetworkError` return `2`; all `Pass`/`Skipped`/`Advisory` return `0`. Precedence: `SpecViolation` beats `NetworkError`. | 91 + | AC8.4 (CLI smoke) | tests/labeler_cli.rs | `ac8_4_spec_violation_exits_1`, `ac8_4_network_error_exits_2` | 8 | (none; `assert_cmd` against an unreachable endpoint for the NetworkError path) | CLI invocation exit code matches the expected integer; smoke test confirms the unit-tested precedence is preserved when run end-to-end. | 92 + 93 + ## Human verification requirements 94 + 95 + None. Every AC listed in the design (AC1.1–AC8.4, 32 cases total) is automatable via the existing fake-based seams: 96 + 97 + - `FakeCreateReportTee` (Phase 3) scripts every labeler-side HTTP response, making AC1/AC2/AC3/AC4/AC7 fully deterministic. 98 + - `FakePdsXrpcClient` (Phase 8) scripts every PDS-side createSession / getServiceAuth / proxied-POST response, making AC5/AC6 fully deterministic. 99 + - `SelfMintSigner::spawn(curve)` binds a real `tokio::net::TcpListener` on `127.0.0.1:0` and serves a real DID document, so AC8.2's curve selection is exercised against the same code path the CLI uses without needing a real labeler. 100 + - `assert_cmd` makes clap parse errors (AC8.1) and process exit codes (AC8.4) testable as ordinary integration tests. 101 + 102 + The sentinel string AC (AC4.6) is specifically asserted on the recorded request body in `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject`. Operators' ability to grep for the sentinel in a real moderation queue is a property of the chosen string format, which is pinned both by `SENTINEL_PREFIX` (unit-tested in `create_report::sentinel::tests`) and by the submitted-body assertion. 103 + 104 + The hard-coded `CONFORMANCE_REPORT_SUBJECT_URI` / `_CID` placeholders remain a release-gate checklist item (documented in Phase 7 and the design's "Pre-release TODO" section) — this is a pre-release content task, not a test-coverage gap. 105 + 106 + ## Coverage summary 107 + 108 + - Total ACs in design: 32 cases across 8 groups (AC1: 4, AC2: 5, AC3: 8, AC4: 6, AC5: 4, AC6: 4, AC7: 2, AC8: 4 — with AC8.2 verified at both unit and CLI-help layers, and AC8.4 verified at both unit-precedence and CLI-smoke layers). 109 + - Automated: 32 cases. 110 + - Human verification: 0 cases. 111 + - Uncovered (flag): 0. 112 + 113 + ## Snapshot coverage 114 + 115 + Stable check IDs pinned by at least one accepted automated snapshot: 116 + 117 + - `report::contract_published` — tests/snapshots/labeler_report__report_contract_present_no_commit.snap, ..._with_commit.snap, ..._missing_no_commit.snap, ..._missing_with_commit.snap (Phase 4). Re-pinned in tests/snapshots/labeler_endtoend__report_all_pass_local_labeler.snap, ..._full_suite.snap, ..._misconfigured_labeler.snap (Phase 8). 118 + - `report::unauthenticated_rejected` — Phase 4 stub snapshots (`"not yet implemented (Phase 5)"`), overwritten in Phase 5 with Pass/SpecViolation/Advisory snapshots. Re-pinned in Phase 8 end-to-end snapshots. 119 + - `report::malformed_bearer_rejected` — same coverage pattern as `unauthenticated_rejected` (Phases 4 → 5 → 8). 120 + - `report::wrong_aud_rejected` — Phase 4 stub ("not yet implemented (Phase 6)"), overwritten in Phase 6, re-pinned in Phase 8. 121 + - `report::wrong_lxm_rejected` — same (Phases 4 → 6 → 8). 122 + - `report::expired_rejected` — same (Phases 4 → 6 → 8). 123 + - `report::rejected_shape_returns_400` — same (Phases 4 → 6 → 8). 124 + - `report::self_mint_accepted` — Phase 4 stub ("not yet implemented (Phase 7)"), overwritten in Phase 7, re-pinned in Phase 8. 125 + - `report::pds_service_auth_accepted` — Phase 4 stub ("not yet implemented (Phase 8)"), overwritten in Phase 8. 126 + - `report::pds_proxied_accepted` — same (Phases 4 → 8). 127 + 128 + Stable diagnostic codes pinned by at least one accepted automated snapshot: 129 + 130 + - `labeler::report::contract_missing` — AC1.3 snapshot (Phase 4) and end-to-end snapshots (Phase 8). 131 + - `labeler::report::unauthenticated_accepted` — AC2.2 assertion + snapshot (Phase 5); end-to-end snapshot when misconfigured-labeler fixture exercises this path. 132 + - `labeler::report::malformed_bearer_accepted` — AC2.4 assertion + snapshot (Phase 5). 133 + - `labeler::report::wrong_aud_accepted` — AC3.2 assertion + snapshot (Phase 6). 134 + - `labeler::report::wrong_lxm_accepted` — AC3.3 failure-leg assertion + snapshot (Phase 6). 135 + - `labeler::report::expired_accepted` — AC3.4 failure-leg assertion + snapshot (Phase 6). 136 + - `labeler::report::shape_not_400` — AC3.6 assertion + snapshot (Phase 6). 137 + - `labeler::report::self_mint_rejected` — AC4.3 assertion + snapshot (Phase 7). 138 + - `labeler::report::pds_service_auth_rejected` — AC5.2 assertion + snapshot (Phase 8). 139 + - `labeler::report::pds_proxied_rejected` — AC6.2 assertion + snapshot (Phase 8). 140 + - `labeler::report::transport_error` — exercised indirectly by AC5.3 and AC6.3 `NetworkError` rows (Phase 8); the code string is carried on the `CreateReportStageError::Transport` variant and surfaces on any transport-level failure. 141 + 142 + The `all_fail_misconfigured_labeler_snapshot` fixture in Phase 8 is the single snapshot that exercises every negative-check `SpecViolation`/`Advisory` diagnostic code simultaneously (by scripting the labeler to return `200 OK` for every negative POST and `400 InvalidRequest` for the shape check). Its acceptance pins the complete diagnostic-code vocabulary as part of the public CLI contract.
+171
docs/test-plans/2026-04-17-labeler-report-stage.md
··· 1 + # Human Test Plan — Labeler Report Stage 2 + 3 + Generated after Phase 8 completion of the labeler report stage implementation. All 38 acceptance criteria (AC1.1 through AC8.4) are covered by automated tests; this plan is confirmatory and quality-of-life focused. Items that benefit from visual inspection or live-system verification are marked explicitly. 4 + 5 + ## Prerequisites 6 + 7 + - Rust toolchain pinned by `rust-toolchain.toml` (MSRV). 8 + - `cargo build --release` completes cleanly. 9 + - `cargo test` passing locally. 10 + - `cargo clippy -- -D warnings` passing. 11 + - Access to a real atproto labeler for end-to-end verification — ideally one you control so you can observe and prune test reports from the moderation queue. 12 + - Optional: a Bluesky account with a generated app-password for exercising the PDS-mediated modes. 13 + - Environment: a terminal emulator that honours ANSI escapes (for colour verification) and one that respects `NO_COLOR`. 14 + 15 + ## Phase 1: Smoke — CLI surface and help text 16 + 17 + | Step | Action | Expected | 18 + |---|---|---| 19 + | 1.1 | Run `cargo run --release -- test labeler --help`. | Exit code 0. `--help` output lists: `--did`, `--subscribe-timeout`, `--verbose`, `--no-color`, `--commit-report`, `--force-self-mint`, `--self-mint-curve` (with default `es256k`), `--report-subject-did`, `--handle`, `--app-password`, and a `<TARGET>` positional. | 20 + | 1.2 | Run `cargo run --release -- test labeler`. | Exit code 2 with a clap parse error mentioning the missing `<TARGET>`. | 21 + | 1.3 | Run `cargo run --release -- test labeler mod.bsky.app --handle alice.bsky.social`. | Exit code non-zero with a clap-style error mentioning `--app-password` (or `app_password`). | 22 + | 1.4 | Run `cargo run --release -- test labeler mod.bsky.app --app-password xxxx-xxxx-xxxx-xxxx`. | Exit code non-zero with a clap-style error mentioning `--handle`. | 23 + | 1.5 | Run `cargo run --release -- test labeler --no-color did:web:nonexistent.invalid 2>/dev/null`. | Stdout contains a rendered report with `== Identity ==` section and at least one `[FAIL]` or `[NET]` glyph. Stdout contains no ANSI escape bytes (`\x1b[`). | 24 + | 1.6 | Run `cargo run --release -- test labeler --verbose --no-color did:web:nonexistent.invalid` and inspect stderr. | Stderr contains `DEBUG`-prefixed lines from `tracing`. | 25 + 26 + ## Phase 2: Read-only conformance run against a real labeler 27 + 28 + Target a real labeler whose behaviour you trust (e.g. `mod.bsky.app` or a community labeler you operate). Do not pass `--commit-report` in this phase. 29 + 30 + | Step | Action | Expected | 31 + |---|---|---| 32 + | 2.1 | `cargo run --release -- test labeler <real-labeler-handle-or-DID> --no-color`. | Exit code 0 if the labeler is conformant. The rendered report has five stage sections in order: Identity, HTTP, Subscription, Crypto, Report. The Report section contains exactly 10 rows, whose IDs appear in this exact order: `report::contract_published`, `report::unauthenticated_rejected`, `report::malformed_bearer_rejected`, `report::wrong_aud_rejected`, `report::wrong_lxm_rejected`, `report::expired_rejected`, `report::rejected_shape_returns_400`, `report::self_mint_accepted`, `report::pds_service_auth_accepted`, `report::pds_proxied_accepted`. | 33 + | 2.2 | In the same output, confirm the gating behaviour. | `report::contract_published` is `[OK]` iff the labeler advertises both `reasonTypes` and `subjectTypes`. `report::unauthenticated_rejected` and `report::malformed_bearer_rejected` should be `[OK]` for a conformant labeler (they do unauthenticated POSTs but don't write). `report::self_mint_accepted`, `report::pds_service_auth_accepted`, and `report::pds_proxied_accepted` should be `[SKIP]` with a reason mentioning `--commit-report`. | 34 + | 2.3 | Confirm that the run emits no side-effect traffic that reached the labeler's moderation queue. Check the labeler's moderation inbox; there should be no new entries from this run. | No reports present. | 35 + | 2.4 | Run with no `--no-color` against a colour-capable terminal. | Glyphs are coloured (green `[OK]`, red `[FAIL]`, yellow `[WARN]`, blue `[SKIP]`, magenta `[NET]`). Setting `NO_COLOR=1` in the env reproduces the `--no-color` layout. | 36 + 37 + ## Phase 3: Self-mint against a locally-reachable labeler 38 + 39 + If you have (or can set up) a labeler reachable on 127.0.0.1 / localhost, the self-mint checks become real end-to-end tests. Otherwise, simulate by pointing `--target` at an SSH tunnel or local dev deployment. 40 + 41 + | Step | Action | Expected | 42 + |---|---|---| 43 + | 3.1 | `cargo run --release -- test labeler http://localhost:8080 --did did:plc:<yours> --commit-report --no-color`. | Report stage rows 3..=6 (`wrong_aud_rejected`, `wrong_lxm_rejected`, `expired_rejected`, `rejected_shape_returns_400`) run real self-minted JWT attempts. A conformant labeler returns `[OK]` for all four. Row 7 (`report::self_mint_accepted`) is `[OK]` and produces exactly one new entry in the labeler's moderation queue. | 44 + | 3.2 | Inspect the moderation queue entry created by step 3.1. | The `reason` string starts with `atproto-devtool conformance test` and ends with the 16-hex-char run id. The `reasonType` is the first `reasonTypes` advertised by the labeler (expected `com.atproto.moderation.defs#reasonSpam`). The `subject` is an account-shape `com.atproto.admin.defs#repoRef` pointing at the ephemeral did:web DID (`did:web:127.0.0.1%3A<port>`). | 45 + | 3.3 | Run the same command with `--self-mint-curve es256` added. | Self-mint rows still pass. The labeler-visible DID document (if you can observe the fetch) carries an ES256 multikey. | 46 + | 3.4 | Run the same command with `--report-subject-did did:plc:somewellknownaccount`. | Row 7 still `[OK]`. The moderation queue entry's `subject.did` is the override DID, regardless of what the labeler advertises in `subjectTypes`. | 47 + 48 + ## Phase 4: Self-mint against a remote labeler with `--force-self-mint` 49 + 50 + | Step | Action | Expected | 51 + |---|---|---| 52 + | 4.1 | Run without the flag: `cargo run --release -- test labeler mod.bsky.app --commit-report --no-color`. | Report stage rows 3..=7 are `[SKIP]` with a reason mentioning `--force-self-mint`. | 53 + | 4.2 | Re-run with `--force-self-mint`. | The tool attempts to publish the self-mint DID doc on a local port and the remote labeler cannot resolve it. Expected outcome: rows 3..=7 will likely be `[FAIL]` or `[NET]` depending on how the labeler handles unresolvable issuer DIDs. This is the expected behaviour; `--force-self-mint` is only useful when the labeler has out-of-band access to the devtool's loopback port (e.g. for dev rigs that punch through NAT). Confirm the error rendering is coherent — every failing row carries a diagnostic with a stable code. | 54 + 55 + ## Phase 5: PDS-mediated modes 56 + 57 + Use a burner or test Bluesky account. Generate an app password at <https://bsky.app/settings/app-passwords>. 58 + 59 + | Step | Action | Expected | 60 + |---|---|---| 61 + | 5.1 | `cargo run --release -- test labeler <conformant-labeler> --handle <your-handle>.bsky.social --app-password xxxx-xxxx-xxxx-xxxx --commit-report --no-color`. | Exit code 0 on a conformant labeler. Rows 8 and 9 (`pds_service_auth_accepted`, `pds_proxied_accepted`) are `[OK]`. Exactly two additional moderation queue entries appear on the labeler side, both carrying the sentinel reason and the run-id. | 62 + | 5.2 | Check the authorship of the two extra reports. | Both should appear to come from your real DID (the PDS-backed identity), not from a did:web loopback DID. | 63 + | 5.3 | Use an intentionally wrong app password. | `createSession` fails. Rows 8 and 9 are `[NET]` with a transport or auth diagnostic. Exit code 2. | 64 + | 5.4 | Point `--handle` at a non-existent PDS-resident handle. | `createSession` still fails in a recognisable way; the failure is not swallowed silently. | 65 + 66 + ## Phase 6: Exit-code matrix 67 + 68 + | Step | Action | Expected | 69 + |---|---|---| 70 + | 6.1 | Run against a conformant labeler with no flags. | Exit code 0. | 71 + | 6.2 | Run against an unreachable endpoint: `cargo run --release -- test labeler https://doesnt-exist.example.test`. | Exit code 2 (NetworkError). | 72 + | 6.3 | Run against a deliberately non-conformant labeler (e.g. one that returns 200 OK for unauthenticated createReport) with `--commit-report`. | Exit code 1 (SpecViolation precedence). Even if some rows are also `[NET]`, the exit code is 1. | 73 + 74 + ## Phase 7: Misconfigured-labeler rendering 75 + 76 + | Step | Action | Expected | 77 + |---|---|---| 78 + | 7.1 | Diff `cargo run --release -- test labeler <labeler>` output against the accepted `tests/snapshots/labeler_report__report_all_fail_misconfigured_labeler_snapshot.snap` if you have a rigged test target available. | Every `report::*` row except `report::contract_published` is a `[FAIL]` or `[WARN]` with a diagnostic. All 10 stable diagnostic codes listed below appear at least once across the rendered rows. | 79 + 80 + Stable diagnostic codes worth eyeballing for accuracy in rendered output: 81 + 82 + - `labeler::report::contract_missing` 83 + - `labeler::report::unauthenticated_accepted` 84 + - `labeler::report::malformed_bearer_accepted` 85 + - `labeler::report::wrong_aud_accepted` 86 + - `labeler::report::wrong_lxm_accepted` 87 + - `labeler::report::expired_accepted` 88 + - `labeler::report::shape_not_400` 89 + - `labeler::report::self_mint_rejected` 90 + - `labeler::report::pds_service_auth_rejected` 91 + - `labeler::report::pds_proxied_rejected` 92 + - `labeler::report::transport_error` 93 + 94 + ## End-to-End: "Day one" conformance run 95 + 96 + Purpose: validate a labeler operator's likely first interaction with the tool. 97 + 98 + Steps: 99 + 100 + 1. Operator generates or uses a did:web labeler on their own domain. 101 + 2. Operator runs `atproto-devtool test labeler <their-labeler-URL>` (no flags). 102 + 3. Verify: all read-only stages pass; Report stage shows 10 rows with write-side rows `[SKIP]`. Exit code 0. 103 + 4. Operator re-runs with `--commit-report --handle ... --app-password ...`. 104 + 5. Verify: Report stage now exercises every row including PDS-mediated ones. Exit code 0. Exactly 3 new entries in the operator's moderation queue, each with the sentinel reason and the same run-id. 105 + 6. Operator searches for "atproto-devtool conformance test" in the moderation queue and can find all three in one filter. 106 + 107 + ## End-to-End: Sentinel grep-ability 108 + 109 + Purpose: confirm the sentinel string is actually useful for operators. 110 + 111 + Steps: 112 + 113 + 1. Run a `--commit-report` conformance run against a test labeler. 114 + 2. Note the run-id printed near the top of the rendered report (or scrape it from the final row's diagnostics). 115 + 3. Filter the moderation queue by reason string containing the run-id. 116 + 4. Verify the resulting set is exactly the reports produced by this one run — no cross-contamination with reports from other runs. 117 + 118 + ## Human Verification Required 119 + 120 + Per `test-requirements.md`, there are zero required human-verification items — every AC is covered by automated tests. The items below specifically benefit from a human eye. 121 + 122 + | Item | Why Manual | Steps | 123 + |---|---|---| 124 + | Rendered colour output | ANSI-sequence presence is unit-tested but colour appropriateness (green for OK, red for FAIL, etc.) benefits from visual check. | Phase 2.4. | 125 + | Moderation queue cleanliness after a `--commit-report` run | The sentinel string's format is unit-tested, but its actual grep-ability in a real moderation UI is an ergonomics question. | Phase 5.2 and End-to-End "Sentinel grep-ability". | 126 + | Exit-code + stderr diagnostic layout when a remote labeler is conformance-broken | Specific rendered-text ergonomics (e.g. miette span placement) benefit from visual inspection even though the codes and glyphs are snapshot-pinned. | Phase 7.1. | 127 + | Sentinel `CONFORMANCE_REPORT_SUBJECT_URI` placeholder value | The requirements doc notes this is a pre-release content task, not a coverage gap. Reviewer should visually confirm the placeholder has been replaced with a real, stable URI before the release that ships the report stage. | Pre-release checklist: grep `CONFORMANCE_REPORT_SUBJECT_URI` in `src/commands/test/labeler/create_report/pollution.rs` and verify the resolved URI is a stable public record the operator community can refer to. | 128 + 129 + ## Traceability 130 + 131 + | Acceptance Criterion | Automated Test | Manual Step | 132 + |---|---|---| 133 + | AC1.1 | `ac1_1_contract_present_emits_pass` + snapshot | Phase 2.1 | 134 + | AC1.2 | `ac1_2_contract_missing_without_commit_skips_stage` + snapshot | Phase 2.2 | 135 + | AC1.3 | `ac1_3_contract_missing_with_commit_is_spec_violation` + snapshot | Phase 7.1 | 136 + | AC1.4 | `ac1_4_empty_arrays_equivalent_to_absent` | Phase 2.2 | 137 + | AC2.1 | `ac2_1_unauthenticated_401_with_envelope_passes` | Phase 2.1 | 138 + | AC2.2 | `ac2_2_unauthenticated_200_is_spec_violation` | Phase 7.1 | 139 + | AC2.3 | `ac2_1` (co-assert) | Phase 2.1 | 140 + | AC2.4 | `ac2_4_malformed_bearer_200_is_spec_violation` | Phase 7.1 | 141 + | AC2.5 | `ac2_5_401_without_envelope_still_passes` | Phase 2.1 | 142 + | AC3.1 | `ac3_1_wrong_aud_401_passes` | Phase 3.1 | 143 + | AC3.2 | `ac3_2_wrong_aud_200_is_spec_violation` | Phase 7.1 | 144 + | AC3.3 | `ac3_3_wrong_lxm_401_passes` + `ac3_4_wrong_lxm_200_is_spec_violation` | Phase 3.1 / 7.1 | 145 + | AC3.4 | `ac3_5_expired_401_passes` + misconfigured snapshot | Phase 3.1 / 7.1 | 146 + | AC3.5 | `ac3_1` (co-assert) | Phase 3.1 | 147 + | AC3.6 | `ac3_6_shape_not_400_emits_advisory` | Phase 7.1 | 148 + | AC3.7 | `ac3_7_non_local_labeler_skips_self_mint_checks` | Phase 4.1 | 149 + | AC3.8 | `ac3_8_force_self_mint_overrides_non_local` | Phase 4.2 | 150 + | AC4.1 | `ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject` | Phase 3.1, 3.2 | 151 + | AC4.2 | `ac4_2_non_local_labeler_prefers_other_and_record` | Phase 4.2 | 152 + | AC4.3 | `ac4_3_non_2xx_is_spec_violation` | Phase 7.1 | 153 + | AC4.4 | `ac4_4_commit_false_skips` | Phase 2.2 | 154 + | AC4.5 | `ac4_5_non_viable_skip_matches_phase_6_reason` | Phase 4.1 | 155 + | AC4.6 | `ac4_1` (co-assert) | Phase 3.2 + Sentinel grep-ability | 156 + | AC5.1 | `ac5_1_full_flow_passes` | Phase 5.1, 5.2 | 157 + | AC5.2 | `ac5_2_labeler_rejects_service_auth_jwt` | Phase 7.1 | 158 + | AC5.3 | `ac5_3_pds_unreachable` | Phase 5.3 | 159 + | AC5.4 | `ac5_4_missing_creds_or_commit_skips` | Phase 2.2 | 160 + | AC6.1 | `ac6_1_proxied_pass` | Phase 5.1 | 161 + | AC6.2 | `ac6_2_labeler_side_rejection_via_proxy` | Phase 7.1 | 162 + | AC6.3 | `ac6_3_pds_rejects_proxy` | Phase 5.4 | 163 + | AC6.4 | `ac6_4_missing_creds_or_commit_skips` | Phase 2.2 | 164 + | AC7.1 | Every AC1–AC6 test + `ac7_1_row_count_is_always_10` + three end-to-end snapshots | Phase 2.1 | 165 + | AC7.2 | `ac7_2_row_order_is_stable` + every snapshot | Phase 2.1 | 166 + | AC8.1 | `ac8_1_handle_without_app_password_fails` + `ac8_1_app_password_without_handle_fails` | Phase 1.3, 1.4 | 167 + | AC8.2 (unit) | `self_mint_signer_es256k_round_trips` + `self_mint_signer_es256_round_trips` | Phase 3.3 | 168 + | AC8.2 (help) | `help_lists_all_flags` | Phase 1.1 | 169 + | AC8.3 | `ac8_3_report_subject_did_overrides_subject` | Phase 3.4 | 170 + | AC8.4 (unit) | `exit_code_*` unit tests in `report.rs` | Phase 6.1–6.3 | 171 + | AC8.4 (smoke) | `ac8_4_unreachable_endpoint_nonzero_exit` | Phase 6.2 |
+93 -1
src/commands/test/labeler.rs
··· 1 1 //! `atproto-devtool test labeler <target>` command. 2 2 3 + pub mod create_report; 3 4 pub mod crypto; 4 5 pub mod http; 5 6 pub mod identity; ··· 13 14 use clap::Args; 14 15 use miette::Report; 15 16 17 + use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 16 18 use crate::common::{ 17 19 APP_USER_AGENT, 18 - identity::{RealDnsResolver, RealHttpClient}, 20 + identity::{Did, RealDnsResolver, RealHttpClient, is_local_labeler_hostname}, 19 21 report::RenderConfig, 20 22 }; 21 23 use pipeline::{LabelerOptions, parse_target, run_pipeline}; ··· 48 50 /// Whether to emit verbose diagnostics. 49 51 #[arg(long)] 50 52 pub verbose: bool, 53 + 54 + /// Commit: opt in to actually POSTing report bodies to the labeler and 55 + /// assert reporting conformance (missing `LabelerPolicies` becomes a 56 + /// SpecViolation rather than a stage-skip). 57 + #[arg(long)] 58 + pub commit_report: bool, 59 + 60 + /// Force self-mint checks to run even when the labeler endpoint is 61 + /// classified as non-local by the hostname heuristic. Use when 62 + /// running against a LAN-reachable labeler that the heuristic misses. 63 + #[arg(long)] 64 + pub force_self_mint: bool, 65 + 66 + /// Curve to use for self-mint JWTs. 67 + #[arg(long, value_enum, default_value_t = SelfMintCurve::default())] 68 + pub self_mint_curve: SelfMintCurve, 69 + 70 + /// Override the default computed subject DID for committing checks. 71 + /// Passed through to `self_mint_accepted`, `pds_service_auth_accepted`, 72 + /// and `pds_proxied_accepted` bodies. 73 + #[arg(long)] 74 + pub report_subject_did: Option<String>, 75 + 76 + /// User handle for PDS-mediated report modes. Must be supplied together 77 + /// with --app-password; enables `pds_service_auth_accepted` and 78 + /// `pds_proxied_accepted` checks when combined with --commit-report. 79 + #[arg(long, requires = "app_password")] 80 + pub handle: Option<String>, 81 + 82 + /// App password for PDS-mediated report modes. Must be supplied 83 + /// together with --handle. 84 + #[arg(long, requires = "handle")] 85 + pub app_password: Option<String>, 51 86 } 52 87 53 88 impl LabelerCmd { ··· 56 91 let target = 57 92 parse_target(&self.target, self.did.as_deref()).map_err(|e| miette::miette!("{e}"))?; 58 93 94 + // Determine tentative endpoint for the locality check. When the target is a 95 + // DID or handle, the endpoint is known only after identity stage; for the 96 + // self-mint signer construction we need it now. We construct the signer 97 + // pessimistically (endpoint unknown) only when --force-self-mint is set. 98 + let tentative_endpoint: Option<url::Url> = match &target { 99 + pipeline::LabelerTarget::Endpoint { url, .. } => Some(url.clone()), 100 + pipeline::LabelerTarget::Identified { .. } => None, 101 + }; 102 + 103 + let tentative_local = tentative_endpoint 104 + .as_ref() 105 + .map(is_local_labeler_hostname) 106 + .unwrap_or(false); 107 + 59 108 // Build a single shared HTTP client. 60 109 let reqwest_client = reqwest::Client::builder() 61 110 .use_rustls_tls() ··· 68 117 let http = RealHttpClient::from_client(reqwest_client.clone()); 69 118 let dns = RealDnsResolver::new(); 70 119 120 + // Build the subject DID override if provided. 121 + let report_subject_override = self.report_subject_did.clone().map(Did); 122 + 123 + // Construct the stable run-id for the sentinel reason string. 124 + let run_id = create_report::sentinel::new_run_id(); 125 + 126 + // Pre-construct the self-mint signer (binds the DidDocServer) when: 127 + // - --force-self-mint is set, OR 128 + // - tentative endpoint is known and classified local. 129 + // Otherwise we skip the allocation and let the stage see 130 + // `self_mint_signer == None` → skip all self-mint checks. 131 + let self_mint_signer_opt = if self.force_self_mint || tentative_local { 132 + Some( 133 + SelfMintSigner::spawn(self.self_mint_curve) 134 + .await 135 + .map_err(|e| miette::miette!("Failed to bind self-mint DID server: {e}"))?, 136 + ) 137 + } else { 138 + None 139 + }; 140 + let self_mint_signer_ref = self_mint_signer_opt.as_ref(); 141 + 142 + // Construct PDS credentials when both handle and app_password are supplied. 143 + let pds_credentials = match (self.handle.as_deref(), self.app_password.as_deref()) { 144 + (Some(h), Some(p)) => Some(pipeline::PdsCredentials { 145 + handle: h.to_string(), 146 + app_password: p.to_string(), 147 + }), 148 + _ => None, 149 + }; 150 + let pds_credentials_ref = pds_credentials.as_ref(); 151 + 71 152 // Run the pipeline. 72 153 let opts = LabelerOptions { 73 154 http: &http, ··· 76 157 ws_client: None, 77 158 subscribe_timeout: self.subscribe_timeout, 78 159 verbose: self.verbose, 160 + 161 + create_report_tee: pipeline::CreateReportTeeKind::Real(&reqwest_client), 162 + commit_report: self.commit_report, 163 + force_self_mint: self.force_self_mint, 164 + self_mint_curve: self.self_mint_curve, 165 + report_subject_override: report_subject_override.as_ref(), 166 + self_mint_signer: self_mint_signer_ref, 167 + pds_credentials: pds_credentials_ref, 168 + pds_xrpc_client: None, 169 + pds_xrpc_client_override: None, 170 + run_id: &run_id, 79 171 }; 80 172 81 173 let report = run_pipeline(target, opts).await;
+176 -32
src/commands/test/labeler/CLAUDE.md
··· 5 5 ## Purpose 6 6 7 7 Implements `atproto-devtool test labeler <target>`, a conformance suite that 8 - validates an atproto labeler across four stages — identity, HTTP, 9 - subscription, and crypto — and produces a structured report plus an exit 10 - code (0 if all spec-required checks pass, 1 otherwise). Each stage is built 11 - around an injected I/O seam so integration tests can replay fixtures 12 - instead of talking to real servers. 8 + validates an atproto labeler across five stages — identity, HTTP, 9 + subscription, crypto, and report — and produces a structured report plus an 10 + exit code (0 if all spec-required checks pass, 1 on spec violations, 2 on 11 + network failures only). Each stage is built around an injected I/O seam so 12 + integration tests can replay fixtures instead of talking to real servers. 13 13 14 14 ## Contracts 15 15 ··· 18 18 `labeler.rs`) — constructs the shared reqwest client and calls 19 19 `pipeline::run_pipeline`. 20 20 - `pipeline::parse_target(raw, explicit_did) -> LabelerTarget` — the 21 - accepted target grammar is frozen: handle, `did:*`, or `https://` URL 22 - (HTTP is rejected with a helpful error; raw endpoints with no DID 23 - simply skip identity/crypto). 21 + accepted target grammar is handle, `did:*`, `https://` URL, or 22 + `http://` URL with a local hostname (loopback, RFC 1918, `.local`). 23 + Remote HTTP is rejected with a helpful error; raw endpoints with 24 + no DID simply skip identity/crypto. 24 25 - `pipeline::run_pipeline(target, LabelerOptions) -> LabelerReport` — the 25 26 one orchestrator that every test hits. 26 27 - **Per-stage entry points**: `identity::run`, `http::run`, 27 - `subscription::run`, `crypto::run`. Each returns a `*StageOutput` with an 28 - `Option<*Facts>` (populated only when the stage succeeds enough to let 29 - downstream stages run) plus a `Vec<CheckResult>`. 28 + `subscription::run`, `crypto::run`, `create_report::run`. Each returns a 29 + `*StageOutput` with an `Option<*Facts>` (populated only when the stage 30 + succeeds enough to let downstream stages run, or `None` when there are no 31 + meaningful facts to carry forward) plus a `Vec<CheckResult>`. 30 32 - **Report shape**: `crate::common::report::{LabelerReport, CheckResult, 31 33 CheckStatus, Stage, SummaryCounts, ReportHeader, RenderConfig}` (the 32 34 report module was promoted out of this tree in the oauth client work; the 33 35 type is still named `LabelerReport` for historical reasons and is reused 34 - unchanged by oauth_client). Five-way 36 + unchanged by oauth_client). `Stage` is a `pub struct Stage(pub 37 + &'static str)` newtype with associated consts 38 + (`Stage::IDENTITY`, `Stage::HTTP`, `Stage::SUBSCRIPTION`, 39 + `Stage::CRYPTO`, `Stage::REPORT` for the labeler; oauth_client adds its 40 + own); report rendering orders stages by first-emission order. Five-way 35 41 `CheckStatus`: `Pass`, `SpecViolation`, `NetworkError`, `Advisory`, 36 42 `Skipped`. Exit code semantics: `1` if any `SpecViolation` is 37 43 recorded; else `2` if any `NetworkError` is recorded; else `0`. ··· 39 45 conformance bug is never masked by an unrelated reachability 40 46 failure. `Advisory` and `Skipped` never influence the exit code. 41 47 - **Check IDs are stable strings** (e.g. `"identity::target_resolved"`, 42 - `"http::first_page_decodes"`, `"crypto::rollup"`). They appear verbatim 43 - in insta snapshots under `tests/snapshots/`; renaming one is a breaking 44 - change to the CLI output contract. 48 + `"http::first_page_decodes"`, `"crypto::rollup"`, 49 + `"report::self_mint_accepted"`). They appear verbatim in insta snapshots 50 + under `tests/snapshots/`; renaming one is a breaking change to the CLI 51 + output contract. 45 52 - **Diagnostic codes are stable strings** (e.g. 46 - `"labeler::identity::labeler_endpoint_parseable"`). Same deal — snapshots 47 - pin them. 53 + `"labeler::identity::labeler_endpoint_parseable"`, 54 + `"labeler::report::contract_missing"`). Same deal — snapshots pin them. 55 + - **Report-stage surface**: `create_report::{Check, CheckFactsOutput, 56 + CreateReportFacts, CreateReportStageOutput, CreateReportStageError, 57 + CreateReportTee, RealCreateReportTee, RawCreateReportResponse, 58 + PdsXrpcClient, RealPdsXrpcClient, RawPdsXrpcResponse, 59 + PdsJwtFetcher, PdsProxiedPoster, CreateReportRunOptions, 60 + XrpcErrorEnvelope, RejectionShape, ResponseOrigin}` plus per-check diagnostic structs 61 + (`ContractMissing`, `UnauthenticatedAccepted`, `MalformedBearerAccepted`, 62 + `WrongAudAccepted`, `WrongLxmAccepted`, `ExpiredAccepted`, `ShapeNot400`, 63 + `SelfMintRejected`, `PdsServiceAuthRejected`, `PdsProxiedRejected`). 64 + `Check::ORDER` is the canonical 10-element iteration order: contract, 65 + unauth, malformed, wrong-aud, wrong-lxm, expired, rejected-shape, 66 + self-mint, pds-service-auth, pds-proxied. Report stage always emits 67 + exactly 10 rows regardless of gating — missing identity facts collapse 68 + to 10 `Skipped` rows so row count and order are invariant. 48 69 49 70 ## Dependencies 50 71 51 72 - **Uses**: `crate::common::identity` for every network hop and DID 52 - primitive. `atrium-api` for labeler record + queryLabels types (we go 53 - through `serde_json` + atrium types, never through `atrium-xrpc-client`). 54 - `reqwest` and `tokio-tungstenite` only via the `RealHttpTee` and 55 - `RealWebSocketClient` seams. 73 + primitive (including `AnySigningKey`, `encode_multikey`, 74 + `is_local_labeler_hostname`). `crate::common::jwt` for hand-rolled 75 + compact JWS encoding used by the report stage. `atrium-api` for labeler 76 + record + queryLabels types (we go through `serde_json` + atrium types, 77 + never through `atrium-xrpc-client`). `reqwest` and `tokio-tungstenite` 78 + only via the `RealHttpTee`, `RealWebSocketClient`, `RealCreateReportTee`, 79 + and `RealPdsXrpcClient` seams. 56 80 - **Used by**: `crate::cli` wires this into the clap command tree; nothing 57 81 else depends on it. 58 82 - **Boundary**: Stage modules talk to each other only through `*Facts` 59 83 structs passed by `pipeline::run_pipeline`. A stage must not import 60 - another stage's internals. 84 + another stage's internals. The report stage reads 85 + `IdentityFacts::{reason_types, subject_types, subject_collections}` that 86 + identity is responsible for populating from the labeler record. 61 87 62 88 ## Key decisions 63 89 64 90 - **Every I/O boundary is a trait**: `HttpClient` + `DnsResolver` from 65 - `common::identity`, plus stage-local `RawHttpTee` (HTTP stage) and 66 - `WebSocketClient` / `FrameStream` (subscription stage). All four are 67 - injectable through `LabelerOptions`. The CLI passes real clients; tests 68 - pass fakes from `tests/common/mod.rs`. 91 + `common::identity`, plus stage-local `RawHttpTee` (HTTP stage), 92 + `WebSocketClient` / `FrameStream` (subscription stage), `CreateReportTee` 93 + (report stage, POST with optional bearer), and `PdsXrpcClient` (report 94 + stage, POST/GET against the user's PDS with optional bearer and 95 + `atproto-proxy` headers). All are injectable through `LabelerOptions`. 96 + The CLI passes real clients; tests pass fakes from `tests/common/mod.rs`. 69 97 - **Shared reqwest client**: `LabelerCmd::run` builds one reqwest client 70 98 with rustls + 10s timeout + user-agent and threads it through every 71 99 stage. Do not construct fresh clients inside stages. ··· 80 108 history, so a failure there is a hard `SpecViolation`. Verification that 81 109 only succeeds against a historic key still passes the stage but emits an 82 110 `Advisory`. 111 + - **Crypto stage skips local labelers with mismatched signing keys**: when 112 + the labeler endpoint is local (per `is_local_labeler_hostname`) and at 113 + least one label fails verification against the DID-document signing 114 + key, `crypto::rollup` is `Skipped` rather than `SpecViolation`. The 115 + rationale is that developers testing a local copy of a labeler will 116 + typically not have the production signing key present, so a mismatch is 117 + expected; PLC history fallback is also skipped in this case. If the 118 + local labeler's key happens to match the published key, verification 119 + proceeds normally (`Pass`). 120 + - **Identity stage downgrades local endpoint mismatches to Advisory**: 121 + when the user supplies `--target http://<local>:<port> --did <prod-did>` 122 + and the DID document advertises a different (production) endpoint, 123 + `identity::resolved_did_matches_flag` emits `Advisory` rather than 124 + `SpecViolation`, and `IdentityFacts.labeler_endpoint` is overridden to 125 + the local URL so HTTP / subscription / report stages all target the 126 + local copy. Without this override the `block_facts = true` branch would 127 + skip the report stage entirely, which is the opposite of what the 128 + developer wanted. Remote-URL mismatches remain `SpecViolation`. 83 129 - **DRISL-CBOR canonicalization for label signing**: crypto stage 84 130 implements the deterministic CBOR canonicalization in 85 131 `canonicalize_label_for_signing` rather than pulling a library — label ··· 102 148 `subscribeLabels` frame are both exercised. Subscription samples are 103 149 capped at `subscription::SAMPLE_LABEL_CAP` to bound memory on noisy 104 150 streams. 151 + - **Report stage runs last and always emits 10 rows**: the report stage 152 + is ordered after crypto because it exercises write-side conformance 153 + (authenticated `createReport`), not observational conformance. The 154 + stage's output row count is a hard invariant — missing identity facts, 155 + missing contract, or absent self-mint / PDS inputs all collapse to 156 + `Skipped` rows rather than fewer rows. `Check::ORDER` is the frozen 157 + iteration sequence. 158 + - **`--commit-report` is the write-side opt-in**: without it the stage 159 + still emits all 10 rows (mostly `Skipped`), but it will not POST 160 + authenticated report bodies to the labeler. `ContractPublished` without 161 + `--commit-report` is a stage-skip; with `--commit-report`, missing 162 + `reasonTypes` / `subjectTypes` becomes a `SpecViolation` gating the 163 + rest of the stage. 164 + - **Self-mint only runs for locally-reachable labelers by default**: 165 + `is_local_labeler_hostname` classifies the labeler endpoint; non-local 166 + hosts skip all self-mint checks because the tool's local did:web doc 167 + server can't be reached from a public labeler. `--force-self-mint` 168 + overrides the heuristic. The `SelfMintSigner` (owner of the ephemeral 169 + did:web HTTP server + signing key) is constructed pessimistically in 170 + `LabelerCmd::run` so the stage can skip cheaply when locality fails. 171 + - **PDS-mediated modes are credentials-gated**: the 172 + `pds_service_auth_accepted` and `pds_proxied_accepted` checks require 173 + `--handle` + `--app-password` (enforced as a symmetric clap `requires`). 174 + The pipeline constructs a `RealPdsXrpcClient` only when credentials are 175 + present; otherwise the checks emit `Skipped` with a reason. 176 + - **PDS client targets the reporter's PDS, not the labeler's**: 177 + `createSession` / `getServiceAuth` must be dispatched against the PDS 178 + that actually hosts the reporter's account. The pipeline resolves 179 + `--handle` (via `resolve_handle` → `resolve_did` → `find_service` for 180 + `#atproto_pds`) and uses the resulting URL for `RealPdsXrpcClient`. 181 + Using `IdentityFacts::pds_endpoint` (the labeler's PDS) would surface 182 + as `InvalidToken: Token could not be verified` when reporter and 183 + labeler live on different PDS shards. Resolution failures flatten to a 184 + string and ride through `CreateReportRunOptions::pds_resolution_error` 185 + so both PDS-mediated rows surface a `NetworkError` with a specific 186 + message instead of a silent `Skipped`. 187 + - **PDS-mediated failures split by origin, not by variant**: a single 188 + `PdsServiceAuthRejected` / `PdsProxiedRejected` diagnostic carries a 189 + `ResponseOrigin` field so labeler-side rejections classify as 190 + `SpecViolation` while PDS-side rejections (`getServiceAuth` refused; 191 + proxy rejected before forwarding) classify as `NetworkError`. Keeping 192 + one variant per check preserves the one-diagnostic-per-check shape the 193 + report stage documents, and the Mode-3 upstream-envelope heuristic 194 + (`UpstreamError` / `UpstreamFailure` / 502 / 504) is what 195 + discriminates the two origins on the PDS-proxied path. 196 + - **Sentinel reason string and run-id**: every committed report body 197 + carries a sentinel reason built by `create_report::sentinel::build` 198 + that encodes the run-id (16 hex chars from `getrandom`) and an RFC 3339 199 + UTC timestamp formatted by hand. This makes accidental reports easy to 200 + filter out of moderation queues. The run-id is generated once in 201 + `LabelerCmd::run` and threaded through `CreateReportRunOptions::run_id`. 105 202 106 203 ## Invariants 107 204 ··· 111 208 - `LabelerReport::exit_code` returns `1` if any `SpecViolation` is 112 209 recorded, `2` if not but at least one `NetworkError` is recorded, 113 210 and `0` otherwise. Advisories and skipped checks never fail the run. 211 + - The report stage always records exactly 10 `report::*` rows in 212 + `Check::ORDER` order. Tests (`labeler_report::ac7_1_row_count`, 213 + `labeler_report::ac7_2_canonical_order`) pin this. Any future check 214 + addition/removal is a wire contract change. 114 215 - Snapshot tests under `tests/snapshots/` are part of the contract. Any 115 216 check ID, diagnostic code, or rendered line change must be accompanied 116 - by a reviewed `cargo insta review`. 217 + by a reviewed `cargo insta review`. Rendered `elapsed: Xms` lines are 218 + normalized by `tests::common::normalize_timing` so per-run timing does 219 + not churn snapshots. 117 220 - The pipeline never calls `reqwest::Client::new()` or constructs a 118 221 tokio-tungstenite connection outside of `Real*` seam structs. 119 222 120 223 ## Key files 121 224 122 - - `labeler.rs` — clap args, `LabelerCmd::run`, CLI bootstrap. 123 - - `pipeline.rs` — `LabelerTarget`, `LabelerOptions`, `parse_target`, 225 + - `labeler.rs` — clap args, `LabelerCmd::run`, CLI bootstrap. New flags: 226 + `--commit-report`, `--force-self-mint`, `--self-mint-curve`, 227 + `--report-subject-did`, `--handle`, `--app-password` (the last two are 228 + symmetrically `requires`-bound). 229 + - `pipeline.rs` — `LabelerTarget`, `LabelerOptions` (now carries 230 + `create_report_tee`, `commit_report`, `force_self_mint`, 231 + `self_mint_curve`, `report_subject_override`, `self_mint_signer`, 232 + `pds_credentials`, `pds_xrpc_client` / `pds_xrpc_client_override`, 233 + `run_id`), `CreateReportTeeKind`, `PdsCredentials`, `parse_target`, 124 234 `run_pipeline` orchestration. 125 235 - `identity.rs` — identity stage: DID resolution, labeler record fetch 126 236 (through `atrium-api` types over the `HttpClient` seam), policy 127 - validation. 237 + validation. `IdentityFacts` now also carries 238 + `reason_types` / `subject_types` / `subject_collections` extracted 239 + from the labeler record so the report stage can check contract shape 240 + without re-parsing. 128 241 - `http.rs` — HTTP stage: `RawHttpTee` trait, `RealHttpTee` reqwest 129 242 implementation, first-page / pagination / cursor checks against 130 243 `com.atproto.label.queryLabels`. ··· 132 245 traits, CBOR frame decoder, two-connection backfill / live-tail logic. 133 246 - `crypto.rs` — label canonicalization, signature verification, PLC key 134 247 history fallback. 248 + - `create_report.rs` — report stage entry point `create_report::run`, 249 + `CreateReportTee` + `RealCreateReportTee` seam, `PdsXrpcClient` + 250 + `RealPdsXrpcClient` seam, `PdsJwtFetcher` and `PdsProxiedPoster` 251 + helpers, `Check` enum (10 variants with stable IDs and 252 + `Check::ORDER`), diagnostic structs (`ContractMissing` + 9 per-check 253 + accepted/rejected variants), `XrpcErrorEnvelope` + `RejectionShape` 254 + classifier for 401 envelope checks, `build_minimal_report_body`. 255 + - `create_report/sentinel.rs` — sentinel reason-string builder, RFC 3339 256 + UTC formatter, `new_run_id` (16-hex-char run identifier via 257 + `getrandom`). 258 + - `create_report/did_doc_server.rs` — `DidDocServer`: an RAII 259 + 127.0.0.1:0-bound HTTP/1.1 server that serves a one-shot did:web 260 + document so the labeler can resolve our self-mint identity. 261 + - `create_report/self_mint.rs` — `SelfMintSigner` (owns signing key + 262 + `DidDocServer`) and `SelfMintCurve` clap `ValueEnum` (`es256`, 263 + `es256k`). 264 + - `create_report/pollution.rs` — pollution-avoidance helpers 265 + `choose_reason_type` and `choose_subject` so committing checks never 266 + submit plausible moderation content. 135 267 136 268 ## Gotchas 137 269 138 270 - `LabelerTarget::Endpoint { did: None }` runs HTTP and subscription but 139 - skips identity and crypto. Emitting those as "blocked" rather than 140 - "skipped — no DID supplied" is a regression. 271 + skips identity, crypto, and report. Emitting those as "blocked" rather 272 + than "skipped — no DID supplied" is a regression. 141 273 - `RawHttpTee::query_labels(cursor)` must NOT duplicate the first-page 142 274 request for reachability — the stage previously pinged before the real 143 275 request, doubling traffic against real servers. ··· 151 283 - Fixture layout under `tests/fixtures/labeler/<stage>/<case>/` is 152 284 referenced by test helper `gen_fixtures` anchored to 153 285 `CARGO_MANIFEST_DIR`. Empty case directories need a `.gitkeep`. 286 + - The report stage's `SelfMintSigner::spawn` binds a TCP port and starts 287 + a tokio task; it's constructed only when needed (local endpoint or 288 + `--force-self-mint`) to avoid orphaning a server on every run. Do not 289 + move the allocation earlier in `LabelerCmd::run`. 290 + - PDS-mediated modes create a `createSession` exactly once per run; the 291 + resulting access JWT is reused across `getServiceAuth` and the 292 + proxied POST. An `I2`-style regression (double `createSession`) is 293 + visible through `FakePdsXrpcClient::request_count` in tests. 294 + - `normalize_timing` in `tests/common/mod.rs` rewrites the `elapsed: Xms` 295 + footer to a fixed token before snapshot comparison. Report-stage 296 + integration tests depend on this — do not write report-stage snapshots 297 + without going through it.
+1714
src/commands/test/labeler/create_report.rs
··· 1 + //! `report` stage: exercises the labeler's authenticated 2 + //! `com.atproto.moderation.createReport` path. 3 + //! 4 + //! The `sentinel` submodule builds the pollution-avoidance reason string 5 + //! that every committed report body carries. 6 + 7 + use std::borrow::Cow; 8 + use std::sync::Arc; 9 + use std::time::{Duration, SystemTime, UNIX_EPOCH}; 10 + use url::Url; 11 + 12 + use async_trait::async_trait; 13 + use miette::{Diagnostic, NamedSource, SourceSpan}; 14 + use reqwest::StatusCode; 15 + use thiserror::Error; 16 + 17 + use crate::commands::test::labeler::identity::IdentityFacts; 18 + use crate::common::diagnostics::pretty_json_for_display; 19 + use crate::common::identity::{Did, is_local_labeler_hostname}; 20 + use crate::common::report::{CheckResult, CheckStatus, Stage}; 21 + 22 + pub mod did_doc_server; 23 + pub mod pollution; 24 + pub mod self_mint; 25 + pub mod sentinel; 26 + 27 + /// Raw HTTP response from POSTing `com.atproto.moderation.createReport`. 28 + /// 29 + /// Mirrors `RawXrpcResponse` from the HTTP stage but specialized for the 30 + /// createReport shape: no typed decode (positive and negative checks need 31 + /// different decode strategies) and the raw body is kept for diagnostic 32 + /// rendering via miette. 33 + #[derive(Debug)] 34 + pub struct RawCreateReportResponse { 35 + /// HTTP status code. 36 + pub status: StatusCode, 37 + /// Content-Type header value, if present. Lowercased for matching. 38 + pub content_type: Option<String>, 39 + /// Raw response body bytes. 40 + pub raw_body: Arc<[u8]>, 41 + /// The URL that was POSTed to (for diagnostics). 42 + pub source_url: String, 43 + } 44 + 45 + /// Error type for `CreateReportTee` operations. 46 + /// 47 + /// Kept intentionally narrow: either a transport failure (TCP / TLS / DNS / 48 + /// reqwest internal), or a well-formed HTTP response that we return as-is. 49 + /// Callers — i.e., the stage — decide what each non-2xx status means per 50 + /// check. 51 + #[derive(Debug, Error, Diagnostic)] 52 + pub enum CreateReportStageError { 53 + /// Transport-level failure: the request never reached a well-formed 54 + /// HTTP exchange. 55 + #[error("createReport transport error: {source}")] 56 + #[diagnostic(code = "labeler::report::transport_error")] 57 + Transport { 58 + /// Underlying error. 59 + #[source] 60 + source: Box<dyn std::error::Error + Send + Sync>, 61 + }, 62 + } 63 + 64 + /// Trait for POSTing `com.atproto.moderation.createReport`. Production 65 + /// impl (`RealCreateReportTee`) wraps a `reqwest::Client`; tests inject 66 + /// `FakeCreateReportTee` from `tests/common/mod.rs`. 67 + /// 68 + /// The body is serialized from a `serde_json::Value` so negative-shape 69 + /// tests can POST intentionally invalid bodies without fighting the type 70 + /// system. 71 + #[async_trait] 72 + pub trait CreateReportTee: Send + Sync { 73 + /// POST the given body to the labeler's `com.atproto.moderation.createReport` 74 + /// endpoint. 75 + /// 76 + /// # Arguments 77 + /// * `auth` — optional Bearer token. `None` ⇒ no `Authorization` header 78 + /// (for the `unauthenticated_rejected` check). `Some(token)` is 79 + /// included as `Authorization: Bearer {token}`. 80 + /// * `body` — JSON body to POST. The impl sends `Content-Type: application/json`. 81 + async fn post_create_report( 82 + &self, 83 + auth: Option<&str>, 84 + body: &serde_json::Value, 85 + ) -> Result<RawCreateReportResponse, CreateReportStageError>; 86 + } 87 + 88 + /// Raw HTTP response from XRPC calls to the PDS. 89 + /// 90 + /// Similar to `RawCreateReportResponse` but used for PDS-specific calls 91 + /// (createSession, getServiceAuth) where the response needs to be parsed 92 + /// as JSON by the caller. 93 + #[derive(Debug)] 94 + pub struct RawPdsXrpcResponse { 95 + /// HTTP status code. 96 + pub status: StatusCode, 97 + /// Raw response body bytes. 98 + pub raw_body: Arc<[u8]>, 99 + /// Content-Type header value, if present. Lowercased for matching. 100 + pub content_type: Option<String>, 101 + /// The URL that was requested (for diagnostics). 102 + pub source_url: String, 103 + } 104 + 105 + /// Narrow seam for POSTing/GETting against the user's PDS. 106 + /// 107 + /// The existing `HttpClient` in `src/common/identity.rs` is GET-only and 108 + /// does not support bearer headers or request bodies. This trait exists 109 + /// to keep those capabilities out of the identity-resolution seam. 110 + #[async_trait] 111 + pub trait PdsXrpcClient: Send + Sync { 112 + /// POST `body` (JSON-serialized) to the PDS endpoint at the given path 113 + /// (e.g., `"xrpc/com.atproto.server.createSession"`). Optional bearer 114 + /// and `atproto-proxy` headers. 115 + async fn post( 116 + &self, 117 + path: &str, 118 + bearer: Option<&str>, 119 + atproto_proxy: Option<&str>, 120 + body: &serde_json::Value, 121 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 122 + 123 + /// GET the PDS endpoint at the given path with optional bearer and 124 + /// URL-encoded query pairs. 125 + async fn get( 126 + &self, 127 + path: &str, 128 + bearer: Option<&str>, 129 + query: &[(&str, &str)], 130 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError>; 131 + } 132 + 133 + /// Real `PdsXrpcClient` implementation using reqwest. 134 + pub struct RealPdsXrpcClient { 135 + client: reqwest::Client, 136 + base: Url, 137 + } 138 + 139 + impl RealPdsXrpcClient { 140 + /// Create a new `RealPdsXrpcClient` using the given shared reqwest 141 + /// client and PDS base URL. 142 + pub fn new(client: reqwest::Client, base: Url) -> Self { 143 + Self { client, base } 144 + } 145 + } 146 + 147 + #[async_trait] 148 + impl PdsXrpcClient for RealPdsXrpcClient { 149 + async fn post( 150 + &self, 151 + path: &str, 152 + bearer: Option<&str>, 153 + atproto_proxy: Option<&str>, 154 + body: &serde_json::Value, 155 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 156 + let mut url = self.base.clone(); 157 + url.set_path(path); 158 + let source_url = url.to_string(); 159 + let mut req = self 160 + .client 161 + .post(url.as_str()) 162 + .header("Content-Type", "application/json") 163 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 164 + if let Some(b) = bearer { 165 + req = req.header("Authorization", format!("Bearer {b}")); 166 + } 167 + if let Some(p) = atproto_proxy { 168 + req = req.header("atproto-proxy", p); 169 + } 170 + let resp = req 171 + .send() 172 + .await 173 + .map_err(|e| CreateReportStageError::Transport { 174 + source: Box::new(e), 175 + })?; 176 + let status = resp.status(); 177 + let content_type = resp 178 + .headers() 179 + .get(reqwest::header::CONTENT_TYPE) 180 + .and_then(|h| h.to_str().ok()) 181 + .map(|s| s.to_ascii_lowercase()); 182 + let body = resp 183 + .bytes() 184 + .await 185 + .map_err(|e| CreateReportStageError::Transport { 186 + source: Box::new(e), 187 + })?; 188 + Ok(RawPdsXrpcResponse { 189 + status, 190 + raw_body: Arc::from(body.as_ref()), 191 + content_type, 192 + source_url, 193 + }) 194 + } 195 + 196 + async fn get( 197 + &self, 198 + path: &str, 199 + bearer: Option<&str>, 200 + query: &[(&str, &str)], 201 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 202 + let mut url = self.base.clone(); 203 + url.set_path(path); 204 + { 205 + let mut pairs = url.query_pairs_mut(); 206 + for (k, v) in query { 207 + pairs.append_pair(k, v); 208 + } 209 + } 210 + let source_url = url.to_string(); 211 + let mut req = self.client.get(url.as_str()); 212 + if let Some(b) = bearer { 213 + req = req.header("Authorization", format!("Bearer {b}")); 214 + } 215 + let resp = req 216 + .send() 217 + .await 218 + .map_err(|e| CreateReportStageError::Transport { 219 + source: Box::new(e), 220 + })?; 221 + let status = resp.status(); 222 + let content_type = resp 223 + .headers() 224 + .get(reqwest::header::CONTENT_TYPE) 225 + .and_then(|h| h.to_str().ok()) 226 + .map(|s| s.to_ascii_lowercase()); 227 + let body = resp 228 + .bytes() 229 + .await 230 + .map_err(|e| CreateReportStageError::Transport { 231 + source: Box::new(e), 232 + })?; 233 + Ok(RawPdsXrpcResponse { 234 + status, 235 + raw_body: Arc::from(body.as_ref()), 236 + content_type, 237 + source_url, 238 + }) 239 + } 240 + } 241 + 242 + /// Real `CreateReportTee` implementation using reqwest. 243 + pub struct RealCreateReportTee { 244 + client: reqwest::Client, 245 + endpoint: Url, 246 + } 247 + 248 + impl RealCreateReportTee { 249 + /// Create a new `RealCreateReportTee` using the given shared reqwest 250 + /// client and labeler endpoint. The endpoint is the labeler's service 251 + /// URL (e.g., `https://labeler.example.com`); the POST path 252 + /// `/xrpc/com.atproto.moderation.createReport` is appended. 253 + pub fn new(client: reqwest::Client, endpoint: Url) -> Self { 254 + Self { client, endpoint } 255 + } 256 + } 257 + 258 + #[async_trait] 259 + impl CreateReportTee for RealCreateReportTee { 260 + async fn post_create_report( 261 + &self, 262 + auth: Option<&str>, 263 + body: &serde_json::Value, 264 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 265 + let mut url = self.endpoint.clone(); 266 + url.set_path("xrpc/com.atproto.moderation.createReport"); 267 + let source_url = url.to_string(); 268 + 269 + tracing::debug!( 270 + url = %source_url, 271 + auth_kind = match auth { 272 + None => "none", 273 + Some(t) if !t.starts_with("ey") => "malformed", 274 + Some(_) => "jwt", 275 + }, 276 + "report stage: issuing createReport POST" 277 + ); 278 + 279 + let mut req = self 280 + .client 281 + .post(url.as_str()) 282 + .header("Content-Type", "application/json") 283 + .body(serde_json::to_vec(body).expect("serde_json::Value always serializes")); 284 + if let Some(token) = auth { 285 + req = req.header("Authorization", format!("Bearer {token}")); 286 + } 287 + 288 + let response = req 289 + .send() 290 + .await 291 + .map_err(|e| CreateReportStageError::Transport { 292 + source: Box::new(e), 293 + })?; 294 + 295 + let status = response.status(); 296 + let content_type = response 297 + .headers() 298 + .get(reqwest::header::CONTENT_TYPE) 299 + .and_then(|h| h.to_str().ok()) 300 + .map(|s| s.to_ascii_lowercase()); 301 + 302 + let body_bytes = response 303 + .bytes() 304 + .await 305 + .map_err(|e| CreateReportStageError::Transport { 306 + source: Box::new(e), 307 + })?; 308 + 309 + tracing::debug!( 310 + url = %source_url, 311 + status = %status, 312 + body_len = body_bytes.len(), 313 + "report stage: createReport response received" 314 + ); 315 + 316 + Ok(RawCreateReportResponse { 317 + status, 318 + content_type, 319 + raw_body: Arc::from(body_bytes.as_ref()), 320 + source_url, 321 + }) 322 + } 323 + } 324 + 325 + /// Error type for `PdsJwtFetcher` operations. 326 + /// 327 + /// Carries a human-readable message; every PDS-side failure is treated as 328 + /// a `NetworkError` by the report stage (per AC5.3 / AC6.3). 329 + #[derive(Debug)] 330 + pub enum PdsJwtFetchError { 331 + Transport(CreateReportStageError), 332 + Failed(RawPdsXrpcResponse), 333 + InvalidBody { 334 + resp: RawPdsXrpcResponse, 335 + error: serde_json::Error, 336 + }, 337 + MissingToken(RawPdsXrpcResponse), 338 + } 339 + 340 + impl std::fmt::Display for PdsJwtFetchError { 341 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 342 + match self { 343 + PdsJwtFetchError::Transport(e) => write!(f, "getServiceAuth transport: {e}"), 344 + PdsJwtFetchError::Failed(resp) => { 345 + write!(f, "getServiceAuth returned status {}", resp.status) 346 + } 347 + PdsJwtFetchError::InvalidBody { error, .. } => { 348 + write!(f, "getServiceAuth body not JSON: {error}") 349 + } 350 + PdsJwtFetchError::MissingToken(_) => write!(f, "getServiceAuth response missing token"), 351 + } 352 + } 353 + } 354 + 355 + impl PdsJwtFetchError { 356 + fn into_diagnostic(self) -> Option<Box<dyn miette::Diagnostic + Send + Sync>> { 357 + match self { 358 + Self::Transport(_) => None, 359 + Self::Failed(resp) | Self::InvalidBody { resp, .. } | Self::MissingToken(resp) => { 360 + let (source_code, span) = body_as_named_source_from_pds(&resp); 361 + let diag = CreateReportDiagnostic::PdsServiceAuthRejected { 362 + origin: ResponseOrigin::Pds, 363 + status: resp.status.as_u16(), 364 + source_code, 365 + span, 366 + }; 367 + Some(Box::new(diag)) 368 + } 369 + } 370 + } 371 + } 372 + 373 + /// Fetches a service-auth JWT from a PDS by first creating a session and 374 + /// then calling `getServiceAuth`. Used in mode-2 (`pds_service_auth_accepted`). 375 + pub struct PdsJwtFetcher<'a> { 376 + client: &'a dyn PdsXrpcClient, 377 + } 378 + 379 + impl<'a> PdsJwtFetcher<'a> { 380 + /// Create a new `PdsJwtFetcher` using the given PDS client. 381 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 382 + Self { client } 383 + } 384 + 385 + /// Call `getServiceAuth` using the provided access JWT, returning the 386 + /// minted service-auth JWT. The access JWT should come from a prior 387 + /// `createSession` call. 388 + pub async fn fetch_with_jwt( 389 + &self, 390 + access_jwt: &str, 391 + aud: &str, 392 + lxm: &str, 393 + exp_absolute_unix: i64, 394 + ) -> Result<String, PdsJwtFetchError> { 395 + // getServiceAuth (GET with query params). 396 + let exp_s = exp_absolute_unix.to_string(); 397 + let resp = self 398 + .client 399 + .get( 400 + "xrpc/com.atproto.server.getServiceAuth", 401 + Some(access_jwt), 402 + &[("aud", aud), ("lxm", lxm), ("exp", &exp_s)], 403 + ) 404 + .await 405 + .map_err(PdsJwtFetchError::Transport)?; 406 + if !resp.status.is_success() { 407 + return Err(PdsJwtFetchError::Failed(resp)); 408 + } 409 + let token = match serde_json::from_slice::<serde_json::Value>(&resp.raw_body) { 410 + Err(error) => Err(PdsJwtFetchError::InvalidBody { resp, error }), 411 + Ok(auth) => auth["token"] 412 + .as_str() 413 + .map(|s| s.to_string()) 414 + .ok_or_else(|| PdsJwtFetchError::MissingToken(resp)), 415 + }?; 416 + 417 + Ok(token) 418 + } 419 + } 420 + 421 + /// Posts `com.atproto.moderation.createReport` to the PDS (not the 422 + /// labeler) with the `atproto-proxy` header, letting the PDS mint and 423 + /// forward the JWT itself. 424 + pub struct PdsProxiedPoster<'a> { 425 + client: &'a dyn PdsXrpcClient, 426 + } 427 + 428 + impl<'a> PdsProxiedPoster<'a> { 429 + /// Create a new `PdsProxiedPoster` using the given PDS client. 430 + pub fn new(client: &'a dyn PdsXrpcClient) -> Self { 431 + Self { client } 432 + } 433 + 434 + /// Post the createReport body through the PDS with the given user 435 + /// access JWT. Returns the `RawPdsXrpcResponse` so the caller can 436 + /// classify success / labeler-side rejection / PDS-side rejection. 437 + pub async fn post( 438 + &self, 439 + labeler_did: &str, 440 + access_jwt: &str, 441 + body: &serde_json::Value, 442 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 443 + self.client 444 + .post( 445 + "xrpc/com.atproto.moderation.createReport", 446 + Some(access_jwt), 447 + Some(&format!("{labeler_did}#atproto_labeler")), 448 + body, 449 + ) 450 + .await 451 + } 452 + } 453 + 454 + /// Minimal per-check outcome facts for possible future consumer stages. 455 + /// All three `Option<bool>` fields are `None` unless the corresponding 456 + /// positive check ran and produced a concrete outcome. 457 + #[derive(Debug, Clone, Default)] 458 + pub struct CreateReportFacts { 459 + /// `self_mint_accepted` outcome: `Some(true)` on Pass, `Some(false)` on 460 + /// SpecViolation, `None` on Skipped/NetworkError. 461 + pub self_mint_succeeded: Option<bool>, 462 + /// `pds_service_auth_accepted` outcome (see above). 463 + pub pds_service_auth_succeeded: Option<bool>, 464 + /// `pds_proxied_accepted` outcome (see above). 465 + pub pds_proxied_succeeded: Option<bool>, 466 + } 467 + 468 + /// Stage output: facts (populated only when the stage produced meaningful 469 + /// outcome data) and the full 10-row results vector. 470 + #[derive(Debug)] 471 + pub struct CreateReportStageOutput { 472 + pub facts: Option<CreateReportFacts>, 473 + pub results: Vec<CheckResult>, 474 + } 475 + 476 + /// Stable check identifiers for the `report` stage. 477 + /// 478 + /// Order MUST match the DoD ordering (AC7.2): contract, unauth, malformed, 479 + /// wrong-aud, wrong-lxm, expired, rejected-shape, self-mint, pds-service-auth, 480 + /// pds-proxied. 481 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 482 + pub enum Check { 483 + ContractPublished, 484 + UnauthenticatedRejected, 485 + MalformedBearerRejected, 486 + WrongAudRejected, 487 + WrongLxmRejected, 488 + ExpiredRejected, 489 + RejectedShapeReturns400, 490 + SelfMintAccepted, 491 + PdsServiceAuthAccepted, 492 + PdsProxiedAccepted, 493 + } 494 + 495 + impl Check { 496 + /// Stable `CheckResult.id` string. 497 + pub fn id(self) -> &'static str { 498 + match self { 499 + Check::ContractPublished => "report::contract_published", 500 + Check::UnauthenticatedRejected => "report::unauthenticated_rejected", 501 + Check::MalformedBearerRejected => "report::malformed_bearer_rejected", 502 + Check::WrongAudRejected => "report::wrong_aud_rejected", 503 + Check::WrongLxmRejected => "report::wrong_lxm_rejected", 504 + Check::ExpiredRejected => "report::expired_rejected", 505 + Check::RejectedShapeReturns400 => "report::rejected_shape_returns_400", 506 + Check::SelfMintAccepted => "report::self_mint_accepted", 507 + Check::PdsServiceAuthAccepted => "report::pds_service_auth_accepted", 508 + Check::PdsProxiedAccepted => "report::pds_proxied_accepted", 509 + } 510 + } 511 + 512 + /// Canonical iteration order for the 10 checks, matching AC7.2. 513 + pub const ORDER: [Check; 10] = [ 514 + Check::ContractPublished, 515 + Check::UnauthenticatedRejected, 516 + Check::MalformedBearerRejected, 517 + Check::WrongAudRejected, 518 + Check::WrongLxmRejected, 519 + Check::ExpiredRejected, 520 + Check::RejectedShapeReturns400, 521 + Check::SelfMintAccepted, 522 + Check::PdsServiceAuthAccepted, 523 + Check::PdsProxiedAccepted, 524 + ]; 525 + 526 + /// Build a `Pass` result for this check with a default summary. 527 + pub fn pass(self) -> CheckResult { 528 + CheckResult { 529 + id: self.id(), 530 + stage: Stage::REPORT, 531 + status: CheckStatus::Pass, 532 + summary: Cow::Borrowed(self.default_summary_pass()), 533 + diagnostic: None, 534 + skipped_reason: None, 535 + } 536 + } 537 + 538 + /// Build a `SpecViolation` result for this check with an optional 539 + /// diagnostic. 540 + pub fn spec_violation(self, diagnostic: CreateReportDiagnostic) -> CheckResult { 541 + CheckResult { 542 + id: self.id(), 543 + stage: Stage::REPORT, 544 + status: CheckStatus::SpecViolation, 545 + summary: Cow::Borrowed(self.default_summary_fail()), 546 + diagnostic: Some(Box::new(diagnostic) as _), 547 + skipped_reason: None, 548 + } 549 + } 550 + 551 + /// Build an `Advisory` result (used by `rejected_shape_returns_400` AC3.6). 552 + pub fn advisory(self, diagnostic: CreateReportDiagnostic) -> CheckResult { 553 + CheckResult { 554 + id: self.id(), 555 + stage: Stage::REPORT, 556 + status: CheckStatus::Advisory, 557 + summary: Cow::Borrowed(self.default_summary_fail()), 558 + diagnostic: Some(Box::new(diagnostic) as _), 559 + skipped_reason: None, 560 + } 561 + } 562 + 563 + /// Build a `NetworkError` result (used by PDS-side failure modes). 564 + pub fn network_error(self, message: String) -> CheckResult { 565 + CheckResult { 566 + id: self.id(), 567 + stage: Stage::REPORT, 568 + status: CheckStatus::NetworkError, 569 + summary: Cow::Owned(format!("{}: {message}", self.default_summary_fail())), 570 + diagnostic: None, 571 + skipped_reason: None, 572 + } 573 + } 574 + 575 + /// Build a `Skipped` result with the supplied reason. 576 + pub fn skip(self, reason: &'static str) -> CheckResult { 577 + CheckResult { 578 + id: self.id(), 579 + stage: Stage::REPORT, 580 + status: CheckStatus::Skipped, 581 + summary: Cow::Borrowed(self.default_summary_pass()), 582 + diagnostic: None, 583 + skipped_reason: Some(Cow::Borrowed(reason)), 584 + } 585 + } 586 + 587 + fn default_summary_pass(self) -> &'static str { 588 + match self { 589 + Check::ContractPublished => "Labeler advertises reportable shape", 590 + Check::UnauthenticatedRejected => "Unauthenticated report rejected", 591 + Check::MalformedBearerRejected => "Malformed bearer rejected", 592 + Check::WrongAudRejected => "JWT with wrong `aud` rejected", 593 + Check::WrongLxmRejected => "JWT with wrong `lxm` rejected", 594 + Check::ExpiredRejected => "Expired JWT rejected", 595 + Check::RejectedShapeReturns400 => "Invalid shape returns 400 InvalidRequest", 596 + Check::SelfMintAccepted => "Self-mint report accepted", 597 + Check::PdsServiceAuthAccepted => "PDS-minted JWT accepted", 598 + Check::PdsProxiedAccepted => "PDS-proxied report accepted", 599 + } 600 + } 601 + 602 + fn default_summary_fail(self) -> &'static str { 603 + match self { 604 + Check::ContractPublished => "Labeler does not advertise a reportable shape", 605 + Check::UnauthenticatedRejected => { 606 + "Unauthenticated report accepted (should have been rejected)" 607 + } 608 + Check::MalformedBearerRejected => { 609 + "Malformed bearer accepted (should have been rejected)" 610 + } 611 + Check::WrongAudRejected => "JWT with wrong `aud` accepted", 612 + Check::WrongLxmRejected => "JWT with wrong `lxm` accepted", 613 + Check::ExpiredRejected => "Expired JWT accepted", 614 + Check::RejectedShapeReturns400 => "Rejection status was not 400 InvalidRequest", 615 + Check::SelfMintAccepted => "Self-mint report rejected", 616 + Check::PdsServiceAuthAccepted => "PDS-minted JWT rejected", 617 + Check::PdsProxiedAccepted => "PDS-proxied report rejected", 618 + } 619 + } 620 + } 621 + 622 + /// Aggregate of the stage-relevant options, extracted from `LabelerOptions` 623 + /// by the pipeline and passed to `run`. Having a local, narrow shape 624 + /// avoids forcing `run`'s signature to take everything in `LabelerOptions`. 625 + pub struct CreateReportRunOptions<'a> { 626 + pub commit_report: bool, 627 + pub force_self_mint: bool, 628 + pub self_mint_curve: self_mint::SelfMintCurve, 629 + pub report_subject_override: Option<&'a crate::common::identity::Did>, 630 + pub self_mint_signer: Option<&'a self_mint::SelfMintSigner>, 631 + pub pds_credentials: Option<&'a crate::commands::test::labeler::pipeline::PdsCredentials>, 632 + pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>, 633 + /// Populated by the pipeline when `--handle` was supplied but resolving 634 + /// the handle to the reporter's PDS endpoint failed. Surfaced as a 635 + /// `NetworkError` on both PDS-mediated checks so the operator can tell 636 + /// a resolution failure apart from a missing-credentials skip. 637 + pub pds_resolution_error: Option<&'a str>, 638 + pub run_id: &'a str, 639 + } 640 + 641 + /// Run the report stage. 642 + /// 643 + /// Stage inputs are passed via `LabelerOptions` (or directly as arguments 644 + /// here, to keep the signature mirroring the other stages). The stage 645 + /// always emits exactly 10 `report::*` CheckResults (AC7.1) in canonical 646 + /// order (AC7.2), regardless of gating decisions. 647 + pub async fn run( 648 + identity_facts: Option<&crate::commands::test::labeler::identity::IdentityFacts>, 649 + report_tee: &dyn CreateReportTee, 650 + opts: &CreateReportRunOptions<'_>, 651 + ) -> CreateReportStageOutput { 652 + let mut results = Vec::with_capacity(10); 653 + 654 + // If identity didn't land, every check is blocked by the identity 655 + // stage. Emit 10 Skipped rows and return. 656 + let Some(id_facts) = identity_facts else { 657 + for c in Check::ORDER { 658 + results.push(c.skip("blocked by identity stage")); 659 + } 660 + return CreateReportStageOutput { 661 + facts: None, 662 + results, 663 + }; 664 + }; 665 + 666 + // Examine the published contract (from Task 0's extended IdentityFacts). 667 + let reason_types = id_facts.reason_types.as_ref(); 668 + let subject_types = id_facts.subject_types.as_ref(); 669 + let has_reason_types = reason_types.map(|v| !v.is_empty()).unwrap_or(false); 670 + let has_subject_types = subject_types.map(|v| !v.is_empty()).unwrap_or(false); 671 + let contract_advertised = has_reason_types && has_subject_types; 672 + 673 + // AC1: compute the contract_published row and the blocking reason for 674 + // all downstream checks if the contract is missing. 675 + // 676 + // Control-flow contract: each branch below pushes EXACTLY 10 rows 677 + // (1 contract row + 9 downstream) and returns. No fallthrough — the 678 + // "contract advertised" branch is the one that invokes the 679 + // authenticated negative checks and the committing positive checks. 680 + if !contract_advertised { 681 + if opts.commit_report { 682 + // AC1.3: commit requested, contract missing ⇒ SpecViolation + 683 + // every other check blocked by this one. 684 + let diag = CreateReportDiagnostic::ContractMissing { 685 + has_reason_types, 686 + has_subject_types, 687 + }; 688 + results.push(Check::ContractPublished.spec_violation(diag)); 689 + for c in Check::ORDER.iter().skip(1).copied() { 690 + results.push(c.skip("blocked by `report::contract_published`")); 691 + } 692 + } else { 693 + // AC1.2: no commit, contract missing ⇒ whole stage skipped. 694 + results.push( 695 + Check::ContractPublished.skip("labeler does not advertise report acceptance"), 696 + ); 697 + for c in Check::ORDER.iter().skip(1).copied() { 698 + results.push(c.skip("labeler does not advertise report acceptance")); 699 + } 700 + } 701 + return CreateReportStageOutput { 702 + facts: None, 703 + results, 704 + }; 705 + } 706 + 707 + // Contract advertised. Emit the Pass row and fall through into the 708 + // per-check logic. 709 + results.push(Check::ContractPublished.pass()); 710 + 711 + // Minimal body for negative checks. The labeler should reject at auth 712 + // before examining body shape; we nonetheless supply a plausible body so 713 + // a labeler that performs body validation first doesn't return 400 714 + // instead of 401, which would make the test ambiguous. 715 + let negative_body = build_minimal_report_body(id_facts); 716 + 717 + // AC2.1/AC2.2/AC2.5 — unauthenticated: 718 + match report_tee.post_create_report(None, &negative_body).await { 719 + Ok(resp) => match RejectionShape::classify(&resp) { 720 + RejectionShape::Conformant { .. } => { 721 + results.push(Check::UnauthenticatedRejected.pass()); 722 + } 723 + RejectionShape::ConformantStatusNonConformantShape => { 724 + results.push(CheckResult { 725 + summary: Cow::Borrowed( 726 + "Unauthenticated report rejected (status 401, non-conformant envelope)", 727 + ), 728 + ..Check::UnauthenticatedRejected.pass() 729 + }); 730 + } 731 + RejectionShape::WrongStatus { status } => { 732 + let status_u16 = status.as_u16(); 733 + let (source_code, span) = body_as_named_source(&resp); 734 + let diag = CreateReportDiagnostic::UnauthenticatedAccepted { 735 + status: status_u16, 736 + source_code, 737 + span, 738 + }; 739 + results.push(Check::UnauthenticatedRejected.spec_violation(diag)); 740 + } 741 + }, 742 + Err(CreateReportStageError::Transport { source }) => { 743 + results.push(Check::UnauthenticatedRejected.network_error(source.to_string())); 744 + } 745 + } 746 + 747 + // AC2.3/AC2.4 — malformed bearer: 748 + match report_tee 749 + .post_create_report(Some("not-a-jwt"), &negative_body) 750 + .await 751 + { 752 + Ok(resp) => match RejectionShape::classify(&resp) { 753 + RejectionShape::Conformant { .. } => { 754 + results.push(Check::MalformedBearerRejected.pass()); 755 + } 756 + RejectionShape::ConformantStatusNonConformantShape => { 757 + results.push(CheckResult { 758 + summary: Cow::Borrowed( 759 + "Malformed bearer rejected (status 401, non-conformant envelope)", 760 + ), 761 + ..Check::MalformedBearerRejected.pass() 762 + }); 763 + } 764 + RejectionShape::WrongStatus { status } => { 765 + let status_u16 = status.as_u16(); 766 + let (source_code, span) = body_as_named_source(&resp); 767 + let diag = CreateReportDiagnostic::MalformedBearerAccepted { 768 + status: status_u16, 769 + source_code, 770 + span, 771 + }; 772 + results.push(Check::MalformedBearerRejected.spec_violation(diag)); 773 + } 774 + }, 775 + Err(CreateReportStageError::Transport { source }) => { 776 + results.push(Check::MalformedBearerRejected.network_error(source.to_string())); 777 + } 778 + } 779 + 780 + // Recompute self_mint_viable using the *actual* labeler endpoint now 781 + // that identity has run. 782 + let is_local_labeler = is_local_labeler_hostname(&id_facts.labeler_endpoint); 783 + let self_mint_viable = opts.force_self_mint || is_local_labeler; 784 + 785 + let signer_for_negative = if self_mint_viable { 786 + opts.self_mint_signer 787 + } else { 788 + None 789 + }; 790 + 791 + // Compute `now` once for all JWT-based checks. 792 + let now = SystemTime::now() 793 + .duration_since(UNIX_EPOCH) 794 + .map(|d| d.as_secs() as i64) 795 + .unwrap_or(0); 796 + 797 + // CRITICAL: this block either emits 4 Skipped rows OR emits 4 real-check 798 + // rows, then falls through to the committing checks for SelfMintAccepted, 799 + // PdsServiceAuthAccepted, PdsProxiedAccepted. Do NOT `return` here — the 800 + // stage always emits 10 rows total, and the later checks need to run 801 + // regardless of self-mint viability. 802 + if let Some(signer) = signer_for_negative { 803 + // Mint per-check tokens from the valid-claims template. All four 804 + // checks share the same `now`, `lxm`, and `template`. Each check 805 + // inlines its own `match` rather than using a shared helper — 806 + // nested `async fn` is unsupported in stable Rust, and a closure 807 + // returning `Box<dyn Diagnostic>` across `await` would force `Send` 808 + // bounds that complicate the call site. 809 + let lxm = "com.atproto.moderation.createReport"; 810 + let template = 811 + signer.valid_claims_template(&id_facts.did, lxm, now, Duration::from_secs(60)); 812 + 813 + let negative_body = build_minimal_report_body(id_facts); 814 + 815 + // AC3.1/AC3.2 — wrong aud: 816 + { 817 + let mut claims = template.clone(); 818 + claims.aud = "did:plc:0000000000000000000000000".to_string(); 819 + let token = signer.sign_jwt(claims); 820 + match report_tee 821 + .post_create_report(Some(&token), &negative_body) 822 + .await 823 + { 824 + Ok(resp) => match RejectionShape::classify(&resp) { 825 + RejectionShape::Conformant { .. } => { 826 + results.push(Check::WrongAudRejected.pass()) 827 + } 828 + RejectionShape::ConformantStatusNonConformantShape => { 829 + results.push(CheckResult { 830 + summary: Cow::Borrowed( 831 + "Rejected with 401 but envelope is non-conformant", 832 + ), 833 + ..Check::WrongAudRejected.pass() 834 + }) 835 + } 836 + RejectionShape::WrongStatus { .. } => { 837 + let (source_code, span) = body_as_named_source(&resp); 838 + let diag = CreateReportDiagnostic::WrongAudAccepted { 839 + status: resp.status.as_u16(), 840 + source_code, 841 + span, 842 + }; 843 + results.push(Check::WrongAudRejected.spec_violation(diag)); 844 + } 845 + }, 846 + Err(CreateReportStageError::Transport { source }) => { 847 + results.push(Check::WrongAudRejected.network_error(source.to_string())); 848 + } 849 + } 850 + } 851 + 852 + // AC3.3 — wrong lxm: 853 + { 854 + let mut claims = template.clone(); 855 + claims.lxm = "com.atproto.server.getSession".to_string(); 856 + let token = signer.sign_jwt(claims); 857 + match report_tee 858 + .post_create_report(Some(&token), &negative_body) 859 + .await 860 + { 861 + Ok(resp) => match RejectionShape::classify(&resp) { 862 + RejectionShape::Conformant { .. } => { 863 + results.push(Check::WrongLxmRejected.pass()) 864 + } 865 + RejectionShape::ConformantStatusNonConformantShape => { 866 + results.push(CheckResult { 867 + summary: Cow::Borrowed( 868 + "Rejected with 401 but envelope is non-conformant", 869 + ), 870 + ..Check::WrongLxmRejected.pass() 871 + }) 872 + } 873 + RejectionShape::WrongStatus { .. } => { 874 + let (source_code, span) = body_as_named_source(&resp); 875 + let diag = CreateReportDiagnostic::WrongLxmAccepted { 876 + status: resp.status.as_u16(), 877 + source_code, 878 + span, 879 + }; 880 + results.push(Check::WrongLxmRejected.spec_violation(diag)); 881 + } 882 + }, 883 + Err(CreateReportStageError::Transport { source }) => { 884 + results.push(Check::WrongLxmRejected.network_error(source.to_string())); 885 + } 886 + } 887 + } 888 + 889 + // AC3.4 — expired: 890 + { 891 + let mut claims = template.clone(); 892 + claims.exp = now - 300; 893 + claims.iat = now - 360; 894 + let token = signer.sign_jwt(claims); 895 + match report_tee 896 + .post_create_report(Some(&token), &negative_body) 897 + .await 898 + { 899 + Ok(resp) => match RejectionShape::classify(&resp) { 900 + RejectionShape::Conformant { .. } => { 901 + results.push(Check::ExpiredRejected.pass()) 902 + } 903 + RejectionShape::ConformantStatusNonConformantShape => { 904 + results.push(CheckResult { 905 + summary: Cow::Borrowed( 906 + "Rejected with 401 but envelope is non-conformant", 907 + ), 908 + ..Check::ExpiredRejected.pass() 909 + }) 910 + } 911 + RejectionShape::WrongStatus { .. } => { 912 + let (source_code, span) = body_as_named_source(&resp); 913 + let diag = CreateReportDiagnostic::ExpiredAccepted { 914 + status: resp.status.as_u16(), 915 + source_code, 916 + span, 917 + }; 918 + results.push(Check::ExpiredRejected.spec_violation(diag)); 919 + } 920 + }, 921 + Err(CreateReportStageError::Transport { source }) => { 922 + results.push(Check::ExpiredRejected.network_error(source.to_string())); 923 + } 924 + } 925 + } 926 + 927 + // AC3.5/AC3.6 — rejected shape: 928 + { 929 + let claims = template.clone(); 930 + let token = signer.sign_jwt(claims); 931 + // Invalid body: a reasonType that is NOT in id_facts.reason_types. 932 + let bogus_reason_type = synth_unadvertised_reason_type(id_facts); 933 + let invalid_body = { 934 + let mut body = negative_body.clone(); 935 + if let Some(obj) = body.as_object_mut() { 936 + obj.insert( 937 + "reasonType".to_string(), 938 + serde_json::Value::String(bogus_reason_type), 939 + ); 940 + } 941 + body 942 + }; 943 + match report_tee 944 + .post_create_report(Some(&token), &invalid_body) 945 + .await 946 + { 947 + Ok(resp) => { 948 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 949 + let error_name = envelope.as_ref().and_then(|e| e.error.clone()); 950 + if resp.status == reqwest::StatusCode::BAD_REQUEST 951 + && error_name.as_deref() == Some("InvalidRequest") 952 + { 953 + // AC3.5: 400 InvalidRequest → Pass. 954 + results.push(Check::RejectedShapeReturns400.pass()); 955 + } else if resp.status == reqwest::StatusCode::UNAUTHORIZED 956 + || resp.status.is_server_error() 957 + { 958 + // AC3.6: 401 or 5xx → Advisory with shape_not_400. 959 + let (source_code, span) = body_as_named_source(&resp); 960 + let diag = CreateReportDiagnostic::ShapeNot400 { 961 + status: resp.status.as_u16(), 962 + error_name: error_name.clone(), 963 + source_code, 964 + span, 965 + }; 966 + results.push(Check::RejectedShapeReturns400.advisory(diag)); 967 + } else if resp.status == reqwest::StatusCode::BAD_REQUEST { 968 + // 400 but not `InvalidRequest` name → Advisory. 969 + let (source_code, span) = body_as_named_source(&resp); 970 + let diag = CreateReportDiagnostic::ShapeNot400 { 971 + status: 400, 972 + error_name: error_name.clone(), 973 + source_code, 974 + span, 975 + }; 976 + results.push(Check::RejectedShapeReturns400.advisory(diag)); 977 + } else { 978 + // Catch-all: 200 accepted → Advisory. A 200 for an invalid 979 + // shape is a labeler looseness issue, not the same category 980 + // as the `self_mint_accepted` SpecViolation (which expects 981 + // a *valid* shape to be accepted). 982 + let (source_code, span) = body_as_named_source(&resp); 983 + let diag = CreateReportDiagnostic::ShapeNot400 { 984 + status: resp.status.as_u16(), 985 + error_name, 986 + source_code, 987 + span, 988 + }; 989 + results.push(Check::RejectedShapeReturns400.advisory(diag)); 990 + } 991 + } 992 + Err(CreateReportStageError::Transport { source }) => { 993 + results.push(Check::RejectedShapeReturns400.network_error(source.to_string())); 994 + } 995 + } 996 + } 997 + } else { 998 + let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)"; 999 + for c in [ 1000 + Check::WrongAudRejected, 1001 + Check::WrongLxmRejected, 1002 + Check::ExpiredRejected, 1003 + Check::RejectedShapeReturns400, 1004 + ] { 1005 + results.push(c.skip(reason)); 1006 + } 1007 + } 1008 + 1009 + // Fallthrough to the committing check logic below. Keeping this block 1010 + // fallthrough-safe is why the `if let Some(signer)` above does NOT 1011 + // `return`. 1012 + 1013 + // AC4.4 — gate on commit_report. 1014 + if !opts.commit_report { 1015 + results.push(Check::SelfMintAccepted.skip("commit gated behind --commit-report")); 1016 + } else if let Some(signer) = signer_for_negative { 1017 + // AC4.1/AC4.2 — construct a positive POST with pollution-avoidance. 1018 + // Reads the contract from the `reason_types` / `subject_types` fields 1019 + // on `IdentityFacts`. 1020 + let reason_type = pollution::choose_reason_type( 1021 + id_facts.reason_types.as_deref().unwrap_or(&[]), 1022 + is_local_labeler, 1023 + ); 1024 + let subject = pollution::choose_subject( 1025 + id_facts.subject_types.as_deref().unwrap_or(&[]), 1026 + signer.issuer_did(), 1027 + opts.report_subject_override, 1028 + is_local_labeler, 1029 + ); 1030 + let sentinel = sentinel::build(opts.run_id, SystemTime::now()); 1031 + let positive_body = serde_json::json!({ 1032 + "reasonType": reason_type, 1033 + "subject": subject, 1034 + "reason": sentinel, 1035 + }); 1036 + 1037 + // AC4.6 — the built body carries the sentinel; the integration test 1038 + // in Task 4 asserts it via FakeCreateReportTee::last_request(). 1039 + 1040 + let claims = signer.valid_claims_template( 1041 + &id_facts.did, 1042 + "com.atproto.moderation.createReport", 1043 + now, 1044 + Duration::from_secs(60), 1045 + ); 1046 + let token = signer.sign_jwt(claims); 1047 + 1048 + match report_tee 1049 + .post_create_report(Some(&token), &positive_body) 1050 + .await 1051 + { 1052 + Ok(resp) if resp.status.is_success() => { 1053 + // AC4.1/AC4.2: Pass. Optionally inspect body for createReport#output 1054 + // shape — loose check: `id` is a number. 1055 + let body_ok = serde_json::from_slice::<serde_json::Value>(&resp.raw_body) 1056 + .ok() 1057 + .and_then(|v| v.get("id").and_then(|id| id.as_i64())) 1058 + .is_some(); 1059 + if body_ok { 1060 + results.push(Check::SelfMintAccepted.pass()); 1061 + } else { 1062 + // 2xx but body doesn't look like createReport#output. Accept as 1063 + // Pass per design (status alone suffices), but note the 1064 + // non-conformant body in the summary. 1065 + results.push(CheckResult { 1066 + summary: Cow::Borrowed( 1067 + "Self-mint report accepted (2xx), body did not match createReport#output shape", 1068 + ), 1069 + ..Check::SelfMintAccepted.pass() 1070 + }); 1071 + } 1072 + } 1073 + Ok(resp) => { 1074 + // AC4.3: non-2xx ⇒ SpecViolation. 1075 + let (source_code, span) = body_as_named_source(&resp); 1076 + let diag = CreateReportDiagnostic::SelfMintRejected { 1077 + status: resp.status.as_u16(), 1078 + source_code, 1079 + span, 1080 + }; 1081 + results.push(Check::SelfMintAccepted.spec_violation(diag)); 1082 + } 1083 + Err(CreateReportStageError::Transport { source }) => { 1084 + results.push(Check::SelfMintAccepted.network_error(source.to_string())); 1085 + } 1086 + } 1087 + } else { 1088 + // AC4.5: commit requested but no signer available (non-viable or not provided). 1089 + // Skip with the same viability reason as the AC3 checks. 1090 + let reason = "self-mint required; labeler endpoint appears non-local (override with --force-self-mint)"; 1091 + results.push(Check::SelfMintAccepted.skip(reason)); 1092 + } 1093 + 1094 + // AC5/AC6 — PDS-mediated modes (modes 2 and 3). 1095 + // Compute the gating precondition common to both PDS checks. 1096 + let pds_gate_reason: &'static str = "requires --handle, --app-password, and --commit-report"; 1097 + let pds_ready = 1098 + opts.commit_report && opts.pds_credentials.is_some() && opts.pds_xrpc_client.is_some(); 1099 + // Distinguish "no credentials" (skip) from "credentials supplied but the 1100 + // reporter's PDS could not be resolved" (network error) so the operator 1101 + // sees a useful failure mode rather than a silent skip. 1102 + let pds_resolution_failed = opts.commit_report 1103 + && opts.pds_credentials.is_some() 1104 + && opts.pds_xrpc_client.is_none() 1105 + && opts.pds_resolution_error.is_some(); 1106 + 1107 + if pds_resolution_failed { 1108 + let msg = opts 1109 + .pds_resolution_error 1110 + .expect("pds_resolution_failed implies Some") 1111 + .to_string(); 1112 + results.push(Check::PdsServiceAuthAccepted.network_error(msg.clone())); 1113 + results.push(Check::PdsProxiedAccepted.network_error(msg)); 1114 + } else if !pds_ready { 1115 + results.push(Check::PdsServiceAuthAccepted.skip(pds_gate_reason)); 1116 + results.push(Check::PdsProxiedAccepted.skip(pds_gate_reason)); 1117 + } else { 1118 + // Safe to unwrap thanks to pds_ready. 1119 + let creds = opts.pds_credentials.expect("pds_ready implies creds"); 1120 + let pds_client = opts.pds_xrpc_client.expect("pds_ready implies client"); 1121 + 1122 + // Reuse locality computed earlier. 1123 + let is_local = is_local_labeler; 1124 + let reason_type = pollution::choose_reason_type( 1125 + id_facts.reason_types.as_deref().unwrap_or(&[]), 1126 + is_local, 1127 + ); 1128 + 1129 + // Fetch the user session (DID and access JWT). Both PDS modes need 1130 + // these upfront. 1131 + match fetch_session_and_did(pds_client, &creds.handle, &creds.app_password).await { 1132 + Err(message) => { 1133 + results.push(Check::PdsServiceAuthAccepted.network_error(message.clone())); 1134 + // AC6.3: if session fetch fails, proxied mode also fails at PDS. 1135 + results.push(Check::PdsProxiedAccepted.network_error(message)); 1136 + } 1137 + Ok(session) => { 1138 + let user_did = Did(session.did); 1139 + let access_jwt = session.access_jwt; 1140 + let subject = pollution::choose_subject( 1141 + id_facts.subject_types.as_deref().unwrap_or(&[]), 1142 + &user_did, 1143 + opts.report_subject_override, 1144 + is_local, 1145 + ); 1146 + let sentinel = sentinel::build(opts.run_id, SystemTime::now()); 1147 + let pds_body = serde_json::json!({ 1148 + "reasonType": reason_type, 1149 + "subject": subject, 1150 + "reason": sentinel, 1151 + }); 1152 + 1153 + // Mode 2: getServiceAuth direct-POST. 1154 + let exp_abs = now + 60; 1155 + let fetcher = PdsJwtFetcher::new(pds_client); 1156 + match fetcher 1157 + .fetch_with_jwt( 1158 + &access_jwt, 1159 + &id_facts.did.0, 1160 + "com.atproto.moderation.createReport", 1161 + exp_abs, 1162 + ) 1163 + .await 1164 + { 1165 + Err(e) => { 1166 + let message = e.to_string(); 1167 + let diagnostic = e.into_diagnostic(); 1168 + results.push(CheckResult { 1169 + diagnostic, 1170 + ..Check::PdsServiceAuthAccepted.network_error(message) 1171 + }); 1172 + } 1173 + Ok(service_jwt) => { 1174 + match report_tee 1175 + .post_create_report(Some(&service_jwt), &pds_body) 1176 + .await 1177 + { 1178 + Ok(resp) if resp.status.is_success() => { 1179 + results.push(Check::PdsServiceAuthAccepted.pass()); 1180 + } 1181 + Ok(resp) => { 1182 + let (source_code, span) = body_as_named_source(&resp); 1183 + let diag = CreateReportDiagnostic::PdsServiceAuthRejected { 1184 + origin: ResponseOrigin::Labeler, 1185 + status: resp.status.as_u16(), 1186 + source_code, 1187 + span, 1188 + }; 1189 + results.push(Check::PdsServiceAuthAccepted.spec_violation(diag)); 1190 + } 1191 + Err(CreateReportStageError::Transport { source }) => { 1192 + // Labeler-side transport failure during direct POST. 1193 + results.push( 1194 + Check::PdsServiceAuthAccepted.network_error(source.to_string()), 1195 + ); 1196 + } 1197 + } 1198 + } 1199 + } 1200 + 1201 + // Mode 3: PDS-proxied. 1202 + let proxier = PdsProxiedPoster::new(pds_client); 1203 + match proxier.post(&id_facts.did.0, &access_jwt, &pds_body).await { 1204 + Err(CreateReportStageError::Transport { source }) => { 1205 + // Transport to the PDS itself; classify PDS-side. 1206 + results.push(Check::PdsProxiedAccepted.network_error(source.to_string())); 1207 + } 1208 + Ok(resp) if resp.status.is_success() => { 1209 + results.push(Check::PdsProxiedAccepted.pass()); 1210 + } 1211 + Ok(resp) => { 1212 + // PDS surfaced a non-2xx. Interpret per envelope to 1213 + // distinguish PDS-side vs labeler-side: 1214 + let (source_code, span) = body_as_named_source_from_pds(&resp); 1215 + let envelope = XrpcErrorEnvelope::parse(&resp.raw_body); 1216 + let err_name = envelope.as_ref().and_then(|e| e.error.clone()); 1217 + let is_upstream_label_error = matches!( 1218 + err_name.as_deref(), 1219 + Some("UpstreamError") | Some("UpstreamFailure") 1220 + ) || resp.status.as_u16() == 502 1221 + || resp.status.as_u16() == 504; 1222 + if is_upstream_label_error { 1223 + // AC6.2: labeler-side rejection surfaced by PDS. 1224 + let diag = CreateReportDiagnostic::PdsProxiedRejected { 1225 + origin: ResponseOrigin::Labeler, 1226 + status: resp.status.as_u16(), 1227 + source_code, 1228 + span, 1229 + }; 1230 + results.push(Check::PdsProxiedAccepted.spec_violation(diag)); 1231 + } else { 1232 + // AC6.3: PDS-side rejection of the proxy attempt. 1233 + let diag = CreateReportDiagnostic::PdsProxiedRejected { 1234 + origin: ResponseOrigin::Pds, 1235 + status: resp.status.as_u16(), 1236 + source_code, 1237 + span, 1238 + }; 1239 + results.push(CheckResult { 1240 + diagnostic: Some(Box::new(diag)), 1241 + ..Check::PdsProxiedAccepted.network_error(format!( 1242 + "PDS rejected proxy attempt with status {}", 1243 + resp.status 1244 + )) 1245 + }); 1246 + } 1247 + } 1248 + } 1249 + } 1250 + } 1251 + } 1252 + 1253 + CreateReportStageOutput { 1254 + facts: None, 1255 + results, 1256 + } 1257 + } 1258 + 1259 + /// Convenience wrapper that does createSession and returns both the DID 1260 + /// and the accessJwt. Needed by both PDS check modes to populate the body 1261 + /// with the correct subject DID. 1262 + struct SessionResult { 1263 + did: String, 1264 + access_jwt: String, 1265 + } 1266 + 1267 + async fn fetch_session_and_did( 1268 + client: &dyn PdsXrpcClient, 1269 + handle: &str, 1270 + app_password: &str, 1271 + ) -> Result<SessionResult, String> { 1272 + let body = serde_json::json!({ "identifier": handle, "password": app_password }); 1273 + let resp = client 1274 + .post("xrpc/com.atproto.server.createSession", None, None, &body) 1275 + .await 1276 + .map_err(|e| format!("createSession transport: {e}"))?; 1277 + if !resp.status.is_success() { 1278 + return Err(format!("createSession returned {}", resp.status)); 1279 + } 1280 + let session: serde_json::Value = 1281 + serde_json::from_slice(&resp.raw_body).map_err(|e| format!("createSession body: {e}"))?; 1282 + let did = session["did"] 1283 + .as_str() 1284 + .ok_or("createSession missing did")? 1285 + .to_string(); 1286 + let access_jwt = session["accessJwt"] 1287 + .as_str() 1288 + .ok_or("createSession missing accessJwt")? 1289 + .to_string(); 1290 + Ok(SessionResult { did, access_jwt }) 1291 + } 1292 + 1293 + /// Synthesize a `reasonType` string that is definitely NOT in the 1294 + /// labeler's advertised `reason_types`. NSID syntax (segments alphanumeric + 1295 + /// period only, fragment after `#`) is strictly valid so the labeler does 1296 + /// not reject for wrong reason (malformed NSID) before checking membership. 1297 + fn synth_unadvertised_reason_type(facts: &IdentityFacts) -> String { 1298 + let empty = Vec::new(); 1299 + let advertised: &[String] = facts.reason_types.as_ref().unwrap_or(&empty); 1300 + for i in 0..1000 { 1301 + let candidate = format!("xyz.atprotodevtool.conformance.defs#unadvertised{i:03}"); 1302 + if !advertised.iter().any(|r| r == &candidate) { 1303 + return candidate; 1304 + } 1305 + } 1306 + // Unreachable in practice. 1307 + "xyz.atprotodevtool.conformance.defs#unadvertisedFallback".to_string() 1308 + } 1309 + 1310 + #[cfg(test)] 1311 + mod check_tests { 1312 + use super::*; 1313 + 1314 + #[test] 1315 + fn check_ids_are_unique_and_report_namespaced() { 1316 + let mut seen = std::collections::HashSet::new(); 1317 + for c in Check::ORDER { 1318 + let id = c.id(); 1319 + assert!(id.starts_with("report::"), "{id} not in report:: namespace"); 1320 + assert!(seen.insert(id), "duplicate check id: {id}"); 1321 + } 1322 + assert_eq!(Check::ORDER.len(), 10, "DoD requires exactly 10 checks"); 1323 + } 1324 + } 1325 + 1326 + /// A loosely-parsed atproto XRPC error envelope. Missing fields are 1327 + /// rendered as `None` rather than failing the parse — the "loose 1328 + /// assertion" philosophy in the design (see "Error envelope assertion 1329 + /// is deliberately loose"). 1330 + #[derive(Debug, Clone)] 1331 + pub struct XrpcErrorEnvelope { 1332 + /// The `error` field (PascalCase error name). `None` if absent or 1333 + /// not a string. 1334 + pub error: Option<String>, 1335 + /// The `message` field. `None` if absent or not a string. 1336 + pub message: Option<String>, 1337 + } 1338 + 1339 + impl XrpcErrorEnvelope { 1340 + /// Try to parse an atproto error envelope from the response body. 1341 + /// Returns `None` only when the body is not valid JSON at all. 1342 + /// Otherwise returns an envelope with whatever fields we could find. 1343 + pub fn parse(body: &[u8]) -> Option<Self> { 1344 + let v: serde_json::Value = serde_json::from_slice(body).ok()?; 1345 + let obj = v.as_object()?; 1346 + Some(Self { 1347 + error: obj.get("error").and_then(|x| x.as_str()).map(String::from), 1348 + message: obj 1349 + .get("message") 1350 + .and_then(|x| x.as_str()) 1351 + .map(String::from), 1352 + }) 1353 + } 1354 + 1355 + /// `true` when the envelope has a non-empty `error` string. 1356 + pub fn has_nonempty_error(&self) -> bool { 1357 + self.error 1358 + .as_deref() 1359 + .map(|s| !s.is_empty()) 1360 + .unwrap_or(false) 1361 + } 1362 + } 1363 + 1364 + /// Outcome of the 401-envelope assertion. 1365 + pub enum RejectionShape { 1366 + /// 401 with a non-empty `error` field — full-conformant. 1367 + Conformant { 1368 + /// The envelope for diagnostic rendering. 1369 + envelope: XrpcErrorEnvelope, 1370 + }, 1371 + /// 401 but the envelope is missing or has an empty `error` field. 1372 + /// Treated as Pass on status alone per AC2.5 but the summary 1373 + /// notes the non-conformant response shape. 1374 + ConformantStatusNonConformantShape, 1375 + /// Any non-401 status. 1376 + WrongStatus { 1377 + /// The observed status code. 1378 + status: reqwest::StatusCode, 1379 + }, 1380 + } 1381 + 1382 + impl RejectionShape { 1383 + /// Classify a createReport response against the 401-envelope rubric. 1384 + pub fn classify(resp: &RawCreateReportResponse) -> Self { 1385 + if resp.status != reqwest::StatusCode::UNAUTHORIZED { 1386 + return Self::WrongStatus { 1387 + status: resp.status, 1388 + }; 1389 + } 1390 + match XrpcErrorEnvelope::parse(&resp.raw_body) { 1391 + Some(env) if env.has_nonempty_error() => Self::Conformant { envelope: env }, 1392 + _ => Self::ConformantStatusNonConformantShape, 1393 + } 1394 + } 1395 + } 1396 + 1397 + #[derive(Debug, Error, Diagnostic)] 1398 + pub enum CreateReportDiagnostic { 1399 + /// Diagnostic for the `contract_missing` spec violation (AC1.3). 1400 + /// 1401 + /// Emitted when `--commit-report` is set and the identity-stage 1402 + /// `labeler_policies` does not advertise a non-empty `reasonTypes` and 1403 + /// `subjectTypes`. The body of the labeler record is attached as source 1404 + /// so users can see what _was_ published. 1405 + #[error("Labeler does not advertise a reportable `LabelerPolicies` shape")] 1406 + #[diagnostic( 1407 + code = "labeler::report::contract_missing", 1408 + help = "`reasonTypes` and `subjectTypes` must both be present and non-empty on the labeler's published policies; the tool cannot verify reporting conformance without them." 1409 + )] 1410 + ContractMissing { 1411 + /// `reasonTypes` present and non-empty? 1412 + has_reason_types: bool, 1413 + /// `subjectTypes` present and non-empty? 1414 + has_subject_types: bool, 1415 + }, 1416 + 1417 + /// Diagnostic for AC2.2: labeler accepted an unauthenticated createReport POST. 1418 + #[error("Labeler accepted unauthenticated createReport (status {status})")] 1419 + #[diagnostic( 1420 + code = "labeler::report::unauthenticated_accepted", 1421 + help = "A labeler must reject createReport with 401 when no Authorization header is supplied." 1422 + )] 1423 + UnauthenticatedAccepted { 1424 + /// Observed status code, e.g., 200. 1425 + status: u16, 1426 + /// Response body for context. 1427 + #[source_code] 1428 + source_code: NamedSource<Arc<[u8]>>, 1429 + /// Span covering the response body so miette renders `source_code`. 1430 + #[label("accepted here")] 1431 + span: SourceSpan, 1432 + }, 1433 + 1434 + /// Diagnostic for AC2.4: labeler accepted a malformed bearer token. 1435 + #[error("Labeler accepted malformed Bearer token (status {status})")] 1436 + #[diagnostic( 1437 + code = "labeler::report::malformed_bearer_accepted", 1438 + help = "A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string." 1439 + )] 1440 + MalformedBearerAccepted { 1441 + /// Observed status code, e.g., 200. 1442 + status: u16, 1443 + /// Response body for context. 1444 + #[source_code] 1445 + source_code: NamedSource<Arc<[u8]>>, 1446 + /// Span covering the response body so miette renders `source_code`. 1447 + #[label("accepted here")] 1448 + span: SourceSpan, 1449 + }, 1450 + 1451 + /// Diagnostic for AC3.2: labeler accepted JWT with wrong `aud` claim. 1452 + #[error("Labeler accepted JWT with wrong `aud` (status {status})")] 1453 + #[diagnostic( 1454 + code = "labeler::report::wrong_aud_accepted", 1455 + help = "A labeler must reject JWTs whose `aud` claim does not match its own DID." 1456 + )] 1457 + WrongAudAccepted { 1458 + /// Observed status code, e.g., 200. 1459 + status: u16, 1460 + /// Response body for context. 1461 + #[source_code] 1462 + source_code: NamedSource<Arc<[u8]>>, 1463 + /// Span covering the response body so miette renders `source_code`. 1464 + #[label("accepted here")] 1465 + span: SourceSpan, 1466 + }, 1467 + 1468 + /// Diagnostic for AC3.3: labeler accepted JWT with wrong `lxm` claim. 1469 + #[error("Labeler accepted JWT with wrong `lxm` (status {status})")] 1470 + #[diagnostic( 1471 + code = "labeler::report::wrong_lxm_accepted", 1472 + help = "A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method." 1473 + )] 1474 + WrongLxmAccepted { 1475 + /// Observed status code, e.g., 200. 1476 + status: u16, 1477 + /// Response body for context. 1478 + #[source_code] 1479 + source_code: NamedSource<Arc<[u8]>>, 1480 + /// Span covering the response body so miette renders `source_code`. 1481 + #[label("accepted here")] 1482 + span: SourceSpan, 1483 + }, 1484 + 1485 + /// Diagnostic for AC3.4: labeler accepted expired JWT. 1486 + #[error("Labeler accepted expired JWT (status {status})")] 1487 + #[diagnostic( 1488 + code = "labeler::report::expired_accepted", 1489 + help = "A labeler must reject JWTs whose `exp` claim is in the past." 1490 + )] 1491 + ExpiredAccepted { 1492 + /// Observed status code, e.g., 200. 1493 + status: u16, 1494 + /// Response body for context. 1495 + #[source_code] 1496 + source_code: NamedSource<Arc<[u8]>>, 1497 + /// Span covering the response body so miette renders `source_code`. 1498 + #[label("accepted here")] 1499 + span: SourceSpan, 1500 + }, 1501 + 1502 + /// Diagnostic for AC3.6: labeler rejected invalid shape with wrong status. 1503 + #[error( 1504 + "Unadvertised `reasonType` was rejected with status {status}, expected 400 InvalidRequest" 1505 + )] 1506 + #[diagnostic( 1507 + code = "labeler::report::shape_not_400", 1508 + help = "A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes." 1509 + )] 1510 + ShapeNot400 { 1511 + /// Observed status code. 1512 + status: u16, 1513 + /// Error name from the response envelope, if present. 1514 + error_name: Option<String>, 1515 + /// Response body for context. 1516 + #[source_code] 1517 + source_code: NamedSource<Arc<[u8]>>, 1518 + /// Span covering the response body so miette renders `source_code`. 1519 + #[label("rejected with wrong status here")] 1520 + span: SourceSpan, 1521 + }, 1522 + 1523 + /// Diagnostic for AC4.3: self-mint report rejected by the labeler. 1524 + #[error("Self-mint report rejected (status {status})")] 1525 + #[diagnostic( 1526 + code = "labeler::report::self_mint_rejected", 1527 + help = "A labeler that advertises reportable shape should accept a well-formed, authenticated createReport. Check the labeler's service-auth validation and its acceptance of the advertised reasonType/subject shape." 1528 + )] 1529 + SelfMintRejected { 1530 + /// Observed HTTP status code. 1531 + status: u16, 1532 + /// Response body for context. 1533 + #[source_code] 1534 + source_code: NamedSource<Arc<[u8]>>, 1535 + /// Span covering the response body so miette renders `source_code`. 1536 + #[label("rejected here")] 1537 + span: SourceSpan, 1538 + }, 1539 + 1540 + /// Diagnostic for AC5.2 / AC5.3: the PDS-mediated service-auth flow 1541 + /// produced a non-2xx response. `origin` identifies whether the 1542 + /// rejection came from the labeler (AC5.2 spec violation) or the 1543 + /// user's PDS during `getServiceAuth` (AC5.3 network error). 1544 + #[error("{origin} rejected PDS-minted service-auth createReport (status {status})")] 1545 + #[diagnostic( 1546 + code = "labeler::report::pds_service_auth_rejected", 1547 + help = "When `origin` is `Labeler`, the PDS issued a service-auth JWT bound to the labeler's DID and the createReport NSID; the labeler should have accepted it. When `origin` is `PDS`, the user's PDS refused to mint the service-auth JWT — verify the handle and app password, and confirm `--handle` resolves to a PDS that can mint service-auth tokens for this user." 1548 + )] 1549 + PdsServiceAuthRejected { 1550 + /// Which party produced the non-2xx response. 1551 + origin: ResponseOrigin, 1552 + /// Observed HTTP status code. 1553 + status: u16, 1554 + /// Response body for context. 1555 + #[source_code] 1556 + source_code: NamedSource<Arc<[u8]>>, 1557 + /// Span covering the response body so miette renders `source_code`. 1558 + #[label("rejected here")] 1559 + span: SourceSpan, 1560 + }, 1561 + 1562 + /// Diagnostic for AC6.2 / AC6.3: the PDS-proxied `createReport` flow 1563 + /// produced a non-2xx response. `origin` identifies whether the 1564 + /// rejection came from the labeler via upstream envelope (AC6.2 spec 1565 + /// violation) or the user's PDS refusing the proxy attempt before 1566 + /// forwarding (AC6.3 network error). 1567 + #[error("{origin} rejected PDS-proxied createReport (status {status})")] 1568 + #[diagnostic( 1569 + code = "labeler::report::pds_proxied_rejected", 1570 + help = "When `origin` is `Labeler`, the PDS forwarded the createReport call on the user's behalf; the downstream labeler reached it but rejected the submission. When `origin` is `PDS`, the user's PDS rejected the proxied call before it could reach the labeler — verify the handle, app password, and that the PDS is configured to proxy moderation calls to the target labeler." 1571 + )] 1572 + PdsProxiedRejected { 1573 + /// Which party produced the non-2xx response. 1574 + origin: ResponseOrigin, 1575 + /// Observed HTTP status code. 1576 + status: u16, 1577 + /// Response body for context. 1578 + #[source_code] 1579 + source_code: NamedSource<Arc<[u8]>>, 1580 + /// Span covering the response body so miette renders `source_code`. 1581 + #[label("rejected here")] 1582 + span: SourceSpan, 1583 + }, 1584 + } 1585 + 1586 + /// Identifies which party in the PDS-mediated flow produced a non-2xx 1587 + /// response. Used to discriminate labeler-side spec violations from 1588 + /// PDS-side network errors within a single diagnostic variant, keeping 1589 + /// the one-diagnostic-per-check shape the report stage documents. 1590 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 1591 + pub enum ResponseOrigin { 1592 + /// The labeler itself produced the response (either directly, or via 1593 + /// the PDS surfacing an upstream-labeler error envelope). 1594 + Labeler, 1595 + /// The user's PDS produced the response without the labeler being 1596 + /// reached (e.g., `getServiceAuth` refused, or proxy rejected before 1597 + /// forwarding). 1598 + Pds, 1599 + } 1600 + 1601 + impl std::fmt::Display for ResponseOrigin { 1602 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1603 + match self { 1604 + ResponseOrigin::Labeler => f.write_str("Labeler"), 1605 + ResponseOrigin::Pds => f.write_str("PDS"), 1606 + } 1607 + } 1608 + } 1609 + 1610 + /// Construct a `NamedSource` and a span covering the whole body. 1611 + /// 1612 + /// The span must be non-empty (and present on a `#[label]` field) for miette's 1613 + /// `GraphicalReportHandler` to actually render the `source_code` block; a 1614 + /// `None` span causes the source to be silently dropped from the rendered 1615 + /// diagnostic. We therefore return the span alongside the source and expect 1616 + /// every `accepted_*` / `rejected_*` diagnostic to wire both through. 1617 + pub(crate) fn body_as_named_source( 1618 + resp: &RawCreateReportResponse, 1619 + ) -> (NamedSource<Arc<[u8]>>, SourceSpan) { 1620 + let pretty = pretty_json_for_display(&resp.raw_body); 1621 + let span = SourceSpan::new(0.into(), pretty.len()); 1622 + (NamedSource::new(resp.source_url.clone(), pretty), span) 1623 + } 1624 + 1625 + /// Construct a `NamedSource` and whole-body span from the PDS. Used for 1626 + /// PDS-mediated mode diagnostics where the response comes from the PDS not 1627 + /// the labeler. 1628 + pub(crate) fn body_as_named_source_from_pds( 1629 + resp: &RawPdsXrpcResponse, 1630 + ) -> (NamedSource<Arc<[u8]>>, SourceSpan) { 1631 + let pretty = pretty_json_for_display(&resp.raw_body); 1632 + let span = SourceSpan::new(0.into(), pretty.len()); 1633 + (NamedSource::new(resp.source_url.clone(), pretty), span) 1634 + } 1635 + 1636 + /// Build a minimal, plausible createReport body for negative tests. 1637 + /// 1638 + /// Chooses the lex-first advertised `reasonType` and the first advertised 1639 + /// `subjectType`, pointing at a safe subject (the labeler's own DID — 1640 + /// labelers never take action on themselves). The body is well-formed so 1641 + /// any validation short-circuit returns auth-layer rejection rather than 1642 + /// shape-layer rejection. 1643 + pub(crate) fn build_minimal_report_body(facts: &IdentityFacts) -> serde_json::Value { 1644 + // Unwrap the contract — run() has already guaranteed it's present 1645 + // and non-empty before this function is reachable. 1646 + let reason_type = facts 1647 + .reason_types 1648 + .as_ref() 1649 + .and_then(|v| v.first()) 1650 + .cloned() 1651 + .unwrap_or_else(|| "com.atproto.moderation.defs#reasonOther".to_string()); 1652 + 1653 + let subject_types: &[String] = facts.subject_types.as_deref().unwrap_or(&[]); 1654 + let subject = if subject_types.iter().any(|t| t == "account") { 1655 + serde_json::json!({ 1656 + "$type": "com.atproto.admin.defs#repoRef", 1657 + "did": facts.did.0, 1658 + }) 1659 + } else if subject_types.iter().any(|t| t == "record") { 1660 + serde_json::json!({ 1661 + "$type": "com.atproto.repo.strongRef", 1662 + // Ghost AT-URI targeting the labeler's own DID. Negative-path 1663 + // only; positive paths use the real pollution-avoidance logic 1664 + // in `self_mint_accepted`. 1665 + "uri": format!("at://{}/app.bsky.feed.post/not-real", facts.did.0), 1666 + "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 1667 + }) 1668 + } else { 1669 + // Fallback: account shape against the labeler itself. 1670 + serde_json::json!({ 1671 + "$type": "com.atproto.admin.defs#repoRef", 1672 + "did": facts.did.0, 1673 + }) 1674 + }; 1675 + 1676 + serde_json::json!({ 1677 + "reasonType": reason_type, 1678 + "subject": subject, 1679 + }) 1680 + } 1681 + 1682 + #[cfg(test)] 1683 + mod envelope_tests { 1684 + use super::*; 1685 + 1686 + #[test] 1687 + fn parse_well_formed_envelope() { 1688 + let body = br#"{"error":"BadJwt","message":"invalid token"}"#; 1689 + let env = XrpcErrorEnvelope::parse(body).expect("parses"); 1690 + assert_eq!(env.error.as_deref(), Some("BadJwt")); 1691 + assert_eq!(env.message.as_deref(), Some("invalid token")); 1692 + assert!(env.has_nonempty_error()); 1693 + } 1694 + 1695 + #[test] 1696 + fn parse_empty_envelope() { 1697 + let body = br#"{}"#; 1698 + let env = XrpcErrorEnvelope::parse(body).expect("parses empty object"); 1699 + assert_eq!(env.error, None); 1700 + assert!(!env.has_nonempty_error()); 1701 + } 1702 + 1703 + #[test] 1704 + fn parse_non_json_returns_none() { 1705 + assert!(XrpcErrorEnvelope::parse(b"<html>").is_none()); 1706 + } 1707 + 1708 + #[test] 1709 + fn parse_empty_error_field_treated_as_missing() { 1710 + let body = br#"{"error":""}"#; 1711 + let env = XrpcErrorEnvelope::parse(body).unwrap(); 1712 + assert!(!env.has_nonempty_error()); 1713 + } 1714 + }
+168
src/commands/test/labeler/create_report/did_doc_server.rs
··· 1 + //! Ephemeral `did:web` document server for self-mint conformance checks. 2 + //! 3 + //! Binds `127.0.0.1:0`, accepts the first inbound TCP connection, and 4 + //! serves a single hand-crafted HTTP/1.1 response carrying the DID 5 + //! document JSON at `/.well-known/did.json`. Any other path returns 6 + //! 404. Other requests to `/.well-known/did.json` are honored too — 7 + //! the labeler may retry. 8 + //! 9 + //! Shuts down on drop: the RAII handle aborts the background task and 10 + //! closes the listener. 11 + 12 + use std::net::SocketAddr; 13 + use std::sync::Arc; 14 + 15 + use tokio::io::{AsyncReadExt, AsyncWriteExt}; 16 + use tokio::net::TcpListener; 17 + use tokio::task::JoinHandle; 18 + 19 + /// A running DID document server. 20 + /// 21 + /// The server runs on `127.0.0.1:{os-assigned-port}` in a background 22 + /// task; the listening address is exposed via `local_addr()`. When the 23 + /// `DidDocServer` is dropped, the background task is aborted. 24 + pub struct DidDocServer { 25 + local_addr: SocketAddr, 26 + task: JoinHandle<()>, 27 + } 28 + 29 + impl DidDocServer { 30 + /// Bind `127.0.0.1:0` and start serving DID-document JSON bytes at 31 + /// `/.well-known/did.json`. The body is built **after** the listener 32 + /// has bound, by invoking `build_body` with the known `SocketAddr`. 33 + /// This lets callers embed the bound port into the DID document 34 + /// atomically — there is no probe phase, no possibility of port drift 35 + /// between binding and serving. 36 + pub async fn spawn<F>(build_body: F) -> std::io::Result<Self> 37 + where 38 + F: FnOnce(SocketAddr) -> Vec<u8>, 39 + { 40 + let listener = TcpListener::bind("127.0.0.1:0").await?; 41 + let local_addr = listener.local_addr()?; 42 + let did_doc_json = build_body(local_addr); 43 + let body: Arc<[u8]> = did_doc_json.into(); 44 + 45 + let task = tokio::spawn(async move { 46 + loop { 47 + let accept = listener.accept().await; 48 + let (mut stream, _peer) = match accept { 49 + Ok(sp) => sp, 50 + Err(_) => return, 51 + }; 52 + let body = body.clone(); 53 + tokio::spawn(async move { 54 + let _ = Self::handle_connection(&mut stream, &body).await; 55 + }); 56 + } 57 + }); 58 + 59 + Ok(Self { local_addr, task }) 60 + } 61 + 62 + /// The listening address (always `127.0.0.1:{port}`). 63 + pub fn local_addr(&self) -> SocketAddr { 64 + self.local_addr 65 + } 66 + 67 + /// Minimal HTTP/1.1 handler: reads the request line, routes on path. 68 + async fn handle_connection( 69 + stream: &mut tokio::net::TcpStream, 70 + did_doc: &[u8], 71 + ) -> std::io::Result<()> { 72 + // Read up to 8 KiB of request headers; servers don't send large 73 + // GET requests but we cap to avoid unbounded reads. 74 + let mut buf = [0u8; 8192]; 75 + let mut total = 0usize; 76 + while total < buf.len() { 77 + let n = stream.read(&mut buf[total..]).await?; 78 + if n == 0 { 79 + break; 80 + } 81 + total += n; 82 + // Headers end at CRLFCRLF. 83 + if buf[..total].windows(4).any(|w| w == b"\r\n\r\n") { 84 + break; 85 + } 86 + } 87 + let request = &buf[..total]; 88 + 89 + // Parse just the request line: `GET /path HTTP/1.1\r\n`. 90 + let first_line_end = request 91 + .iter() 92 + .position(|&b| b == b'\r' || b == b'\n') 93 + .unwrap_or(request.len()); 94 + let first_line = std::str::from_utf8(&request[..first_line_end]).unwrap_or(""); 95 + let mut parts = first_line.split_whitespace(); 96 + let method = parts.next().unwrap_or(""); 97 + let path = parts.next().unwrap_or(""); 98 + 99 + let (status_line, body, content_type) = 100 + if method == "GET" && path == "/.well-known/did.json" { 101 + ("HTTP/1.1 200 OK", did_doc, "application/json") 102 + } else { 103 + ( 104 + "HTTP/1.1 404 Not Found", 105 + b"not found" as &[u8], 106 + "text/plain", 107 + ) 108 + }; 109 + 110 + let response_head = format!( 111 + "{status_line}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", 112 + body.len() 113 + ); 114 + stream.write_all(response_head.as_bytes()).await?; 115 + stream.write_all(body).await?; 116 + stream.flush().await?; 117 + let _ = stream.shutdown().await; 118 + Ok(()) 119 + } 120 + } 121 + 122 + impl Drop for DidDocServer { 123 + fn drop(&mut self) { 124 + self.task.abort(); 125 + } 126 + } 127 + 128 + #[cfg(test)] 129 + mod tests { 130 + use super::*; 131 + 132 + #[tokio::test] 133 + async fn serves_did_doc_on_well_known_path() { 134 + let body = br#"{"id":"did:web:127.0.0.1%3A0"}"#.to_vec(); 135 + let body_for_assert = body.clone(); 136 + let server = DidDocServer::spawn(move |_addr| body).await.expect("spawn"); 137 + let url = format!("http://{}/.well-known/did.json", server.local_addr()); 138 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 139 + assert_eq!(resp.status(), 200); 140 + assert_eq!(resp.headers()["content-type"], "application/json"); 141 + let got = resp.bytes().await.expect("bytes"); 142 + assert_eq!(got.as_ref(), body_for_assert.as_slice()); 143 + } 144 + 145 + #[tokio::test] 146 + async fn returns_404_for_other_paths() { 147 + let server = DidDocServer::spawn(|_addr| b"{}".to_vec()) 148 + .await 149 + .expect("spawn"); 150 + let url = format!("http://{}/nope", server.local_addr()); 151 + let resp = reqwest::Client::new().get(&url).send().await.expect("http"); 152 + assert_eq!(resp.status(), 404); 153 + } 154 + 155 + #[tokio::test] 156 + async fn body_builder_receives_bound_addr() { 157 + let captured = std::sync::Arc::new(std::sync::Mutex::new(None)); 158 + let captured_clone = captured.clone(); 159 + let server = DidDocServer::spawn(move |addr| { 160 + *captured_clone.lock().unwrap() = Some(addr); 161 + format!(r#"{{"port":{}}}"#, addr.port()).into_bytes() 162 + }) 163 + .await 164 + .expect("spawn"); 165 + let addr = *captured.lock().unwrap(); 166 + assert_eq!(addr, Some(server.local_addr())); 167 + } 168 + }
+173
src/commands/test/labeler/create_report/pollution.rs
··· 1 + //! Pollution-avoidance helpers for committing report checks. 2 + //! 3 + //! When the tool actually POSTs a report (positive path only), we have 4 + //! two obligations: 5 + //! 6 + //! 1. Avoid contaminating real moderation queues on public labelers. 7 + //! 2. Be easy to identify and dismiss for operators who see the report. 8 + //! 9 + //! Obligation 1 is satisfied by preferring: 10 + //! - `reasonOther` reasonType when advertised (signals "this is a test 11 + //! or edge case, review leisurely"), falling back to the lex-first 12 + //! advertised value. 13 + //! - `record` subject type with a hardcoded AT-URI pointing at an 14 + //! explanation post the tool author publishes (release-gate item), 15 + //! falling back to `account` subject pointing at the *reporter's* 16 + //! own DID (the self-mint DID, which is always safe). 17 + //! 18 + //! Obligation 2 is satisfied by the sentinel `reason` string from the 19 + //! `sentinel` module — it's stable and greppable. 20 + //! 21 + //! On *local* labelers the safety constraint relaxes: the queue is the 22 + //! developer's own, so we use lex-first `reasonType` + `account` subject 23 + //! (pointing at the reporter's own DID) to exercise the simplest working 24 + //! shape. This makes the test deterministic for round-trip debugging. 25 + 26 + use serde_json::{Value, json}; 27 + 28 + use crate::common::identity::Did; 29 + 30 + /// The record-subject AT-URI used in non-local pollution-safe POSTs. 31 + pub const CONFORMANCE_REPORT_SUBJECT_URI: &str = 32 + "at://did:plc:bvdrfwiamgi5leqs63q2duro/app.bsky.feed.post/3mjxxrzqtwc2d"; 33 + 34 + /// The record-subject CID for `CONFORMANCE_REPORT_SUBJECT_URI`. 35 + pub const CONFORMANCE_REPORT_SUBJECT_CID: &str = 36 + "bafyreigvtlsnrkzac53uluemkc7p7345a2yqa2ct5lg6vglmvfakq4kkxq"; 37 + 38 + /// Choose the `reasonType` for a committing POST. 39 + /// 40 + /// Returns the full NSID string (e.g., 41 + /// `"com.atproto.moderation.defs#reasonOther"`). Preconditions: 42 + /// `advertised` is non-empty (the stage's contract-published gate 43 + /// guarantees this). 44 + pub fn choose_reason_type(advertised: &[String], is_local: bool) -> String { 45 + let prefer_other = "com.atproto.moderation.defs#reasonOther"; 46 + if !is_local && advertised.iter().any(|r| r == prefer_other) { 47 + return prefer_other.to_string(); 48 + } 49 + advertised 50 + .first() 51 + .cloned() 52 + .unwrap_or_else(|| prefer_other.to_string()) 53 + } 54 + 55 + /// Choose the `subject` JSON for a committing POST. 56 + /// 57 + /// - `advertised_types`: non-empty (contract-published guarantees). 58 + /// - `reporter_did`: the self-mint DID (for the "own reporter" fallback). 59 + /// - `override_did`: if `Some`, always use an `account` subject with this 60 + /// DID (for `--report-subject-did`). 61 + /// - `is_local`: local labelers use the simplest shape for debugging. 62 + pub fn choose_subject( 63 + advertised_types: &[String], 64 + reporter_did: &Did, 65 + override_did: Option<&Did>, 66 + is_local: bool, 67 + ) -> Value { 68 + if let Some(did) = override_did { 69 + return json!({ 70 + "$type": "com.atproto.admin.defs#repoRef", 71 + "did": did.0, 72 + }); 73 + } 74 + if !is_local && advertised_types.iter().any(|s| s == "record") { 75 + return json!({ 76 + "$type": "com.atproto.repo.strongRef", 77 + "uri": CONFORMANCE_REPORT_SUBJECT_URI, 78 + "cid": CONFORMANCE_REPORT_SUBJECT_CID, 79 + }); 80 + } 81 + // Local, override absent, or `record` not advertised → account subject 82 + // pointing at the reporter's own DID (always safe). 83 + json!({ 84 + "$type": "com.atproto.admin.defs#repoRef", 85 + "did": reporter_did.0, 86 + }) 87 + } 88 + 89 + #[cfg(test)] 90 + mod tests { 91 + use super::*; 92 + 93 + #[test] 94 + fn choose_reason_type_prefers_other_when_advertised_and_non_local() { 95 + let advertised = vec![ 96 + "com.atproto.moderation.defs#reasonSpam".to_string(), 97 + "com.atproto.moderation.defs#reasonOther".to_string(), 98 + ]; 99 + assert_eq!( 100 + choose_reason_type(&advertised, false), 101 + "com.atproto.moderation.defs#reasonOther" 102 + ); 103 + } 104 + 105 + #[test] 106 + fn choose_reason_type_uses_lex_first_when_local() { 107 + let advertised = vec![ 108 + "com.atproto.moderation.defs#reasonSpam".to_string(), 109 + "com.atproto.moderation.defs#reasonOther".to_string(), 110 + ]; 111 + assert_eq!( 112 + choose_reason_type(&advertised, true), 113 + "com.atproto.moderation.defs#reasonSpam" 114 + ); 115 + } 116 + 117 + #[test] 118 + fn choose_reason_type_falls_back_to_first_when_other_absent() { 119 + let advertised = vec!["com.atproto.moderation.defs#reasonSpam".to_string()]; 120 + assert_eq!( 121 + choose_reason_type(&advertised, false), 122 + "com.atproto.moderation.defs#reasonSpam" 123 + ); 124 + } 125 + 126 + #[test] 127 + fn choose_subject_local_returns_account_on_reporter() { 128 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 129 + let subj = choose_subject( 130 + &["account".to_string(), "record".to_string()], 131 + &reporter, 132 + None, 133 + true, 134 + ); 135 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 136 + assert_eq!(subj["did"], reporter.0); 137 + } 138 + 139 + #[test] 140 + fn choose_subject_non_local_prefers_record_when_advertised() { 141 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 142 + let subj = choose_subject( 143 + &["account".to_string(), "record".to_string()], 144 + &reporter, 145 + None, 146 + false, 147 + ); 148 + assert_eq!(subj["$type"], "com.atproto.repo.strongRef"); 149 + assert_eq!(subj["uri"], CONFORMANCE_REPORT_SUBJECT_URI); 150 + } 151 + 152 + #[test] 153 + fn choose_subject_non_local_falls_back_to_account_when_record_absent() { 154 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 155 + let subj = choose_subject(&["account".to_string()], &reporter, None, false); 156 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 157 + assert_eq!(subj["did"], reporter.0); 158 + } 159 + 160 + #[test] 161 + fn choose_subject_override_always_wins() { 162 + let reporter = Did("did:web:127.0.0.1%3A5000".to_string()); 163 + let override_did = Did("did:plc:target".to_string()); 164 + let subj = choose_subject( 165 + &["record".to_string()], 166 + &reporter, 167 + Some(&override_did), 168 + false, // non-local; without override this would be record/strongRef 169 + ); 170 + assert_eq!(subj["$type"], "com.atproto.admin.defs#repoRef"); 171 + assert_eq!(subj["did"], "did:plc:target"); 172 + } 173 + }
+261
src/commands/test/labeler/create_report/self_mint.rs
··· 1 + //! `SelfMintSigner`: owns a random key, an ephemeral `did:web` identity 2 + //! server, and a reference curve. Exposes a single method for signing 3 + //! atproto service-auth JWTs with that identity. 4 + 5 + use std::net::SocketAddr; 6 + use std::time::Duration; 7 + 8 + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 9 + use serde_json::json; 10 + use url::Url; 11 + 12 + // Reach `rand_core` through `k256`'s re-export so we pin the same version 13 + // that `k256::ecdsa::SigningKey::random` expects. 14 + use k256::elliptic_curve::rand_core; 15 + 16 + use crate::commands::test::labeler::create_report::did_doc_server::DidDocServer; 17 + use crate::common::identity::{AnySigningKey, Did, encode_multikey}; 18 + use crate::common::jwt::{self, JwtClaims, JwtHeader}; 19 + 20 + // Local RNG shim: `k256::ecdsa::SigningKey::random` and 21 + // `p256::ecdsa::SigningKey::random` take `CryptoRngCore`. We build a 22 + // thin adapter around `getrandom` (carried as a direct dependency) 23 + // since `elliptic_curve::rand_core::OsRng` is not re-exported through 24 + // the current dep graph. 25 + struct GetrandomRng; 26 + impl rand_core::RngCore for GetrandomRng { 27 + fn next_u32(&mut self) -> u32 { 28 + let mut b = [0u8; 4]; 29 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 30 + u32::from_le_bytes(b) 31 + } 32 + fn next_u64(&mut self) -> u64 { 33 + let mut b = [0u8; 8]; 34 + getrandom::getrandom(&mut b).expect("OS CSPRNG"); 35 + u64::from_le_bytes(b) 36 + } 37 + fn fill_bytes(&mut self, dest: &mut [u8]) { 38 + getrandom::getrandom(dest).expect("OS CSPRNG"); 39 + } 40 + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { 41 + getrandom::getrandom(dest).map_err(|_| rand_core::Error::new("getrandom failed")) 42 + } 43 + } 44 + impl rand_core::CryptoRng for GetrandomRng {} 45 + 46 + /// Curve selector for self-mint keys, mirrors clap's `--self-mint-curve` flag. 47 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] 48 + pub enum SelfMintCurve { 49 + /// secp256k1 (JWT `alg = "ES256K"`). Default. 50 + #[default] 51 + Es256k, 52 + /// NIST P-256 (JWT `alg = "ES256"`). 53 + Es256, 54 + } 55 + 56 + /// A self-mint JWT signer. Owns the keypair, the DID, and the backing DID 57 + /// document server (which is shut down on drop). 58 + pub struct SelfMintSigner { 59 + signing_key: AnySigningKey, 60 + issuer_did: Did, 61 + /// Held for its Drop side effect (the server stays up while this 62 + /// field is alive). Also read by `did_doc_url()` to expose the 63 + /// listening port. 64 + did_doc_server: DidDocServer, 65 + } 66 + 67 + impl SelfMintSigner { 68 + /// Create a new self-mint signer with a freshly-generated key of the 69 + /// requested curve. Binds `127.0.0.1:0` for the DID document server. 70 + /// 71 + /// Port-stable by construction: the server binds first, then the 72 + /// body-builder closure embeds the bound port into the DID document 73 + /// before the first request is served. There is no probe phase and 74 + /// no window in which the port can drift. 75 + pub async fn spawn(curve: SelfMintCurve) -> std::io::Result<Self> { 76 + let signing_key = match curve { 77 + SelfMintCurve::Es256k => { 78 + AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut GetrandomRng)) 79 + } 80 + SelfMintCurve::Es256 => { 81 + AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut GetrandomRng)) 82 + } 83 + }; 84 + let verifying_key = signing_key.verifying_key(); 85 + let multikey = encode_multikey(&verifying_key); 86 + 87 + // Capture the DID that the builder computes so we can store it. 88 + // `Arc<Mutex<Option<Did>>>` lets the one-shot closure publish the 89 + // DID out through the body-builder boundary. 90 + let issuer_capture: std::sync::Arc<std::sync::Mutex<Option<Did>>> = 91 + std::sync::Arc::new(std::sync::Mutex::new(None)); 92 + let issuer_capture_clone = issuer_capture.clone(); 93 + let multikey_for_builder = multikey.clone(); 94 + 95 + let server = DidDocServer::spawn(move |addr| { 96 + let did = did_for(addr); 97 + let did_doc = json!({ 98 + "@context": ["https://www.w3.org/ns/did/v1"], 99 + "id": did.0, 100 + "alsoKnownAs": [], 101 + "verificationMethod": [{ 102 + "id": format!("{}#atproto", did.0), 103 + "type": "Multikey", 104 + "controller": did.0, 105 + "publicKeyMultibase": multikey_for_builder, 106 + }], 107 + "service": [], 108 + }); 109 + let bytes = serde_json::to_vec(&did_doc).expect("static JSON serializes"); 110 + *issuer_capture_clone.lock().unwrap() = Some(did); 111 + bytes 112 + }) 113 + .await?; 114 + 115 + let issuer_did = issuer_capture 116 + .lock() 117 + .unwrap() 118 + .take() 119 + .expect("body-builder runs synchronously before spawn returns"); 120 + 121 + Ok(Self { 122 + signing_key, 123 + issuer_did, 124 + did_doc_server: server, 125 + }) 126 + } 127 + 128 + /// The issuer DID bound to this signer (`did:web:127.0.0.1%3A{port}`). 129 + pub fn issuer_did(&self) -> &Did { 130 + &self.issuer_did 131 + } 132 + 133 + /// URL the labeler will fetch to resolve the DID document. 134 + pub fn did_doc_url(&self) -> url::Url { 135 + let mut u = base_url(self.did_doc_server.local_addr()); 136 + u.set_path("/.well-known/did.json"); 137 + u 138 + } 139 + 140 + /// Sign a JWT with these claims. The `iss` field in `claims` is 141 + /// overridden with this signer's DID so callers never forget. 142 + pub fn sign_jwt(&self, mut claims: JwtClaims) -> String { 143 + claims.iss = self.issuer_did.0.clone(); 144 + let header = JwtHeader::for_signing_key(&self.signing_key); 145 + jwt::encode_compact(&header, &claims, &self.signing_key) 146 + .expect("encode_compact is infallible for well-formed structs") 147 + } 148 + 149 + /// Build a valid-claims template for the given labeler DID and the 150 + /// createReport NSID. Callers mutate specific fields for negative 151 + /// tests. `now_unix_secs` is the current wall-clock time in UNIX 152 + /// seconds; `exp_after` is the lifetime. 153 + pub fn valid_claims_template( 154 + &self, 155 + labeler_did: &Did, 156 + lxm: &str, 157 + now_unix_secs: i64, 158 + exp_after: Duration, 159 + ) -> JwtClaims { 160 + JwtClaims { 161 + iss: self.issuer_did.0.clone(), 162 + aud: labeler_did.0.clone(), 163 + exp: now_unix_secs + exp_after.as_secs() as i64, 164 + iat: now_unix_secs, 165 + lxm: lxm.to_string(), 166 + jti: crate::commands::test::labeler::create_report::sentinel::new_run_id(), 167 + } 168 + } 169 + } 170 + 171 + /// Construct `did:web:127.0.0.1%3A{port}` for a self-mint identity bound 172 + /// to the given local `SocketAddr`. The `:` between the IP and the port is 173 + /// percent-encoded per atproto did:web rules. 174 + /// 175 + /// Uses the `SocketAddr` IP literally (typically `127.0.0.1`). IPv6 176 + /// loopback would produce `did:web:::1%3A{port}` which the atproto did 177 + /// syntax regex rejects; for v1 the self-mint server is IPv4-only. 178 + pub(crate) fn did_for(addr: SocketAddr) -> Did { 179 + assert!(addr.is_ipv4(), "self-mint DidDocServer is IPv4-only"); 180 + let host = addr.ip().to_string(); 181 + let port = addr.port(); 182 + // Percent-encode the `:` (and, defensively, any other non-alphanumeric) 183 + // with the standard set. For the `127.0.0.1:{port}` case this yields 184 + // exactly `127.0.0.1%3A{port}`. 185 + let encoded_hostport = format!("{host}{}{port}", utf8_percent_encode(":", NON_ALPHANUMERIC)); 186 + Did(format!("did:web:{encoded_hostport}")) 187 + } 188 + 189 + /// Base URL the labeler uses to fetch the self-mint DID document: 190 + /// `http://127.0.0.1:{port}`. 191 + pub(crate) fn base_url(addr: SocketAddr) -> Url { 192 + Url::parse(&format!("http://{addr}")).expect("SocketAddr Display is always a valid authority") 193 + } 194 + 195 + #[cfg(test)] 196 + mod tests { 197 + use super::*; 198 + use crate::common::identity::{AnyVerifyingKey, parse_multikey}; 199 + use crate::common::jwt::verify_compact; 200 + 201 + #[test] 202 + fn self_mint_did_encodes_colon() { 203 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 204 + let did = did_for(addr); 205 + assert_eq!(did.0, "did:web:127.0.0.1%3A5000"); 206 + } 207 + 208 + #[test] 209 + fn self_mint_base_url_uses_http() { 210 + let addr: SocketAddr = "127.0.0.1:5000".parse().unwrap(); 211 + let url = base_url(addr); 212 + assert_eq!(url.as_str(), "http://127.0.0.1:5000/"); 213 + } 214 + 215 + async fn round_trip(curve: SelfMintCurve, expected_alg: &str) { 216 + let signer = SelfMintSigner::spawn(curve).await.expect("spawn"); 217 + 218 + // Fetch the DID document as the labeler would. 219 + let url = signer.did_doc_url(); 220 + let client = reqwest::Client::new(); 221 + let resp = client.get(url).send().await.expect("http"); 222 + assert_eq!(resp.status(), 200); 223 + let doc: serde_json::Value = resp.json().await.expect("json"); 224 + assert_eq!( 225 + doc["id"], 226 + serde_json::Value::String(signer.issuer_did().0.clone()) 227 + ); 228 + let vm = doc["verificationMethod"][0].clone(); 229 + let multikey = vm["publicKeyMultibase"] 230 + .as_str() 231 + .expect("multikey") 232 + .to_string(); 233 + 234 + // Decode the key and verify a signature from the signer. 235 + let parsed = parse_multikey(&multikey).expect("parse multikey"); 236 + let vkey: AnyVerifyingKey = parsed.verifying_key; 237 + 238 + let claims = signer.valid_claims_template( 239 + &Did("did:plc:aaa22222222222222222bbbbbb".to_string()), 240 + "com.atproto.moderation.createReport", 241 + 1_776_000_000, 242 + Duration::from_secs(60), 243 + ); 244 + let token = signer.sign_jwt(claims.clone()); 245 + let (header, decoded_claims) = verify_compact(&token, &vkey).expect("verify"); 246 + assert_eq!(header.alg, expected_alg); 247 + assert_eq!(decoded_claims.iss, signer.issuer_did().0); 248 + assert_eq!(decoded_claims.aud, claims.aud); 249 + assert_eq!(decoded_claims.lxm, "com.atproto.moderation.createReport"); 250 + } 251 + 252 + #[tokio::test] 253 + async fn self_mint_signer_es256k_round_trips() { 254 + round_trip(SelfMintCurve::Es256k, "ES256K").await; 255 + } 256 + 257 + #[tokio::test] 258 + async fn self_mint_signer_es256_round_trips() { 259 + round_trip(SelfMintCurve::Es256, "ES256").await; 260 + } 261 + }
+140
src/commands/test/labeler/create_report/sentinel.rs
··· 1 + //! Builder for the sentinel `reason` field used in conformance-test reports. 2 + //! 3 + //! Every committing check's report body carries a stable, recognizable 4 + //! string in its `reason` field so that labeler operators can identify and 5 + //! dismiss reports submitted by `atproto-devtool` without mistaking them 6 + //! for real user reports. 7 + //! 8 + //! Format: `atproto-devtool conformance test <RFC3339-UTC> <run-id>` 9 + //! 10 + //! Example: `atproto-devtool conformance test 2026-04-17T12:34:56Z 5f9c1a3b4d7e8a0f` 11 + //! 12 + //! The run-id is a 16-hex-char random nonce generated once per pipeline run 13 + //! (not per check); the same run-id is reused across all report submissions 14 + //! within a single `test labeler` invocation so operators can trace a group 15 + //! of test reports back to one run. 16 + 17 + use std::time::{SystemTime, UNIX_EPOCH}; 18 + 19 + /// Prefix used so operators can grep their moderation queue for 20 + /// conformance-test reports with a single query. 21 + pub const SENTINEL_PREFIX: &str = "atproto-devtool conformance test"; 22 + 23 + /// Build a sentinel reason string. `run_id` should be a stable 16-hex-char 24 + /// identifier for the current test invocation; `now` is the current wall-clock 25 + /// time, typically `SystemTime::now()`. 26 + pub fn build(run_id: &str, now: SystemTime) -> String { 27 + let rfc3339 = format_rfc3339_utc(now); 28 + format!("{SENTINEL_PREFIX} {rfc3339} {run_id}") 29 + } 30 + 31 + /// Hand-rolled RFC 3339 UTC formatter: `YYYY-MM-DDTHH:MM:SSZ`. 32 + /// 33 + /// Avoids a `chrono` / `time` dependency. Leap seconds are not handled; 34 + /// the sentinel reason is a human-readable label, not a parseable timestamp. 35 + /// For times before the UNIX epoch or more than `i64::MAX` seconds in the 36 + /// future we degrade gracefully to `1970-01-01T00:00:00Z`. 37 + pub fn format_rfc3339_utc(ts: SystemTime) -> String { 38 + let secs = ts 39 + .duration_since(UNIX_EPOCH) 40 + .map(|d| d.as_secs() as i64) 41 + .unwrap_or(0); 42 + let (year, month, day, hour, min, sec) = unix_to_civil(secs); 43 + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") 44 + } 45 + 46 + /// Convert UNIX seconds to a civil date-time (UTC) using Howard Hinnant's 47 + /// algorithm for the Gregorian calendar. Correct for all years in [1, 9999]. 48 + pub fn unix_to_civil(secs: i64) -> (i32, u32, u32, u32, u32, u32) { 49 + // Seconds-of-day. 50 + let days = secs.div_euclid(86_400); 51 + let sod = secs.rem_euclid(86_400); 52 + let hour = (sod / 3600) as u32; 53 + let min = ((sod % 3600) / 60) as u32; 54 + let sec = (sod % 60) as u32; 55 + 56 + // Days since 1970-01-01 -> civil date. Algorithm from 57 + // http://howardhinnant.github.io/date_algorithms.html#civil_from_days. 58 + let z = days + 719_468; 59 + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; 60 + let doe = (z - era * 146_097) as u32; // [0, 146096] 61 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] 62 + let y = yoe as i32 + era as i32 * 400; 63 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] 64 + let mp = (5 * doy + 2) / 153; // [0, 11] 65 + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] 66 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] 67 + let year = if m <= 2 { y + 1 } else { y }; 68 + (year, m, d, hour, min, sec) 69 + } 70 + 71 + /// Generate a random 16-hex-char run identifier. Uses `getrandom` 72 + /// (panics if the OS CSPRNG is unavailable, which cannot happen on supported 73 + /// platforms). 74 + pub fn new_run_id() -> String { 75 + let mut bytes = [0u8; 8]; 76 + getrandom::getrandom(&mut bytes).expect("OS CSPRNG is always available on supported platforms"); 77 + #[expect(clippy::format_collect)] 78 + { 79 + bytes.iter().map(|b| format!("{b:02x}")).collect() 80 + } 81 + } 82 + 83 + #[cfg(test)] 84 + mod tests { 85 + use super::*; 86 + 87 + #[test] 88 + fn format_rfc3339_utc_pins_known_points() { 89 + // Pre-epoch time degrades gracefully to 1970-01-01T00:00:00Z. 90 + let before_epoch = UNIX_EPOCH.checked_sub(std::time::Duration::from_secs(1)); 91 + if let Some(t) = before_epoch { 92 + assert_eq!(format_rfc3339_utc(t), "1970-01-01T00:00:00Z"); 93 + } 94 + 95 + // 1970-01-01T00:00:00Z (epoch). 96 + assert_eq!(format_rfc3339_utc(UNIX_EPOCH), "1970-01-01T00:00:00Z"); 97 + 98 + // 2024-02-29T00:00:00Z (leap year, leap day). 99 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_709_164_800); 100 + assert_eq!(format_rfc3339_utc(t), "2024-02-29T00:00:00Z"); 101 + 102 + // 2024-02-29T12:34:56Z (leap year, leap day with time). 103 + let t = UNIX_EPOCH + std::time::Duration::from_secs(1_709_210_096); 104 + assert_eq!(format_rfc3339_utc(t), "2024-02-29T12:34:56Z"); 105 + 106 + // 2000-02-29T00:00:00Z (leap year, leap day in year 2000). 107 + let t = UNIX_EPOCH + std::time::Duration::from_secs(951_782_400); 108 + assert_eq!(format_rfc3339_utc(t), "2000-02-29T00:00:00Z"); 109 + 110 + // 1972-02-29T00:00:00Z (leap year). 111 + let t = UNIX_EPOCH + std::time::Duration::from_secs(68_169_600); 112 + assert_eq!(format_rfc3339_utc(t), "1972-02-29T00:00:00Z"); 113 + 114 + // Year 9999-12-31T23:59:59Z (boundary). 115 + let t = UNIX_EPOCH + std::time::Duration::from_secs(253_402_300_799); 116 + assert_eq!(format_rfc3339_utc(t), "9999-12-31T23:59:59Z"); 117 + } 118 + 119 + #[test] 120 + fn build_contains_prefix_and_run_id() { 121 + let s = build("abcdef1234567890", UNIX_EPOCH); 122 + assert!(s.starts_with(SENTINEL_PREFIX)); 123 + assert!(s.ends_with("abcdef1234567890")); 124 + assert!(s.contains("1970-01-01T00:00:00Z")); 125 + } 126 + 127 + #[test] 128 + fn new_run_id_is_16_hex_chars() { 129 + let id = new_run_id(); 130 + assert_eq!(id.len(), 16); 131 + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); 132 + } 133 + 134 + #[test] 135 + fn new_run_id_is_unique_between_calls() { 136 + let a = new_run_id(); 137 + let b = new_run_id(); 138 + assert_ne!(a, b); 139 + } 140 + }
+189 -12
src/commands/test/labeler/crypto.rs
··· 13 13 use sha2::{Digest, Sha256}; 14 14 use thiserror::Error; 15 15 16 + use crate::common::identity::is_local_labeler_hostname; 16 17 use crate::common::report::{CheckResult, CheckStatus, Stage}; 17 18 18 19 /// Checks emitted by the crypto stage. ··· 447 448 /// 448 449 /// Logic: 449 450 /// 1. Empty labels → emit Skipped. 450 - /// 2. For each label: canonicalize → on error emit per-label SpecViolation. 451 + /// 2. For each label: canonicalize → on error buffer a per-label SpecViolation. 451 452 /// 3. Verify against `identity.signing_key` → on success increment `verified_with_current`. 452 453 /// 4. On all-pass: emit `crypto::rollup` Pass. 453 - /// 5. Else if `did:plc`: fetch PLC audit log and retry against historic keys. 454 - /// 6. Else (`did:web`): emit `crypto::rollup` SpecViolation with no rotation history. 454 + /// 5. Otherwise, if the labeler endpoint is local (loopback, RFC 1918, .local): 455 + /// emit `crypto::rollup` Skipped and drop the buffered per-label violations. 456 + /// Rationale: a developer testing a local copy of a labeler is unlikely to 457 + /// have the production signing key present, so label-signature failures are 458 + /// expected and not a conformance issue for this run. 459 + /// 6. Else if `did:plc`: fetch PLC audit log and retry against historic keys. 460 + /// 7. Else (`did:web`): emit `crypto::rollup` SpecViolation with no rotation history. 455 461 pub async fn run( 456 462 identity: &crate::commands::test::labeler::identity::IdentityFacts, 457 463 labels: &[Label], ··· 466 472 } 467 473 468 474 let mut results = Vec::new(); 475 + let mut per_label_violations = Vec::new(); 469 476 let mut verified_with_current = 0usize; 470 477 let mut failed_against_current: Vec<FailedLabel> = Vec::new(); 471 478 472 - // Verify all labels against the current key. 479 + // Verify all labels against the current key. Per-label SpecViolations are 480 + // buffered so we can drop them cleanly when a local labeler triggers the 481 + // "production key not present" skip path. 473 482 for label in labels { 474 483 match canonicalize_label_for_signing(label) { 475 484 Err(err) => { 476 - // Canonicalization failed — emit a per-label SpecViolation. 477 485 let diagnostic = CryptoCheckError::LabelCanonicalizationFailed { 478 486 label_uri: label.uri.clone(), 479 487 source: err.clone(), 480 488 }; 481 - results.push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic))); 489 + per_label_violations 490 + .push(Check::CanonicalizationFailed.spec_violation(Box::new(diagnostic))); 482 491 failed_against_current.push(FailedLabel { 483 492 label: label.clone(), 484 493 canonicalization_error: Some(err), 485 494 }); 486 495 } 487 496 Ok(canonical) => { 488 - // Try to parse the signature. 489 497 match parse_signature(&canonical.signature_bytes, &identity.signing_key) { 490 498 Err(_) => { 491 - // Signature parsing failed. 492 499 let diagnostic = CryptoCheckError::SignatureBytesUnparseable { 493 500 label_uri: label.uri.clone(), 494 501 curve: identity.signing_key.curve_name(), 495 502 }; 496 - results.push( 503 + per_label_violations.push( 497 504 Check::SignatureBytesUnparseable.spec_violation(Box::new(diagnostic)), 498 505 ); 499 506 failed_against_current.push(FailedLabel { ··· 502 509 }); 503 510 } 504 511 Ok(signature) => { 505 - // Try to verify against the current key. 506 512 match identity 507 513 .signing_key 508 514 .verify_prehash(&canonical.prehash, &signature) ··· 548 554 }; 549 555 } 550 556 551 - // Some labels failed current-key verification; check DID type for history fallback. 557 + // Some labels failed to verify. If the labeler endpoint is local, assume 558 + // the developer is testing with a signing key different from the one 559 + // published in the DID document, and skip the rest of the stage rather 560 + // than flagging the mismatch as a spec violation. 561 + if is_local_labeler_hostname(&identity.labeler_endpoint) { 562 + results.push(Check::Rollup.skip( 563 + "local labeler signing key does not match the published DID document \ 564 + (production signing key not available in this test environment)", 565 + )); 566 + return CryptoStageOutput { 567 + facts: None, 568 + results, 569 + }; 570 + } 571 + 572 + // Non-local: commit the buffered per-label violations before falling 573 + // through to the PLC-history / did:web branches below. 574 + results.extend(per_label_violations); 575 + 576 + // Check DID type for history fallback. 552 577 match identity.did.method() { 553 578 crate::common::identity::DidMethod::Plc => { 554 579 tracing::debug!( ··· 764 789 #[cfg(test)] 765 790 mod tests { 766 791 use super::*; 767 - use crate::common::identity::{AnySignature, AnyVerifyingKey}; 792 + use crate::commands::test::labeler::identity::IdentityFacts; 793 + use crate::common::identity::{ 794 + AnySignature, AnyVerifyingKey, Did, DidDocument, IdentityError, RawDidDocument, 795 + encode_multikey, 796 + }; 797 + use atrium_api::app::bsky::labeler::defs::LabelerPolicies; 768 798 use atrium_api::com::atproto::label::defs::{Label, LabelData}; 769 799 use atrium_api::types::string::Datetime; 770 800 use k256::ecdsa::SigningKey as K256SigningKey; 771 801 use k256::ecdsa::signature::hazmat::PrehashSigner; 802 + use std::sync::Arc; 803 + use url::Url; 804 + 805 + /// HttpClient stub that panics if called. Used by the local-skip crypto 806 + /// tests, which are expected to short-circuit before making any network 807 + /// request for PLC history. 808 + struct PanicHttpClient; 809 + 810 + #[async_trait::async_trait] 811 + impl crate::common::identity::HttpClient for PanicHttpClient { 812 + async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> { 813 + panic!("PanicHttpClient reached for {url}; crypto stage should have short-circuited"); 814 + } 815 + } 816 + 817 + /// Minimal `IdentityFacts` fixture builder for crypto-stage tests. The 818 + /// `labeler_endpoint` argument controls the locality heuristic. 819 + fn make_crypto_facts(signing_key: AnyVerifyingKey, labeler_endpoint: Url) -> IdentityFacts { 820 + let did = Did("did:web:localhost%3A8080".to_string()); 821 + let multikey = encode_multikey(&signing_key); 822 + let doc_json = format!( 823 + r##"{{"id":"{did}","verificationMethod":[{{"id":"{did}#atproto_label","type":"Multikey","controller":"{did}","publicKeyMultibase":"{multikey}"}}],"service":[{{"id":"#atproto_labeler","type":"AtprotoLabeler","serviceEndpoint":"{labeler_endpoint}"}},{{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}}]}}"##, 824 + did = did.0, 825 + ); 826 + let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses"); 827 + let raw_did_doc = RawDidDocument { 828 + parsed: doc, 829 + source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()), 830 + source_name: "test DID document".to_string(), 831 + }; 832 + let labeler_policies: LabelerPolicies = serde_json::from_value(serde_json::json!({ 833 + "labelValues": [], 834 + })) 835 + .expect("LabelerPolicies deserializes"); 836 + IdentityFacts { 837 + did, 838 + raw_did_doc, 839 + labeler_endpoint, 840 + pds_endpoint: Url::parse("https://pds.example.com").unwrap(), 841 + signing_key_id: "did:web:localhost%3A8080#atproto_label".to_string(), 842 + signing_key_multikey: multikey, 843 + signing_key, 844 + labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]), 845 + labeler_policies, 846 + reason_types: None, 847 + subject_types: None, 848 + subject_collections: None, 849 + } 850 + } 851 + 852 + /// Build a syntactically valid `Label` signed with `signing_key`. The 853 + /// signature is over the DRISL-CBOR prehash, matching what a conformant 854 + /// labeler produces. 855 + fn sign_label_with(signing_key: &K256SigningKey) -> Label { 856 + let placeholder: Label = LabelData { 857 + cid: None, 858 + cts: Datetime::new("2026-01-01T00:00:00.000Z".parse().expect("valid datetime")), 859 + exp: None, 860 + neg: Some(false), 861 + sig: Some(vec![0u8; 64]), 862 + src: "did:plc:test123456789abcdefghijklmnop" 863 + .parse() 864 + .expect("valid did"), 865 + uri: "at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1".to_string(), 866 + val: "spam".to_string(), 867 + ver: Some(1), 868 + } 869 + .into(); 870 + let canonical = 871 + canonicalize_label_for_signing(&placeholder).expect("canonicalize placeholder label"); 872 + let sig: k256::ecdsa::Signature = signing_key 873 + .sign_prehash(&canonical.prehash) 874 + .expect("sign prehash"); 875 + 876 + let mut signed_data = placeholder.data.clone(); 877 + signed_data.sig = Some(sig.to_bytes().to_vec()); 878 + signed_data.into() 879 + } 772 880 773 881 /// Test that the canonicalizer correctly rejects floats. 774 882 #[test] ··· 1049 1157 ) 1050 1158 }); 1051 1159 } 1160 + } 1161 + 1162 + /// Local labeler with a mismatched signing key: the stage should skip the 1163 + /// rollup rather than flagging a SpecViolation, because the developer is 1164 + /// testing with a test-environment key and not the production key 1165 + /// published in the DID document. 1166 + #[tokio::test] 1167 + async fn local_labeler_skips_rollup_when_signing_key_mismatches() { 1168 + let published_seed: [u8; 32] = [1u8; 32]; 1169 + let local_seed: [u8; 32] = [2u8; 32]; 1170 + 1171 + let published = K256SigningKey::from_slice(&published_seed).expect("valid seed"); 1172 + let local = K256SigningKey::from_slice(&local_seed).expect("valid seed"); 1173 + 1174 + let label = sign_label_with(&local); 1175 + let facts = make_crypto_facts( 1176 + AnyVerifyingKey::K256(*published.verifying_key()), 1177 + Url::parse("http://localhost:8080").unwrap(), 1178 + ); 1179 + 1180 + let output = run(&facts, &[label], &PanicHttpClient).await; 1181 + 1182 + // Exactly one rollup row, with status Skipped. 1183 + assert_eq!(output.results.len(), 1, "expected only the rollup row"); 1184 + let rollup = &output.results[0]; 1185 + assert_eq!(rollup.id, "crypto::rollup"); 1186 + assert_eq!(rollup.status, CheckStatus::Skipped); 1187 + let reason = rollup 1188 + .skipped_reason 1189 + .as_deref() 1190 + .expect("skip reason present"); 1191 + assert!( 1192 + reason.contains("local labeler"), 1193 + "skip reason should mention local labeler: {reason}" 1194 + ); 1195 + assert!( 1196 + output.facts.is_none(), 1197 + "facts should be None when the rollup is skipped" 1198 + ); 1199 + } 1200 + 1201 + /// Local labeler whose labels ARE signed with the published key: the 1202 + /// stage should still Pass, not Skip. The local-labeler relaxation only 1203 + /// fires when verification actually fails. 1204 + #[tokio::test] 1205 + async fn local_labeler_passes_when_signing_key_matches() { 1206 + let seed: [u8; 32] = [3u8; 32]; 1207 + let signing = K256SigningKey::from_slice(&seed).expect("valid seed"); 1208 + let label = sign_label_with(&signing); 1209 + 1210 + let facts = make_crypto_facts( 1211 + AnyVerifyingKey::K256(*signing.verifying_key()), 1212 + Url::parse("http://127.0.0.1:5000").unwrap(), 1213 + ); 1214 + 1215 + let output = run(&facts, &[label], &PanicHttpClient).await; 1216 + 1217 + let rollup = output 1218 + .results 1219 + .iter() 1220 + .find(|r| r.id == "crypto::rollup") 1221 + .expect("rollup row present"); 1222 + assert_eq!( 1223 + rollup.status, 1224 + CheckStatus::Pass, 1225 + "matching local key should still Pass" 1226 + ); 1227 + let facts = output.facts.expect("facts populated on pass"); 1228 + assert_eq!(facts.verified_with_current, 1); 1052 1229 } 1053 1230 }
+287 -106
src/commands/test/labeler/identity.rs
··· 18 18 }; 19 19 use crate::common::identity::{ 20 20 AnyVerifyingKey, Did, DidDocument, DnsResolver, HttpClient, IdentityError, RawDidDocument, 21 - find_service, parse_multikey, resolve_did, resolve_handle, 21 + find_service, is_local_labeler_hostname, parse_multikey, resolve_did, resolve_handle, 22 22 }; 23 23 use crate::common::report::{CheckResult, CheckStatus, Stage}; 24 24 25 + /// The fetched labeler record with parsed policies and optional field lists. 26 + struct FetchedLabelerRecord { 27 + /// Raw bytes of the record for diagnostics. 28 + bytes: Arc<[u8]>, 29 + /// Parsed policies from the record. 30 + policies: LabelerPolicies, 31 + /// Advertised reason types (if present). 32 + reason_types: Option<Vec<String>>, 33 + /// Advertised subject types (if present). 34 + subject_types: Option<Vec<String>>, 35 + /// Advertised subject collections (if present). 36 + subject_collections: Option<Vec<String>>, 37 + } 38 + 25 39 /// Facts about the labeler's identity, populated only when all checks pass. 26 40 #[derive(Debug, Clone)] 27 41 pub struct IdentityFacts { ··· 47 61 pub labeler_record_bytes: Arc<[u8]>, 48 62 /// The parsed labeler policies record. 49 63 pub labeler_policies: LabelerPolicies, 64 + /// `app.bsky.labeler.service.reasonTypes` — the NSIDs of reason types 65 + /// this labeler accepts for `createReport`. `None` means "not advertised"; 66 + /// the report stage treats `None` and `Some(vec![])` identically as 67 + /// "contract not published" per AC1.4. 68 + pub reason_types: Option<Vec<String>>, 69 + /// `app.bsky.labeler.service.subjectTypes` — the subject-type kinds 70 + /// (`account`, `record`, ...) this labeler accepts for reports. 71 + pub subject_types: Option<Vec<String>>, 72 + /// `app.bsky.labeler.service.subjectCollections` — NSIDs of record 73 + /// collections this labeler will accept reports about. Retained so 74 + /// future pollution-avoidance refinements can honor collection-level 75 + /// restrictions; not currently consumed by the report stage. 76 + pub subject_collections: Option<Vec<String>>, 50 77 } 51 78 52 79 /// Output from the identity stage: facts (if all checks pass) plus all check results. ··· 365 392 } 366 393 } 367 394 395 + pub fn advisory( 396 + self, 397 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 398 + ) -> CheckResult { 399 + CheckResult { 400 + id: self.id(), 401 + stage: Stage::IDENTITY, 402 + status: CheckStatus::Advisory, 403 + summary: Cow::Borrowed(self.summary_str()), 404 + diagnostic, 405 + skipped_reason: None, 406 + } 407 + } 408 + 368 409 /// Skip this check because a prerequisite check failed. 369 410 pub fn blocked_by(self, prerequisite: Check) -> CheckResult { 370 411 self.skip(format!("blocked by {}", prerequisite.id())) ··· 503 544 // Check::LabelerEndpointParseable and Check::LabelerEndpointIsHttps. 504 545 // If the service is missing, both checks are blocked. If the URL is 505 546 // unparseable, the scheme check is blocked by the parseable check. 506 - let labeler_endpoint: Option<Url> = match labeler_service { 547 + // 548 + // `mut` so the ResolvedDidMatchesFlag branch below can substitute the 549 + // user's local override URL into `IdentityFacts.labeler_endpoint` when 550 + // a local `--target` endpoint disagrees with the DID document. 551 + let mut labeler_endpoint: Option<Url> = match labeler_service { 507 552 None => { 508 553 results.push(Check::LabelerEndpointParseable.blocked_by(Check::LabelerServicePresent)); 509 554 results.push(Check::LabelerEndpointIsHttps.blocked_by(Check::LabelerServicePresent)); ··· 512 557 Some(svc) => match Url::parse(&svc.service_endpoint) { 513 558 Ok(url) => { 514 559 results.push(Check::LabelerEndpointParseable.pass()); 515 - if url.scheme() != "https" { 560 + // HTTPS is the default accepted scheme. Plaintext HTTP is also 561 + // accepted when the hostname is local (loopback, RFC 1918, 562 + // `.local` mDNS) so developers can target a labeler running on 563 + // their own machine or LAN. 564 + let is_https = url.scheme() == "https"; 565 + let is_http_local = url.scheme() == "http" && is_local_labeler_hostname(&url); 566 + if !is_https && !is_http_local { 516 567 let span = 517 568 span_for_quoted_literal(display_doc_bytes.as_ref(), &svc.service_endpoint); 518 569 let diag = Box::new(NonHttpsLabelerEndpointError { 519 570 message: format!( 520 - "Labeler endpoint must use HTTPS, got: {}", 571 + "Labeler endpoint must use HTTPS (or HTTP with a local hostname), got: {}", 521 572 svc.service_endpoint 522 573 ), 523 574 named_source: NamedSource::new( ··· 569 620 }, 570 621 Some(resolved_endpoint), 571 622 ) => { 572 - if !endpoints_match(flag_url, resolved_endpoint) { 623 + if endpoints_match(flag_url, resolved_endpoint) { 624 + results.push(Check::ResolvedDidMatchesFlag.pass()); 625 + } else { 573 626 // Search for the raw endpoint string from the DID doc's service entry. 574 627 let service = 575 628 find_service(&raw_did_doc.parsed, "atproto_labeler", "AtprotoLabeler"); 576 629 let span = service.and_then(|svc| { 577 630 span_for_quoted_literal(display_doc_bytes.as_ref(), &svc.service_endpoint) 578 631 }); 579 - let diag = Box::new(EndpointMismatchError { 580 - message: format!( 581 - "DID document endpoint ({resolved_endpoint}) does not match provided endpoint ({flag_url})" 582 - ), 583 - named_source: NamedSource::new( 584 - raw_did_doc.source_name.clone(), 585 - display_doc_bytes.clone(), 586 - ), 587 - span, 588 - }); 589 - block_facts = true; 590 - results.push(Check::ResolvedDidMatchesFlag.spec_violation(Some(diag))); 591 - } else { 592 - results.push(Check::ResolvedDidMatchesFlag.pass()); 632 + 633 + if is_local_labeler_hostname(flag_url) { 634 + // The user is targeting a local copy of the labeler. The 635 + // production DID document won't advertise a localhost URL, 636 + // so a mismatch here is expected — surface it as Advisory 637 + // so downstream stages still run, and substitute the local 638 + // URL into IdentityFacts so HTTP/subscription/report all 639 + // talk to the local copy instead of the published endpoint. 640 + let diag = Box::new(EndpointMismatchError { 641 + message: format!( 642 + "DID document endpoint ({resolved_endpoint}) does not match local override ({flag_url}); using the local URL for the remaining stages" 643 + ), 644 + named_source: NamedSource::new( 645 + raw_did_doc.source_name.clone(), 646 + display_doc_bytes.clone(), 647 + ), 648 + span, 649 + }); 650 + results.push(Check::ResolvedDidMatchesFlag.advisory(Some(diag))); 651 + labeler_endpoint = Some(flag_url.clone()); 652 + } else { 653 + let diag = Box::new(EndpointMismatchError { 654 + message: format!( 655 + "DID document endpoint ({resolved_endpoint}) does not match provided endpoint ({flag_url})" 656 + ), 657 + named_source: NamedSource::new( 658 + raw_did_doc.source_name.clone(), 659 + display_doc_bytes.clone(), 660 + ), 661 + span, 662 + }); 663 + block_facts = true; 664 + results.push(Check::ResolvedDidMatchesFlag.spec_violation(Some(diag))); 665 + } 593 666 } 594 667 } 595 668 ( ··· 710 783 }; 711 784 712 785 // Check::LabelerRecordFetched. 713 - let (labeler_record_bytes, labeler_policies): (Option<Arc<[u8]>>, Option<LabelerPolicies>) = 714 - match &pds_endpoint { 715 - None => { 716 - results.push(Check::LabelerRecordFetched.blocked_by(Check::PdsEndpointPresent)); 717 - (None, None) 786 + let fetched_record: Option<FetchedLabelerRecord> = match &pds_endpoint { 787 + None => { 788 + results.push(Check::LabelerRecordFetched.blocked_by(Check::PdsEndpointPresent)); 789 + None 790 + } 791 + Some(pds_url) => match fetch_labeler_record(&did, pds_url, http).await { 792 + Ok(record) => { 793 + results.push(Check::LabelerRecordFetched.pass()); 794 + Some(record) 718 795 } 719 - Some(pds_url) => match fetch_labeler_record(&did, pds_url, http).await { 720 - Ok((bytes, policies)) => { 721 - results.push(Check::LabelerRecordFetched.pass()); 722 - (Some(bytes), Some(policies)) 723 - } 724 - Err(e) => { 725 - let (check_status, message, named_source, span) = match &e { 726 - FetchRecordError::Network(_) => { 727 - (CheckStatus::NetworkError, e.to_string(), None, None) 728 - } 729 - FetchRecordError::NotFound => { 730 - (CheckStatus::SpecViolation, e.to_string(), None, None) 731 - } 732 - FetchRecordError::HttpStatus { .. } => { 733 - (CheckStatus::SpecViolation, e.to_string(), None, None) 734 - } 735 - FetchRecordError::ParseEnvelope { 736 - display_body, 737 - display_line, 738 - display_column, 739 - source, 740 - } => { 741 - let src = NamedSource::new("PDS response", display_body.clone()); 742 - let span = span_at_line_column( 743 - display_body.as_ref(), 744 - *display_line, 745 - *display_column, 746 - ); 747 - ( 748 - CheckStatus::SpecViolation, 749 - format!("Failed to parse PDS getRecord envelope: {source}"), 750 - Some(src), 751 - Some(span), 752 - ) 753 - } 754 - }; 755 - let diag = Box::new(LabelerRecordFetchError { 756 - message, 757 - named_source, 758 - span, 759 - }); 760 - block_facts = true; 761 - let base = match check_status { 762 - CheckStatus::NetworkError => { 763 - Check::LabelerRecordFetched.network_error(Some(diag)) 764 - } 765 - _ => Check::LabelerRecordFetched.spec_violation(Some(diag)), 766 - }; 767 - results.push(base); 768 - (None, None) 769 - } 770 - }, 771 - }; 796 + Err(e) => { 797 + let (check_status, message, named_source, span) = match &e { 798 + FetchRecordError::Network(_) => { 799 + (CheckStatus::NetworkError, e.to_string(), None, None) 800 + } 801 + FetchRecordError::NotFound => { 802 + (CheckStatus::SpecViolation, e.to_string(), None, None) 803 + } 804 + FetchRecordError::HttpStatus { .. } => { 805 + (CheckStatus::SpecViolation, e.to_string(), None, None) 806 + } 807 + FetchRecordError::ParseEnvelope { 808 + display_body, 809 + display_line, 810 + display_column, 811 + source, 812 + } => { 813 + let src = NamedSource::new("PDS response", display_body.clone()); 814 + let span = span_at_line_column( 815 + display_body.as_ref(), 816 + *display_line, 817 + *display_column, 818 + ); 819 + ( 820 + CheckStatus::SpecViolation, 821 + format!("Failed to parse PDS getRecord envelope: {source}"), 822 + Some(src), 823 + Some(span), 824 + ) 825 + } 826 + }; 827 + let diag = Box::new(LabelerRecordFetchError { 828 + message, 829 + named_source, 830 + span, 831 + }); 832 + block_facts = true; 833 + let base = match check_status { 834 + CheckStatus::NetworkError => { 835 + Check::LabelerRecordFetched.network_error(Some(diag)) 836 + } 837 + _ => Check::LabelerRecordFetched.spec_violation(Some(diag)), 838 + }; 839 + results.push(base); 840 + None 841 + } 842 + }, 843 + }; 772 844 773 845 // Check::LabelerRecordPoliciesNonempty. 774 - match (&labeler_record_bytes, &labeler_policies) { 775 - (None, None) => { 846 + match &fetched_record { 847 + None => { 776 848 results 777 849 .push(Check::LabelerRecordPoliciesNonempty.blocked_by(Check::LabelerRecordFetched)); 778 850 } 779 - (Some(bytes), Some(policies)) => { 780 - if policies.label_values.is_empty() { 781 - let display_bytes = pretty_json_for_display(bytes.as_ref()); 851 + Some(record) => { 852 + if record.policies.label_values.is_empty() { 853 + let display_bytes = pretty_json_for_display(record.bytes.as_ref()); 782 854 let span = span_for_quoted_literal(display_bytes.as_ref(), "labelValues"); 783 855 let diag = Box::new(EmptyPoliciesError { 784 856 message: "Labeler record policies.labelValues is empty".to_string(), ··· 791 863 results.push(Check::LabelerRecordPoliciesNonempty.pass()); 792 864 } 793 865 } 794 - _ => { 795 - // Should not reach this state: either both are Some or both are None. 796 - block_facts = true; 797 - results 798 - .push(Check::LabelerRecordPoliciesNonempty.blocked_by(Check::LabelerRecordFetched)); 799 - } 800 866 } 801 867 802 868 // Populate facts only if no checks blocked. ··· 806 872 pds_endpoint, 807 873 signing_key_ids, 808 874 signing_key, 809 - labeler_record_bytes, 810 - labeler_policies, 875 + fetched_record, 811 876 ) { 812 - (Some(le), Some(pe), Some((ski, skm)), Some(sk), Some(lrb), Some(lp)) => { 813 - Some(IdentityFacts { 814 - did, 815 - raw_did_doc, 816 - labeler_endpoint: le, 817 - pds_endpoint: pe, 818 - signing_key_id: ski, 819 - signing_key_multikey: skm, 820 - signing_key: sk, 821 - labeler_record_bytes: lrb, 822 - labeler_policies: lp, 823 - }) 824 - } 877 + (Some(le), Some(pe), Some((ski, skm)), Some(sk), Some(record)) => Some(IdentityFacts { 878 + did, 879 + raw_did_doc, 880 + labeler_endpoint: le, 881 + pds_endpoint: pe, 882 + signing_key_id: ski, 883 + signing_key_multikey: skm, 884 + signing_key: sk, 885 + labeler_record_bytes: record.bytes, 886 + labeler_policies: record.policies, 887 + reason_types: record.reason_types, 888 + subject_types: record.subject_types, 889 + subject_collections: record.subject_collections, 890 + }), 825 891 _ => None, 826 892 } 827 893 } else { ··· 889 955 } 890 956 891 957 /// Fetch the labeler record from the PDS using the HTTP client seam. 892 - /// Returns (bytes, policies) on success. 958 + /// Returns a fetched labeler record with parsed policies and optional field lists on success. 893 959 async fn fetch_labeler_record( 894 960 did: &Did, 895 961 pds_endpoint: &Url, 896 962 http: &dyn HttpClient, 897 - ) -> Result<(Arc<[u8]>, LabelerPolicies), FetchRecordError> { 963 + ) -> Result<FetchedLabelerRecord, FetchRecordError> { 898 964 // Build the XRPC request URL. 899 965 let mut url = pds_endpoint.clone(); 900 966 url.set_path("/xrpc/com.atproto.repo.getRecord"); ··· 920 986 // The response is expected to have 921 987 // `{uri, cid, value: <app.bsky.labeler.service record>}`. 922 988 match serde_json::from_slice::<GetRecordResponse>(body_arc.as_ref()) { 923 - Ok(response) => Ok((body_arc, response.value.policies)), 989 + Ok(response) => { 990 + let reason_types = response.value.reason_types.as_ref().map(|v| v.to_vec()); 991 + let subject_types = response.value.subject_types.as_ref().map(|v| v.to_vec()); 992 + let subject_collections = response 993 + .value 994 + .subject_collections 995 + .as_ref() 996 + .map(|v| v.iter().map(|n| n.to_string()).collect::<Vec<String>>()); 997 + Ok(FetchedLabelerRecord { 998 + bytes: body_arc, 999 + policies: response.value.policies, 1000 + reason_types, 1001 + subject_types, 1002 + subject_collections, 1003 + }) 1004 + } 924 1005 Err(raw_err) => { 925 1006 // Pretty-print the body so miette's source display wraps 926 1007 // across lines, then re-parse against the pretty form to ··· 961 1042 #[label("JSON parse error")] 962 1043 span: SourceSpan, 963 1044 } 1045 + 1046 + #[cfg(test)] 1047 + mod tests { 1048 + use super::*; 1049 + use async_trait::async_trait; 1050 + 1051 + /// Fake HTTP client for unit tests. 1052 + struct FakeHttpClient { 1053 + responses: std::collections::HashMap<String, (u16, Vec<u8>)>, 1054 + } 1055 + 1056 + impl FakeHttpClient { 1057 + fn new() -> Self { 1058 + Self { 1059 + responses: std::collections::HashMap::new(), 1060 + } 1061 + } 1062 + 1063 + fn add_response(&mut self, url: impl Into<String>, status: u16, body: Vec<u8>) { 1064 + self.responses.insert(url.into(), (status, body)); 1065 + } 1066 + } 1067 + 1068 + #[async_trait] 1069 + impl HttpClient for FakeHttpClient { 1070 + async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError> { 1071 + let url_str = url.as_str(); 1072 + self.responses 1073 + .get(url_str) 1074 + .cloned() 1075 + .ok_or_else(|| IdentityError::DidResolutionFailed { 1076 + status: 404, 1077 + body: "Not found".to_string(), 1078 + }) 1079 + } 1080 + } 1081 + 1082 + #[tokio::test] 1083 + async fn identity_retains_reason_and_subject_types() { 1084 + // Load the fixture that includes reasonTypes and subjectTypes. 1085 + let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( 1086 + "tests/fixtures/labeler/identity/report_stage_contract_present/labeler_record.json", 1087 + ); 1088 + let labeler_record_bytes = std::fs::read(&fixture_path).expect("fixture file exists"); 1089 + 1090 + // Create a fake HTTP client and seed it with the fixture. 1091 + let mut http = FakeHttpClient::new(); 1092 + let pds_url = Url::parse("https://pds.example.com").unwrap(); 1093 + let did = Did("did:plc:test123456789012345678901234".to_string()); 1094 + 1095 + // The query string format expected by fetch_labeler_record. 1096 + let query = format!( 1097 + "repo={}&collection=app.bsky.labeler.service&rkey=self", 1098 + did.0 1099 + ); 1100 + let mut fetch_url = pds_url.clone(); 1101 + fetch_url.set_path("/xrpc/com.atproto.repo.getRecord"); 1102 + fetch_url.set_query(Some(&query)); 1103 + 1104 + http.add_response(fetch_url.as_str(), 200, labeler_record_bytes.clone()); 1105 + 1106 + // Call fetch_labeler_record. 1107 + let result = fetch_labeler_record(&did, &pds_url, &http).await; 1108 + 1109 + // Assert success and check the returned fields. 1110 + assert!(result.is_ok(), "fetch_labeler_record should succeed"); 1111 + let record = result.unwrap(); 1112 + 1113 + // Verify reason_types is present and non-empty. 1114 + assert!(record.reason_types.is_some(), "reason_types should be Some"); 1115 + let rt = record.reason_types.unwrap(); 1116 + assert_eq!(rt.len(), 2, "reason_types should have 2 entries"); 1117 + assert!( 1118 + rt.iter().any(|r| r.contains("reasonSpam")), 1119 + "should include reasonSpam" 1120 + ); 1121 + 1122 + // Verify subject_types is present and non-empty. 1123 + assert!( 1124 + record.subject_types.is_some(), 1125 + "subject_types should be Some" 1126 + ); 1127 + let st = record.subject_types.unwrap(); 1128 + assert_eq!(st.len(), 2, "subject_types should have 2 entries"); 1129 + assert!(st.iter().any(|s| s == "account"), "should include account"); 1130 + assert!(st.iter().any(|s| s == "record"), "should include record"); 1131 + 1132 + // Verify subject_collections is present and non-empty. 1133 + assert!( 1134 + record.subject_collections.is_some(), 1135 + "subject_collections should be Some" 1136 + ); 1137 + let sc = record.subject_collections.unwrap(); 1138 + assert_eq!(sc.len(), 2, "subject_collections should have 2 entries"); 1139 + assert!( 1140 + sc.iter().any(|s| s.contains("bsky.feed.post")), 1141 + "should include app.bsky.feed.post" 1142 + ); 1143 + } 1144 + }
+212 -8
src/commands/test/labeler/pipeline.rs
··· 6 6 use thiserror::Error; 7 7 use url::Url; 8 8 9 + use crate::commands::test::labeler::create_report::self_mint::{SelfMintCurve, SelfMintSigner}; 10 + use crate::commands::test::labeler::create_report::{ 11 + self, CreateReportTee, PdsXrpcClient, RealCreateReportTee, 12 + }; 9 13 use crate::commands::test::labeler::crypto; 10 14 use crate::commands::test::labeler::http::{self, RealHttpTee}; 11 15 use crate::commands::test::labeler::identity; 12 16 use crate::commands::test::labeler::subscription::{self, RealWebSocketClient}; 13 - use crate::common::{ 14 - identity::{Did, DnsResolver, HttpClient}, 15 - report::{CheckStatus, LabelerReport, ReportHeader, Stage, blocked_by, skipped_with_reason}, 17 + use crate::common::identity::{ 18 + Did, DnsResolver, HttpClient, find_service, is_local_labeler_hostname, resolve_did, 19 + resolve_handle, 20 + }; 21 + use crate::common::report::{ 22 + CheckStatus, LabelerReport, ReportHeader, Stage, blocked_by, skipped_with_reason, 16 23 }; 17 24 18 25 /// A labeler target: either a resolvable identifier (handle or DID) or a raw endpoint URL. ··· 57 64 pub subscribe_timeout: Duration, 58 65 /// Whether to emit verbose diagnostics. 59 66 pub verbose: bool, 67 + 68 + /// CreateReport tee to use for the report stage. 69 + pub create_report_tee: CreateReportTeeKind<'a>, 70 + /// Commit: opt in to actually POSTing report bodies to the labeler and 71 + /// assert reporting conformance. 72 + pub commit_report: bool, 73 + /// Force self-mint checks to run even when the labeler endpoint is 74 + /// classified as non-local by the hostname heuristic. 75 + pub force_self_mint: bool, 76 + /// Curve to use for self-mint JWTs. Default `es256k`. 77 + pub self_mint_curve: SelfMintCurve, 78 + /// Override the default computed subject DID for committing checks. 79 + pub report_subject_override: Option<&'a Did>, 80 + /// Self-mint signer. Populated in `LabelerCmd::run` only when the 81 + /// heuristic + `--force-self-mint` say self-mint is viable; otherwise 82 + /// `None` so the report stage skips the self-mint checks with a 83 + /// reason. 84 + pub self_mint_signer: Option<&'a SelfMintSigner>, 85 + /// PDS credentials for modes 2 and 3. Populated when `--handle` and 86 + /// `--app-password` are both supplied; otherwise `None`. 87 + pub pds_credentials: Option<&'a PdsCredentials>, 88 + /// PDS XRPC client for modes 2 and 3. Populated by the pipeline when 89 + /// credentials are supplied; tests inject overrides via `pds_xrpc_client_override`. 90 + pub pds_xrpc_client: Option<&'a dyn PdsXrpcClient>, 91 + /// Test-only override for PDS XRPC client injection. When set, takes 92 + /// precedence over the pipeline-constructed client. 93 + pub pds_xrpc_client_override: Option<&'a dyn PdsXrpcClient>, 94 + /// Stable run-id for the sentinel reason string. Created in `LabelerCmd::run`. 95 + pub run_id: &'a str, 60 96 } 61 97 62 98 /// HTTP tee to use for the labeler endpoint. ··· 67 103 Test(&'a dyn http::RawHttpTee), 68 104 } 69 105 106 + /// CreateReport tee selector. `Real` delegates to a shared reqwest client 107 + /// that the pipeline instantiates per-endpoint; `Test` lets integration 108 + /// tests inject a `FakeCreateReportTee`. 109 + pub enum CreateReportTeeKind<'a> { 110 + /// Shared reqwest client; pipeline constructs a `RealCreateReportTee`. 111 + Real(&'a reqwest::Client), 112 + /// Explicit control (for tests). 113 + Test(&'a dyn CreateReportTee), 114 + } 115 + 116 + /// Credentials for PDS-mediated modes (modes 2 and 3). Present when 117 + /// `--handle` and `--app-password` are both supplied; otherwise `None`. 118 + #[derive(Debug, Clone)] 119 + pub struct PdsCredentials { 120 + /// The user's handle (e.g., `alice.bsky.social`). 121 + pub handle: String, 122 + /// The user's app password. 123 + pub app_password: String, 124 + } 125 + 70 126 /// Error from target parsing. 71 127 #[derive(Debug, Error, Diagnostic)] 72 128 #[error("{message}")] ··· 84 140 85 141 fn unrecognized_target(raw: &str) -> Self { 86 142 Self::new(format!( 87 - "Unrecognized target '{raw}'. Expected one of:\n - ATProto handle (e.g., alice.bsky.social)\n - DID (e.g., did:plc:abc123 or did:web:example.com)\n - HTTPS endpoint URL (e.g., https://labeler.example.com)" 143 + "Unrecognized target '{raw}'. Expected one of:\n - ATProto handle (e.g., alice.bsky.social)\n - DID (e.g., did:plc:abc123 or did:web:example.com)\n - HTTPS endpoint URL (e.g., https://labeler.example.com)\n - HTTP endpoint URL with a local hostname (e.g., http://localhost:8080)" 88 144 )) 89 145 } 90 146 91 147 fn http_not_supported(raw: &str) -> Self { 92 148 Self::new(format!( 93 - "HTTP endpoint '{raw}' is not supported. Please use an HTTPS endpoint instead." 149 + "HTTP endpoint '{raw}' is not supported for remote hosts. Use HTTPS, or point at a local labeler (localhost / 127.0.0.0/8 / RFC 1918 / .local) to allow plaintext HTTP." 94 150 )) 95 151 } 96 152 ··· 173 229 }); 174 230 } 175 231 176 - // Check for HTTP URL (reject with helpful message). 232 + // Check for HTTP URL. Local hostnames (loopback, RFC 1918, .local, mDNS) 233 + // are accepted so developers can target a labeler running on their 234 + // machine or LAN. Remote HTTP is still rejected to guard against 235 + // accidental plaintext traffic to a production labeler. 177 236 if raw.starts_with("http://") { 237 + let url = Url::parse(raw) 238 + .map_err(|e| TargetParseError::new(format!("Invalid URL '{raw}': {e}")))?; 239 + if is_local_labeler_hostname(&url) { 240 + return Ok(LabelerTarget::Endpoint { 241 + url, 242 + did: explicit_did.map(|d| Did(d.to_string())), 243 + }); 244 + } 178 245 return Err(TargetParseError::http_not_supported(raw)); 179 246 } 180 247 ··· 359 426 )); 360 427 } 361 428 429 + // Construct the PDS XRPC client when credentials are supplied. The test 430 + // override takes precedence if provided; else we resolve the reporter's 431 + // own PDS endpoint from `--handle` and construct a real client against 432 + // it. The labeler's PDS (`identity_output.facts.pds_endpoint`) is NOT 433 + // the right target here: `createSession` / `getServiceAuth` must hit the 434 + // PDS that actually hosts the reporter's account, otherwise the PDS 435 + // rejects the access token (`InvalidToken: Token could not be verified`). 436 + let mut pds_resolution_error: Option<String> = None; 437 + let pds_xrpc_client_owned: Option<create_report::RealPdsXrpcClient> = 438 + if opts.pds_xrpc_client_override.is_some() { 439 + // Test override supplied; don't construct real client. 440 + None 441 + } else if let (Some(creds), CreateReportTeeKind::Real(http_client)) = 442 + (opts.pds_credentials, &opts.create_report_tee) 443 + { 444 + match resolve_reporter_pds_endpoint(&creds.handle, opts.http, opts.dns).await { 445 + Ok(url) => Some(create_report::RealPdsXrpcClient::new( 446 + (*http_client).clone(), 447 + url, 448 + )), 449 + Err(msg) => { 450 + pds_resolution_error = Some(msg); 451 + None 452 + } 453 + } 454 + } else { 455 + None 456 + }; 457 + let pds_xrpc_client_ref: Option<&dyn PdsXrpcClient> = pds_xrpc_client_owned 458 + .as_ref() 459 + .map(|c| c as &dyn PdsXrpcClient); 460 + 461 + // Run the report stage. Uses `identity_output.facts.as_ref()` so the 462 + // stage can skip-with-reason when identity didn't produce facts. 463 + let create_report_run_opts = create_report::CreateReportRunOptions { 464 + commit_report: opts.commit_report, 465 + force_self_mint: opts.force_self_mint, 466 + self_mint_curve: opts.self_mint_curve, 467 + report_subject_override: opts.report_subject_override, 468 + self_mint_signer: opts.self_mint_signer, 469 + pds_credentials: opts.pds_credentials, 470 + pds_xrpc_client: opts.pds_xrpc_client_override.or(pds_xrpc_client_ref), 471 + pds_resolution_error: pds_resolution_error.as_deref(), 472 + run_id: opts.run_id, 473 + }; 474 + let labeler_endpoint_for_report = labeler_endpoint.clone(); 475 + let report_output = match opts.create_report_tee { 476 + CreateReportTeeKind::Test(tee) => { 477 + create_report::run(identity_output.facts.as_ref(), tee, &create_report_run_opts).await 478 + } 479 + CreateReportTeeKind::Real(client) => { 480 + // Resolve the labeler endpoint for the real tee. When identity 481 + // didn't supply an endpoint, we'd still want `run` to emit the 482 + // 10 `Skipped` rows — but construct a do-nothing tee with a 483 + // dummy URL to satisfy the type; the function short-circuits on 484 + // `identity_facts == None` before any tee method is called. 485 + let endpoint = labeler_endpoint_for_report.unwrap_or_else(|| { 486 + // Safe dummy: we know `run` won't issue any POSTs in this 487 + // path (identity_facts is None). 488 + url::Url::parse("http://127.0.0.1:0").expect("dummy URL parses") 489 + }); 490 + let real_tee = RealCreateReportTee::new(client.clone(), endpoint); 491 + create_report::run( 492 + identity_output.facts.as_ref(), 493 + &real_tee, 494 + &create_report_run_opts, 495 + ) 496 + .await 497 + } 498 + }; 499 + for result in report_output.results { 500 + report.record(result); 501 + } 502 + 362 503 report.finish(); 363 504 report 364 505 } 365 506 507 + /// Resolve the reporter's handle to the service endpoint of their PDS. 508 + /// 509 + /// This is the endpoint `createSession` and `getServiceAuth` must be 510 + /// dispatched against in the report stage's PDS-mediated modes. It is 511 + /// generally NOT the same as `IdentityFacts::pds_endpoint`, which is the 512 + /// PDS advertised by the *labeler's* DID document. 513 + /// 514 + /// Failures are flattened to a single human-readable string — the report 515 + /// stage surfaces it as a `NetworkError` on both PDS-mediated rows. 516 + async fn resolve_reporter_pds_endpoint( 517 + handle: &str, 518 + http: &dyn HttpClient, 519 + dns: &dyn DnsResolver, 520 + ) -> Result<Url, String> { 521 + let did = resolve_handle(handle, http, dns) 522 + .await 523 + .map_err(|e| format!("failed to resolve handle {handle} to a DID: {e}"))?; 524 + let raw_doc = resolve_did(&did, http) 525 + .await 526 + .map_err(|e| format!("failed to resolve DID {did} to a DID document: {e}"))?; 527 + let service = find_service(&raw_doc.parsed, "atproto_pds", "AtprotoPersonalDataServer") 528 + .ok_or_else(|| { 529 + format!("DID document for {did} does not advertise an #atproto_pds service") 530 + })?; 531 + Url::parse(&service.service_endpoint).map_err(|_| { 532 + format!( 533 + "DID document for {did} has a malformed #atproto_pds endpoint: {}", 534 + service.service_endpoint 535 + ) 536 + }) 537 + } 538 + 366 539 /// Format a target for display in the report header. 367 540 fn format_target(target: &LabelerTarget) -> String { 368 541 match target { ··· 469 642 } 470 643 471 644 #[test] 472 - fn parse_target_endpoint_http_rejected() { 645 + fn parse_target_endpoint_http_remote_rejected() { 473 646 let err = parse_target("http://evil.example", None).expect_err("should reject http"); 474 - assert!(err.message.contains("HTTPS")); 647 + assert!(err.message.contains("HTTP")); 648 + assert!(err.message.contains("local")); 649 + } 650 + 651 + #[test] 652 + fn parse_target_endpoint_http_local_accepted() { 653 + // Each of these hostnames is classified as local by 654 + // `is_local_labeler_hostname`, so plaintext HTTP is allowed. 655 + let cases = &[ 656 + "http://localhost:8080", 657 + "http://127.0.0.1:5000", 658 + "http://127.1.2.3/", 659 + "http://[::1]:8080/", 660 + "http://10.0.0.1/", 661 + "http://192.168.1.100:8080", 662 + "http://172.16.0.1/", 663 + "http://mybox.local:8080", 664 + ]; 665 + for raw in cases { 666 + let target = parse_target(raw, None) 667 + .unwrap_or_else(|e| panic!("expected {raw} to parse, got: {}", e.message)); 668 + match target { 669 + LabelerTarget::Endpoint { url, did } => { 670 + assert_eq!( 671 + url.as_str().trim_end_matches('/'), 672 + raw.trim_end_matches('/') 673 + ); 674 + assert!(did.is_none()); 675 + } 676 + _ => panic!("expected Endpoint variant for {raw}"), 677 + } 678 + } 475 679 } 476 680 477 681 #[test]
+1
src/common.rs
··· 2 2 3 3 pub mod diagnostics; 4 4 pub mod identity; 5 + pub mod jwt; 5 6 pub mod oauth; 6 7 pub mod report; 7 8
+80 -8
src/common/CLAUDE.md
··· 5 5 ## Purpose 6 6 7 7 Narrow, mockable primitives shared across `atproto-devtool`'s conformance 8 - test subcommands. Four submodules: 8 + test subcommands. Five submodules: 9 9 10 10 - `common::identity` — DNS, HTTPS, PLC directory, DID document, and labeler 11 11 record primitives. Every network hop here is a trait object so integration 12 - tests can replay recorded fixtures instead of hitting real servers. 12 + tests can replay recorded fixtures instead of hitting real servers. Also 13 + owns the signing/verifying key newtypes (`AnySigningKey`, 14 + `AnyVerifyingKey`, `AnySignature`) that every curve-generic operation 15 + routes through. 16 + - `common::jwt` — minimal hand-rolled compact JWS encoder/decoder used by 17 + the labeler report stage to mint self-mint service-auth tokens without 18 + pulling a JWT library. Only ES256 and ES256K. 13 19 - `common::oauth` — OAuth 2.0 / JOSE primitives for the `test oauth *` 14 20 family: `clock` (trait-object time source), `jws` (JWK parsing + ES256 15 21 signing/verification via `jsonwebtoken`), and `relying_party` (atproto-spec ··· 33 39 constructible from a shared client via `from_client` so stages can reuse 34 40 one TLS pool) and `RealDnsResolver` (hickory). 35 41 - Types: `Did`, `DidMethod`, `DidDocument`, `RawDidDocument`, `Service`, 36 - `VerificationMethod`, `Curve`, `AnyVerifyingKey`, `AnySignature`, 37 - `ParsedMultikey`, `PlcHistoricKey`. 42 + `VerificationMethod`, `Curve`, `AnyVerifyingKey`, `AnySigningKey`, 43 + `AnySignature`, `ParsedMultikey`, `PlcHistoricKey`. 38 44 - Resolvers: `resolve_handle`, `resolve_did`, `find_service`, 39 - `parse_multikey`, `plc_history_for_fragment`. 45 + `parse_multikey`, `encode_multikey`, `plc_history_for_fragment`. 46 + - Classification: `is_local_labeler_hostname(&Url) -> bool` — returns 47 + `true` for loopback, `.local`, and RFC 1918 IPv4 addresses. Drives 48 + the report stage's self-mint viability check. 40 49 - `IdentityError` — single error enum covering every resolution failure. 41 50 Variants are matched on by the identity stage to emit distinct check 42 51 results, so adding or removing variants is a contract change. 52 + - **Signing API**: 53 + - `AnySigningKey::{K256, P256}` mirror `AnyVerifyingKey` for the 54 + signing side. `sign(msg)` and `sign_prehash(&[u8; 32])` return 55 + `AnySignature`. All signatures are low-s normalized — the k256 56 + backend does this automatically; the p256 backend normalizes 57 + explicitly because p256's `sign_prehash` can return high-s. 58 + - `AnySigningKey::verifying_key()` returns the paired `AnyVerifyingKey`. 59 + - `AnySigningKey::jwt_alg()` returns `"ES256K"` or `"ES256"`. 60 + - `AnySignature::to_jws_bytes() -> [u8; 64]` serializes `r || s` 61 + big-endian (JWS raw-signature form, NOT DER). 62 + - `encode_multikey(&AnyVerifyingKey) -> String` is the exact inverse 63 + of `parse_multikey`: base58btc multibase with the multicodec curve 64 + prefix (`0xe701` for secp256k1, `0x8024` for P-256) followed by the 65 + compressed SEC1 point. 66 + - **Exposes from `jwt`**: 67 + - Types: `JwtHeader`, `JwtClaims`, `JwtError`. Field names 68 + (`alg`, `typ`, `iss`, `aud`, `exp`, `iat`, `lxm`, `jti`) are the 69 + exact JSON keys atproto labelers expect — do NOT rename without 70 + adding `#[serde(rename = "...")]`. 71 + - Functions: `encode_compact(&header, &claims, &AnySigningKey)` for 72 + producing compact-form tokens, `decode_compact(token)` for parsing, 73 + `verify_compact(token, &AnyVerifyingKey)` for end-to-end verify. 74 + - Only ES256 and ES256K are supported. `nbf` is deliberately omitted 75 + — the atproto spec does not require it and some servers reject 76 + unexpected claims. 77 + - `JwtError` does NOT derive `miette::Diagnostic` with stable codes. 78 + It is surfaced only inside the stage, which wraps any failure in a 79 + stage-local diagnostic with a `labeler::report::*` code before 80 + rendering. 43 81 - **Exposes from `oauth::clock`**: 44 82 - Trait: `Clock` (`now_unix_seconds(&self) -> u64`). The only sanctioned 45 83 time source for anything the OAuth tests observe. Passed as ··· 78 116 - Struct: `CheckResult { id: &'static str, stage: Stage, status, summary, 79 117 diagnostic: Option<Box<dyn Diagnostic + Send + Sync>>, skipped_reason }`. 80 118 - Newtype: `Stage(pub &'static str)` with `label()` and the constants 81 - `IDENTITY`, `HTTP`, `SUBSCRIPTION`, `CRYPTO` (labeler) plus 119 + `IDENTITY`, `HTTP`, `SUBSCRIPTION`, `CRYPTO`, `REPORT` (labeler) plus 82 120 `DISCOVERY`, `METADATA`, `JWKS`, `INTERACTIVE` (oauth client). Use 83 121 `Stage::<NAME>` rather than constructing ad-hoc stages — the render 84 122 loop groups by stage identity and stable heading strings appear in ··· 121 159 `serde_json`, `serde_urlencoded` (OAuth form bodies), `miette`, 122 160 `thiserror`, `jsonwebtoken` (rust_crypto backend, for `oauth::jws`), 123 161 `rand_chacha` + `rand_core` (for the deterministic RP RNG), `url`. 162 + `common::jwt` additionally uses `base64` (URL_SAFE_NO_PAD engine) and 163 + `serde`. 124 164 - **Used by**: every stage in `commands/test/labeler/` and 125 165 `commands/test/oauth/client/`, plus integration tests under `tests/`. 166 + `common::jwt` is currently only used by the labeler report stage. 126 167 - **Boundary**: nothing in `common/` depends on anything under `commands/`. 127 168 Stage-specific types (`IdentityFacts`, `DiscoveryFacts`, `MetadataFacts`, 128 169 etc.) live next to their stage, not here. `common::report` is the only ··· 146 187 before building the PLC directory URL. Do not regress. 147 188 - **No `#[serde(flatten)]` / `#[serde(untagged)]`**: Required by project 148 189 conventions; all DID document types use explicit `#[serde(rename)]`. 190 + - **All signatures low-s normalized**: `AnySigningKey::sign` guarantees 191 + low-s form for both curves so `AnyVerifyingKey::verify_prehash` round-trip 192 + always succeeds. atproto requires low-s; p256's backend does not enforce 193 + it, so we explicitly call `normalize_s`. 194 + - **Hand-rolled JWT instead of a library**: We only need compact JWS with 195 + ES256/ES256K for a handful of tightly-scoped report-stage tokens. A full 196 + JWT library would pull RSA, HMAC, JWE, and a JSON Schema validator we do 197 + not want. The module is <500 lines and fully covered by round-trip 198 + tests. 199 + - **`is_local_labeler_hostname` is deliberately conservative**: IPv6 200 + private ranges (`fc00::/7`, link-local) are NOT classified as local in 201 + v1. Operators running labelers on IPv6 ULA must pass `--force-self-mint`. 149 202 150 203 ## Invariants 151 204 ··· 156 209 - `HttpClient::get_bytes` returns the HTTP status even for non-2xx responses 157 210 rather than converting them to errors; callers decide what a non-200 158 211 means in context. 212 + - `AnySignature::to_jws_bytes()` is always exactly 64 bytes, for both 213 + curves. 214 + - `encode_multikey(parse_multikey(s).verifying_key) == s` for every 215 + well-formed atproto multikey — round-tripping is pinned by unit tests. 216 + - `jwt::verify_compact` accepts exactly three `.`-separated segments. 217 + Four-segment (JWE) or malformed inputs return `JwtError::MalformedCompact`. 159 218 160 219 ## Key files 161 220 162 - - `identity.rs` — all `HttpClient` / `DnsResolver` / DID plumbing, plus 163 - extensive unit tests at the bottom. 221 + - `identity.rs` — all resolvers, DID types, signing/verifying key 222 + newtypes, `encode_multikey` / `parse_multikey`, 223 + `is_local_labeler_hostname`, plus extensive unit tests at the bottom. 224 + - `jwt.rs` — compact JWS encoder/decoder for ES256 and ES256K: 225 + `JwtHeader`, `JwtClaims`, `JwtError`, `encode_compact`, 226 + `decode_compact`, `verify_compact`. Segments use unpadded base64url; 227 + signatures are raw `r || s` (not DER) per RFC 7518 §3.4. 164 228 - `oauth/clock.rs` — `Clock` trait and `RealClock`. 165 229 - `oauth/jws.rs` — `JwsAlg`, `JwkUse`, `ParsedJwk`, `JwsError`, `parse_jwk`, 166 230 `sign_jws`, `verify_jws`. All `JwsError` variants carry stable ··· 194 258 (oldest-first) and dedupes by multikey string, not by position — the 195 259 same key appearing across multiple rotations collapses to a single 196 260 `PlcHistoricKey` (keeping the earliest introduction's metadata). 261 + - `AnySigningKey` nonces (`jti` in JWTs, run-id in sentinels) come from 262 + `getrandom::getrandom`, not from `rand`. The crate is a direct dep 263 + because the transitive `rand_core` in `elliptic-curve` is built 264 + without the `getrandom` feature. 265 + - p256 `sign_prehash` returns signatures that may be high-s; always go 266 + through `AnySigningKey::sign` / `sign_prehash` rather than calling the 267 + backend trait directly, or low-s normalization will be skipped and 268 + atproto servers will reject the signature.
+323
src/common/identity.rs
··· 145 145 } 146 146 } 147 147 148 + /// A private signing key that may be one of several supported curves. 149 + /// 150 + /// Mirrors `AnyVerifyingKey` for the signing side. Signatures produced by 151 + /// `sign` are always low-s normalized to match the atproto convention 152 + /// already established by `AnySignature`. 153 + #[derive(Debug, Clone)] 154 + pub enum AnySigningKey { 155 + /// secp256k1 signing key. 156 + K256(k256::ecdsa::SigningKey), 157 + /// P-256 signing key. 158 + P256(p256::ecdsa::SigningKey), 159 + } 160 + 161 + impl AnySigningKey { 162 + /// Returns the corresponding verifying key. 163 + pub fn verifying_key(&self) -> AnyVerifyingKey { 164 + match self { 165 + AnySigningKey::K256(k) => AnyVerifyingKey::K256(*k.verifying_key()), 166 + AnySigningKey::P256(k) => AnyVerifyingKey::P256(*k.verifying_key()), 167 + } 168 + } 169 + 170 + /// Returns the JWT `alg` header identifier for this key's curve 171 + /// ("ES256K" for secp256k1, "ES256" for P-256). 172 + pub fn jwt_alg(&self) -> &'static str { 173 + match self { 174 + AnySigningKey::K256(_) => "ES256K", 175 + AnySigningKey::P256(_) => "ES256", 176 + } 177 + } 178 + 179 + /// Signs the SHA-256 prehash of `msg` and returns the signature in 180 + /// low-s normalized form. 181 + /// 182 + /// The returned `AnySignature` is guaranteed to satisfy 183 + /// `AnyVerifyingKey::verify_prehash` against the corresponding 184 + /// verifying key when given the same prehash bytes. 185 + pub fn sign(&self, msg: &[u8]) -> AnySignature { 186 + use sha2::{Digest, Sha256}; 187 + let prehash: [u8; 32] = Sha256::digest(msg).into(); 188 + self.sign_prehash(&prehash) 189 + } 190 + 191 + /// Signs a precomputed 32-byte SHA-256 prehash directly. 192 + pub fn sign_prehash(&self, prehash: &[u8; 32]) -> AnySignature { 193 + use k256::ecdsa::signature::hazmat::PrehashSigner as K256PrehashSigner; 194 + use p256::ecdsa::signature::hazmat::PrehashSigner as P256PrehashSigner; 195 + match self { 196 + AnySigningKey::K256(k) => { 197 + // k256's sign_prehash already returns a low-s normalized 198 + // signature (BIP-0062 enforcement is built in). Returns an 199 + // ecdsa::Signature. 200 + let sig: k256::ecdsa::Signature = K256PrehashSigner::sign_prehash(k, prehash) 201 + .expect("SHA-256 output is always 32 bytes"); 202 + AnySignature::K256(sig) 203 + } 204 + AnySigningKey::P256(k) => { 205 + // p256's sign_prehash may return a high-s signature; 206 + // normalize explicitly. 207 + let sig: p256::ecdsa::Signature = P256PrehashSigner::sign_prehash(k, prehash) 208 + .expect("SHA-256 output is always 32 bytes"); 209 + let normalized = sig.normalize_s().unwrap_or(sig); 210 + AnySignature::P256(normalized) 211 + } 212 + } 213 + } 214 + } 215 + 148 216 /// A signature that may be one of several supported curves. 149 217 #[derive(Debug, Clone)] 150 218 pub enum AnySignature { ··· 152 220 K256(k256::ecdsa::Signature), 153 221 /// P-256 signature. 154 222 P256(p256::ecdsa::Signature), 223 + } 224 + 225 + impl AnySignature { 226 + /// Serializes the signature bytes for JWS compact form: raw `r || s` 227 + /// big-endian concatenation (NOT DER). 228 + /// 229 + /// For both ES256 and ES256K this is a 64-byte fixed-length array. 230 + pub fn to_jws_bytes(&self) -> [u8; 64] { 231 + match self { 232 + AnySignature::K256(s) => s.to_bytes().into(), 233 + AnySignature::P256(s) => s.to_bytes().into(), 234 + } 235 + } 155 236 } 156 237 157 238 /// Error from signature verification across multiple curves. ··· 696 777 } 697 778 } 698 779 780 + /// Encode an `AnyVerifyingKey` as the atproto multibase-multikey format: 781 + /// base58btc multibase prefix `z`, multicodec curve prefix, compressed SEC1 782 + /// public key bytes. 783 + /// 784 + /// See <https://atproto.com/specs/cryptography>. The inverse of `parse_multikey`. 785 + pub fn encode_multikey(key: &AnyVerifyingKey) -> String { 786 + // Multicodec varint prefixes (see https://github.com/multiformats/multicodec). 787 + const SECP256K1_PUB: &[u8] = &[0xe7, 0x01]; 788 + const P256_PUB: &[u8] = &[0x80, 0x24]; 789 + 790 + let (prefix, compressed): (&[u8], Vec<u8>) = match key { 791 + AnyVerifyingKey::K256(k) => { 792 + let point = k.to_encoded_point(true); 793 + (SECP256K1_PUB, point.as_bytes().to_vec()) 794 + } 795 + AnyVerifyingKey::P256(k) => { 796 + let point = k.to_encoded_point(true); 797 + (P256_PUB, point.as_bytes().to_vec()) 798 + } 799 + }; 800 + 801 + let mut buf = Vec::with_capacity(prefix.len() + compressed.len()); 802 + buf.extend_from_slice(prefix); 803 + buf.extend_from_slice(&compressed); 804 + multibase::encode(multibase::Base::Base58Btc, &buf) 805 + } 806 + 699 807 /// A historic key entry from a PLC audit log for a given verification method fragment. 700 808 #[derive(Debug, Clone, PartialEq, Eq)] 701 809 pub struct PlcHistoricKey { ··· 826 934 Ok(decoded.to_string()) 827 935 } 828 936 937 + /// Classify a URL's hostname as "locally reachable from the tool's 938 + /// machine" for the purposes of self-mint `did:web` viability. 939 + /// 940 + /// Returns `true` when the hostname is one of: 941 + /// - `localhost` (case-insensitive) 942 + /// - `127.0.0.1` (or any IPv4 loopback / `::1`) 943 + /// - Any `.local` mDNS suffix (case-insensitive) 944 + /// - Any RFC 1918 IPv4 private address (10/8, 172.16/12, 192.168/16) 945 + /// 946 + /// Returns `false` for all other hostnames. IPv6 private ranges (fc00::/7, 947 + /// link-local) are deliberately NOT classified as local in v1; revisit if 948 + /// users report issues. 949 + pub fn is_local_labeler_hostname(url: &Url) -> bool { 950 + use url::Host; 951 + 952 + let host = match url.host() { 953 + Some(h) => h, 954 + None => return false, 955 + }; 956 + 957 + match host { 958 + Host::Ipv4(addr) => addr.is_loopback() || addr.is_private(), 959 + Host::Ipv6(addr) => addr.is_loopback(), 960 + Host::Domain(domain) => { 961 + let lower = domain.to_ascii_lowercase(); 962 + if lower == "localhost" { 963 + return true; 964 + } 965 + if lower.ends_with(".local") { 966 + return true; 967 + } 968 + false 969 + } 970 + } 971 + } 972 + 829 973 #[cfg(test)] 830 974 mod tests { 831 975 use super::*; 832 976 use k256::ecdsa::SigningKey as K256SigningKey; 833 977 use k256::ecdsa::signature::hazmat::PrehashSigner; 834 978 use p256::ecdsa::SigningKey as P256SigningKey; 979 + use sha2::Digest; 835 980 use std::collections::HashMap; 836 981 837 982 /// Response variant for FakeHttpClient. ··· 1413 1558 assert_eq!(body, "Transport error: connection refused"); 1414 1559 } 1415 1560 e => panic!("Expected DidResolutionFailed with status 0, got {e:?}"), 1561 + } 1562 + } 1563 + 1564 + #[test] 1565 + fn any_signing_key_k256_round_trip() { 1566 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 1567 + let vkey = key.verifying_key(); 1568 + let msg = b"test message"; 1569 + let sig = key.sign(msg); 1570 + assert!(vkey.verify_prehash(&[0u8; 32], &sig).is_err()); // Wrong prehash should fail. 1571 + 1572 + // Sign the same message and verify. 1573 + let sig2 = key.sign(msg); 1574 + let hash: [u8; 32] = sha2::Sha256::digest(msg).into(); 1575 + assert!(vkey.verify_prehash(&hash, &sig2).is_ok()); 1576 + } 1577 + 1578 + #[test] 1579 + fn any_signing_key_p256_round_trip() { 1580 + let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 1581 + let vkey = key.verifying_key(); 1582 + let msg = b"test message"; 1583 + let sig = key.sign(msg); 1584 + let hash: [u8; 32] = sha2::Sha256::digest(msg).into(); 1585 + assert!(vkey.verify_prehash(&hash, &sig).is_ok()); 1586 + } 1587 + 1588 + #[test] 1589 + fn any_signing_key_jwt_alg() { 1590 + let k256_key = 1591 + AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 1592 + let p256_key = 1593 + AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 1594 + 1595 + assert_eq!(k256_key.jwt_alg(), "ES256K"); 1596 + assert_eq!(p256_key.jwt_alg(), "ES256"); 1597 + } 1598 + 1599 + #[test] 1600 + fn any_signature_to_jws_bytes() { 1601 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 1602 + let msg = b"test"; 1603 + let sig = key.sign(msg); 1604 + let jws_bytes = sig.to_jws_bytes(); 1605 + assert_eq!(jws_bytes.len(), 64); 1606 + } 1607 + 1608 + #[test] 1609 + fn any_signing_key_p256_signature_is_normalized() { 1610 + // Test that P256 signatures produced by AnySigningKey::sign are normalized to low-s. 1611 + let key = AnySigningKey::P256(P256SigningKey::from_slice(&[3u8; 32]).expect("valid seed")); 1612 + let msg = b"test message for normalization"; 1613 + let vkey = key.verifying_key(); 1614 + 1615 + // Sign and get the signature. 1616 + let sig = key.sign(msg); 1617 + 1618 + // Explicitly verify that the signature is low-s (normalized). 1619 + if let AnySignature::P256(sig_p256) = &sig { 1620 + assert!( 1621 + sig_p256.normalize_s().is_none(), 1622 + "signature should already be low-s (further normalization should return None)" 1623 + ); 1624 + } else { 1625 + unreachable!("signing with P256 key must produce P256 signature"); 1626 + } 1627 + 1628 + // Also verify that signature verifies correctly. 1629 + use sha2::Digest as _; 1630 + let hash: [u8; 32] = sha2::Sha256::digest(msg).into(); 1631 + assert!( 1632 + vkey.verify_prehash(&hash, &sig).is_ok(), 1633 + "P256 signature should verify after normalization" 1634 + ); 1635 + 1636 + // Also verify that to_jws_bytes produces a 64-byte result. 1637 + let sig_bytes = sig.to_jws_bytes(); 1638 + assert_eq!( 1639 + sig_bytes.len(), 1640 + 64, 1641 + "P256 signature should be 64 bytes after JWS serialization" 1642 + ); 1643 + } 1644 + 1645 + #[test] 1646 + fn is_local_labeler_hostname_classifies_expected_hosts() { 1647 + let cases: &[(&str, bool)] = &[ 1648 + // Positive: localhost variants. 1649 + ("http://localhost/", true), 1650 + ("https://LOCALHOST:8080/foo", true), 1651 + ("http://127.0.0.1/", true), 1652 + ("http://127.1.2.3/", true), 1653 + ("http://[::1]/", true), 1654 + // Positive: .local mDNS. 1655 + ("http://mybox.local/", true), 1656 + ("https://mybox.LOCAL:8443/", true), 1657 + // Positive: RFC 1918. 1658 + ("http://10.0.0.1/", true), 1659 + ("http://172.16.0.1/", true), 1660 + ("http://172.31.255.255/", true), 1661 + ("http://192.168.1.100/", true), 1662 + // Negative: public. 1663 + ("https://labeler.example.com/", false), 1664 + ("http://8.8.8.8/", false), 1665 + ("http://172.15.0.1/", false), // outside 172.16/12 1666 + ("http://172.32.0.1/", false), // outside 172.16/12 1667 + ("http://11.0.0.1/", false), // outside 10/8 once we pass 10.x 1668 + ("http://172.17.1.1/", true), // inside 172.16/12 1669 + ]; 1670 + for (url, expected) in cases { 1671 + let parsed = Url::parse(url).expect("test URLs are valid"); 1672 + assert_eq!( 1673 + is_local_labeler_hostname(&parsed), 1674 + *expected, 1675 + "classification mismatch for {url}" 1676 + ); 1677 + } 1678 + } 1679 + 1680 + #[test] 1681 + fn encode_multikey_round_trip_k256() { 1682 + // Create a random k256 signing key, encode its public key, then 1683 + // decode it back and verify the keys match. 1684 + let signing_key = AnySigningKey::K256(k256::ecdsa::SigningKey::random( 1685 + &mut k256::elliptic_curve::rand_core::OsRng, 1686 + )); 1687 + let original_verifying = signing_key.verifying_key(); 1688 + 1689 + // Encode to multikey format. 1690 + let encoded = encode_multikey(&original_verifying); 1691 + assert!( 1692 + encoded.starts_with('z'), 1693 + "multikey should start with 'z' (base58btc)" 1694 + ); 1695 + 1696 + // Decode back and verify. 1697 + let parsed = parse_multikey(&encoded).expect("encoded multikey should parse"); 1698 + match (&original_verifying, &parsed.verifying_key) { 1699 + (AnyVerifyingKey::K256(original), AnyVerifyingKey::K256(decoded)) => { 1700 + let orig_bytes = original.to_sec1_bytes(); 1701 + let decoded_bytes = decoded.to_sec1_bytes(); 1702 + assert_eq!( 1703 + orig_bytes, decoded_bytes, 1704 + "k256 keys should match after round-trip" 1705 + ); 1706 + } 1707 + _ => panic!("Expected K256 keys"), 1708 + } 1709 + } 1710 + 1711 + #[test] 1712 + fn encode_multikey_round_trip_p256() { 1713 + // Create a random p256 signing key, encode its public key, then 1714 + // decode it back and verify the keys match. 1715 + let signing_key = AnySigningKey::P256(p256::ecdsa::SigningKey::random( 1716 + &mut p256::elliptic_curve::rand_core::OsRng, 1717 + )); 1718 + let original_verifying = signing_key.verifying_key(); 1719 + 1720 + // Encode to multikey format. 1721 + let encoded = encode_multikey(&original_verifying); 1722 + assert!( 1723 + encoded.starts_with('z'), 1724 + "multikey should start with 'z' (base58btc)" 1725 + ); 1726 + 1727 + // Decode back and verify. 1728 + let parsed = parse_multikey(&encoded).expect("encoded multikey should parse"); 1729 + match (&original_verifying, &parsed.verifying_key) { 1730 + (AnyVerifyingKey::P256(original), AnyVerifyingKey::P256(decoded)) => { 1731 + let orig_bytes = original.to_encoded_point(true).as_bytes().to_vec(); 1732 + let decoded_bytes = decoded.to_encoded_point(true).as_bytes().to_vec(); 1733 + assert_eq!( 1734 + orig_bytes, decoded_bytes, 1735 + "p256 keys should match after round-trip" 1736 + ); 1737 + } 1738 + _ => panic!("Expected P256 keys"), 1416 1739 } 1417 1740 } 1418 1741 }
+443
src/common/jwt.rs
··· 1 + //! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for 2 + //! atproto service-auth. 3 + //! 4 + //! This module exists to avoid pulling a full JWT library for a handful of 5 + //! tightly-scoped use cases: minting self-mint JWTs for labeler conformance 6 + //! tests, and decoding them in tests to verify round-trip correctness. 7 + //! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 8 + //! encoding, unpadded base64url segments, UTF-8 JSON payloads. 9 + 10 + use base64::Engine; 11 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 12 + use serde::{Deserialize, Serialize}; 13 + use thiserror::Error; 14 + 15 + use crate::common::identity::{AnySignature, AnySignatureError, AnySigningKey, AnyVerifyingKey}; 16 + 17 + /// Compact JWS header for atproto service-auth tokens. 18 + /// 19 + /// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT 20 + /// rename without updating `serde` attributes — atproto wire format 21 + /// requires exactly `alg` and `typ`. 22 + #[derive(Debug, Clone, Serialize, Deserialize)] 23 + pub struct JwtHeader { 24 + /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256). 25 + pub alg: String, 26 + /// Token type; always "JWT". 27 + pub typ: String, 28 + } 29 + 30 + impl JwtHeader { 31 + /// Build a header for the given signing key, setting `alg` to match the 32 + /// curve and `typ` to `"JWT"`. 33 + pub fn for_signing_key(key: &AnySigningKey) -> Self { 34 + Self { 35 + alg: key.jwt_alg().to_string(), 36 + typ: "JWT".to_string(), 37 + } 38 + } 39 + } 40 + 41 + /// Atproto service-auth JWT claims. 42 + /// 43 + /// Fields match the atproto inter-service authentication spec: 44 + /// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is 45 + /// deliberately omitted — the spec does not require it and some servers 46 + /// reject unexpected claims. 47 + /// 48 + /// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`, 49 + /// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT 50 + /// rename without adding `#[serde(rename = "...")]` attributes. 51 + #[derive(Debug, Clone, Serialize, Deserialize)] 52 + pub struct JwtClaims { 53 + /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`). 54 + pub iss: String, 55 + /// Audience — the target service's DID, bare (no `#fragment`). 56 + pub aud: String, 57 + /// Expiration, UNIX seconds. 58 + pub exp: i64, 59 + /// Issued-at, UNIX seconds. 60 + pub iat: i64, 61 + /// Lexicon method NSID the token authorizes (e.g., 62 + /// `com.atproto.moderation.createReport`). 63 + pub lxm: String, 64 + /// Random nonce to prevent replay — hex string, 32 chars (16 bytes). 65 + pub jti: String, 66 + } 67 + 68 + /// Errors from JWT encode/decode. 69 + /// 70 + /// **Not user-rendered:** these errors only surface inside tests and 71 + /// library helpers. They deliberately do NOT derive `miette::Diagnostic` 72 + /// with stable codes — the stage converts any failure into a 73 + /// `CreateReportStageError::Transport` or a specific check SpecViolation 74 + /// before rendering. If a future caller needs one of these variants 75 + /// rendered to the user, they must wrap it in a stage-local diagnostic 76 + /// with a proper `code = "labeler::..."` string. 77 + #[derive(Debug, Error)] 78 + pub enum JwtError { 79 + /// Compact form was not three `.`-separated base64url segments. 80 + #[error("malformed compact JWT: expected three segments")] 81 + MalformedCompact, 82 + /// A base64url segment failed to decode. 83 + #[error("base64url decode failed for {segment}")] 84 + Base64Decode { 85 + /// Which segment failed: "header", "claims", or "signature". 86 + segment: &'static str, 87 + /// Underlying base64 error. 88 + #[source] 89 + source: base64::DecodeError, 90 + }, 91 + /// A segment decoded to valid bytes but invalid JSON. 92 + #[error("JSON decode failed for {segment}")] 93 + JsonDecode { 94 + /// Which segment failed: "header" or "claims". 95 + segment: &'static str, 96 + /// Underlying serde_json error. 97 + #[source] 98 + source: serde_json::Error, 99 + }, 100 + /// JSON serialization of header or claims failed (should not happen for 101 + /// well-formed structs). 102 + #[error("JSON encode failed")] 103 + JsonEncode(serde_json::Error), 104 + /// Signature was not exactly 64 bytes. 105 + #[error("signature was {actual} bytes; expected 64")] 106 + SignatureLength { 107 + /// Actual length in bytes. 108 + actual: usize, 109 + }, 110 + /// Signature had the correct length but invalid scalar values (e.g., r or s 111 + /// is 0 or exceeds the curve order). 112 + #[error("signature has invalid scalar values")] 113 + InvalidSignatureScalar, 114 + /// The algorithm identifier in the header is not recognized. 115 + #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")] 116 + UnsupportedAlg { 117 + /// The unrecognized algorithm string. 118 + alg: String, 119 + }, 120 + /// Underlying ECDSA verification failure (e.g., curve mismatch). 121 + #[error("signature verification failed")] 122 + SignatureVerify(#[from] AnySignatureError), 123 + } 124 + 125 + /// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`. 126 + /// 127 + /// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 128 + /// prehash under the supplied key. Returns the full compact token string. 129 + pub fn encode_compact( 130 + header: &JwtHeader, 131 + claims: &JwtClaims, 132 + signer: &AnySigningKey, 133 + ) -> Result<String, JwtError> { 134 + let header_json = serde_json::to_vec(header).map_err(JwtError::JsonEncode)?; 135 + let claims_json = serde_json::to_vec(claims).map_err(JwtError::JsonEncode)?; 136 + let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); 137 + let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json); 138 + let signing_input = format!("{header_b64}.{claims_b64}"); 139 + let sig = signer.sign(signing_input.as_bytes()); 140 + let sig_bytes = sig.to_jws_bytes(); 141 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); 142 + Ok(format!("{header_b64}.{claims_b64}.{sig_b64}")) 143 + } 144 + 145 + /// Decode a compact JWT into `(header, claims, signature_bytes)`. 146 + /// 147 + /// Does NOT verify the signature — use `verify_compact` for that. This helper 148 + /// is primarily for test round-tripping and for negative-test assertions 149 + /// (e.g., "the minted token has the expected `alg` header"). 150 + pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 151 + let parts: Vec<&str> = token.split('.').collect(); 152 + if parts.len() != 3 { 153 + return Err(JwtError::MalformedCompact); 154 + } 155 + let header_b64 = parts[0]; 156 + let claims_b64 = parts[1]; 157 + let sig_b64 = parts[2]; 158 + let header_bytes = 159 + URL_SAFE_NO_PAD 160 + .decode(header_b64) 161 + .map_err(|source| JwtError::Base64Decode { 162 + segment: "header", 163 + source, 164 + })?; 165 + let claims_bytes = 166 + URL_SAFE_NO_PAD 167 + .decode(claims_b64) 168 + .map_err(|source| JwtError::Base64Decode { 169 + segment: "claims", 170 + source, 171 + })?; 172 + let sig_bytes = URL_SAFE_NO_PAD 173 + .decode(sig_b64) 174 + .map_err(|source| JwtError::Base64Decode { 175 + segment: "signature", 176 + source, 177 + })?; 178 + let header: JwtHeader = 179 + serde_json::from_slice(&header_bytes).map_err(|source| JwtError::JsonDecode { 180 + segment: "header", 181 + source, 182 + })?; 183 + let claims: JwtClaims = 184 + serde_json::from_slice(&claims_bytes).map_err(|source| JwtError::JsonDecode { 185 + segment: "claims", 186 + source, 187 + })?; 188 + Ok((header, claims, sig_bytes)) 189 + } 190 + 191 + /// Verify a compact JWT against the given verifying key. Does NOT check 192 + /// claim values (exp/aud/lxm) — that is the labeler's job in production, 193 + /// or the stage's assertion job in tests. Only verifies the signature. 194 + pub fn verify_compact( 195 + token: &str, 196 + vkey: &AnyVerifyingKey, 197 + ) -> Result<(JwtHeader, JwtClaims), JwtError> { 198 + let (header, claims, sig_bytes) = decode_compact(token)?; 199 + let expected_alg = match vkey { 200 + AnyVerifyingKey::K256(_) => "ES256K", 201 + AnyVerifyingKey::P256(_) => "ES256", 202 + }; 203 + if header.alg != expected_alg { 204 + return Err(JwtError::UnsupportedAlg { 205 + alg: header.alg.clone(), 206 + }); 207 + } 208 + if sig_bytes.len() != 64 { 209 + return Err(JwtError::SignatureLength { 210 + actual: sig_bytes.len(), 211 + }); 212 + } 213 + let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above"); 214 + let any_sig = match vkey { 215 + AnyVerifyingKey::K256(_) => { 216 + let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into()) 217 + .map_err(|_| JwtError::InvalidSignatureScalar)?; 218 + AnySignature::K256(sig) 219 + } 220 + AnyVerifyingKey::P256(_) => { 221 + let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into()) 222 + .map_err(|_| JwtError::InvalidSignatureScalar)?; 223 + AnySignature::P256(sig) 224 + } 225 + }; 226 + // Recompute the signing input and verify. 227 + let dot = token 228 + .rfind('.') 229 + .expect("three-segment token has a last dot"); 230 + let signing_input = &token[..dot]; 231 + use sha2::{Digest, Sha256}; 232 + let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into(); 233 + vkey.verify_prehash(&prehash, &any_sig)?; 234 + Ok((header, claims)) 235 + } 236 + 237 + #[cfg(test)] 238 + mod tests { 239 + use super::*; 240 + use k256::ecdsa::SigningKey as K256SigningKey; 241 + use p256::ecdsa::SigningKey as P256SigningKey; 242 + 243 + #[test] 244 + fn encode_decode_roundtrip_k256() { 245 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 246 + let vkey = key.verifying_key(); 247 + let header = JwtHeader::for_signing_key(&key); 248 + let claims = JwtClaims { 249 + iss: "did:web:127.0.0.1%3A5000".to_string(), 250 + aud: "did:plc:test".to_string(), 251 + exp: 2000000000, 252 + iat: 1700000000, 253 + lxm: "com.atproto.moderation.createReport".to_string(), 254 + jti: "0123456789abcdef".to_string(), 255 + }; 256 + 257 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 258 + let (decoded_header, decoded_claims) = 259 + verify_compact(&token, &vkey).expect("verify succeeds"); 260 + 261 + assert_eq!(decoded_header.alg, "ES256K"); 262 + assert_eq!(decoded_claims.iss, claims.iss); 263 + assert_eq!(decoded_claims.aud, claims.aud); 264 + } 265 + 266 + #[test] 267 + fn encode_decode_roundtrip_p256() { 268 + let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 269 + let vkey = key.verifying_key(); 270 + let header = JwtHeader::for_signing_key(&key); 271 + let claims = JwtClaims { 272 + iss: "did:web:example.com".to_string(), 273 + aud: "did:plc:test".to_string(), 274 + exp: 2000000000, 275 + iat: 1700000000, 276 + lxm: "com.atproto.moderation.createReport".to_string(), 277 + jti: "fedcba9876543210".to_string(), 278 + }; 279 + 280 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 281 + let (decoded_header, decoded_claims) = 282 + verify_compact(&token, &vkey).expect("verify succeeds"); 283 + 284 + assert_eq!(decoded_header.alg, "ES256"); 285 + assert_eq!(decoded_claims.aud, claims.aud); 286 + } 287 + 288 + #[test] 289 + fn encode_decode_roundtrip_tampered_claims_fails() { 290 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 291 + let vkey = key.verifying_key(); 292 + let header = JwtHeader::for_signing_key(&key); 293 + let claims = JwtClaims { 294 + iss: "did:web:127.0.0.1%3A5000".to_string(), 295 + aud: "did:plc:test".to_string(), 296 + exp: 2000000000, 297 + iat: 1700000000, 298 + lxm: "com.atproto.moderation.createReport".to_string(), 299 + jti: "0123456789abcdef".to_string(), 300 + }; 301 + 302 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 303 + let parts: Vec<&str> = token.split('.').collect(); 304 + assert_eq!(parts.len(), 3); 305 + 306 + // Tamper with claims segment. 307 + let tampered = format!("{}.YWJj.{}", parts[0], parts[2]); 308 + let result = verify_compact(&tampered, &vkey); 309 + assert!(result.is_err()); 310 + } 311 + 312 + #[test] 313 + fn decode_compact_malformed_two_segments() { 314 + let result = decode_compact("header.claims"); 315 + assert!(matches!(result, Err(JwtError::MalformedCompact))); 316 + } 317 + 318 + #[test] 319 + fn decode_compact_malformed_four_segments() { 320 + // Tokens with four or more segments are malformed. 321 + let result = decode_compact("YQ.Yg.Yw.ZA"); 322 + assert!(matches!(result, Err(JwtError::MalformedCompact))); 323 + } 324 + 325 + #[test] 326 + fn decode_compact_invalid_base64() { 327 + let result = decode_compact("!!!.claims.sig"); 328 + assert!(matches!( 329 + result, 330 + Err(JwtError::Base64Decode { 331 + segment: "header", 332 + .. 333 + }) 334 + )); 335 + } 336 + 337 + #[test] 338 + fn verify_compact_curve_mismatch() { 339 + let k256_key = 340 + AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 341 + let p256_key = 342 + AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 343 + 344 + let header = JwtHeader::for_signing_key(&k256_key); 345 + let claims = JwtClaims { 346 + iss: "did:web:test".to_string(), 347 + aud: "did:plc:test".to_string(), 348 + exp: 2000000000, 349 + iat: 1700000000, 350 + lxm: "com.atproto.moderation.createReport".to_string(), 351 + jti: "0123456789abcdef".to_string(), 352 + }; 353 + 354 + let token = encode_compact(&header, &claims, &k256_key).expect("encode succeeds"); 355 + let p256_vkey = p256_key.verifying_key(); 356 + 357 + // Trying to verify a K256-signed token with a P256 key should fail. 358 + let result = verify_compact(&token, &p256_vkey); 359 + assert!(result.is_err()); 360 + } 361 + 362 + #[test] 363 + fn encode_compact_produces_valid_structure() { 364 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 365 + let header = JwtHeader::for_signing_key(&key); 366 + let claims = JwtClaims { 367 + iss: "did:web:test".to_string(), 368 + aud: "did:plc:test".to_string(), 369 + exp: 2000000000, 370 + iat: 1700000000, 371 + lxm: "com.atproto.moderation.createReport".to_string(), 372 + jti: "0123456789abcdef".to_string(), 373 + }; 374 + 375 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 376 + 377 + // Token must have exactly 3 segments. 378 + let parts: Vec<&str> = token.split('.').collect(); 379 + assert_eq!(parts.len(), 3); 380 + 381 + // Each segment must decode as valid base64url. 382 + for (i, segment) in parts.iter().enumerate() { 383 + let segment_name = ["header", "claims", "signature"][i]; 384 + let result = URL_SAFE_NO_PAD.decode(segment); 385 + assert!( 386 + result.is_ok(), 387 + "segment {segment_name} failed to decode as base64url" 388 + ); 389 + } 390 + } 391 + 392 + #[test] 393 + fn verify_compact_invalid_signature_scalar_k256() { 394 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 395 + let vkey = key.verifying_key(); 396 + let header = JwtHeader::for_signing_key(&key); 397 + let claims = JwtClaims { 398 + iss: "did:web:127.0.0.1%3A5000".to_string(), 399 + aud: "did:plc:test".to_string(), 400 + exp: 2000000000, 401 + iat: 1700000000, 402 + lxm: "com.atproto.moderation.createReport".to_string(), 403 + jti: "0123456789abcdef".to_string(), 404 + }; 405 + 406 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 407 + let parts: Vec<&str> = token.split('.').collect(); 408 + assert_eq!(parts.len(), 3); 409 + 410 + // Replace the signature with all zeros (64 bytes, base64url-encoded). 411 + let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]); 412 + let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig); 413 + 414 + let result = verify_compact(&tampered, &vkey); 415 + assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 416 + } 417 + 418 + #[test] 419 + fn verify_compact_invalid_signature_scalar_p256() { 420 + let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 421 + let vkey = key.verifying_key(); 422 + let header = JwtHeader::for_signing_key(&key); 423 + let claims = JwtClaims { 424 + iss: "did:web:example.com".to_string(), 425 + aud: "did:plc:test".to_string(), 426 + exp: 2000000000, 427 + iat: 1700000000, 428 + lxm: "com.atproto.moderation.createReport".to_string(), 429 + jti: "fedcba9876543210".to_string(), 430 + }; 431 + 432 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 433 + let parts: Vec<&str> = token.split('.').collect(); 434 + assert_eq!(parts.len(), 3); 435 + 436 + // Replace the signature with all zeros (64 bytes, base64url-encoded). 437 + let zero_sig = URL_SAFE_NO_PAD.encode([0u8; 64]); 438 + let tampered = format!("{}.{}.{}", parts[0], parts[1], zero_sig); 439 + 440 + let result = verify_compact(&tampered, &vkey); 441 + assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 442 + } 443 + }
+6 -5
src/common/report.rs
··· 86 86 /// This is a newtype over a `&'static str` label to allow the Stage space 87 87 /// to be extended across commands without coupling through a shared enum. 88 88 /// The labeler pipeline records results stage-contiguously (identity, then 89 - /// HTTP, then subscription, then crypto), so insertion order == stage order 90 - /// and the render loop's change-detecting `current_stage: Option<Stage>` 91 - /// equality check is sufficient to group results. If a future refactor ever 92 - /// interleaves results across stages, the render loop will need an explicit 93 - /// stage-order-preserving grouping pass. 89 + /// HTTP, then subscription, then crypto, then report), so insertion order == 90 + /// stage order and the render loop's change-detecting 91 + /// `current_stage: Option<Stage>` equality check is sufficient to group 92 + /// results. If a future refactor ever interleaves results across stages, the 93 + /// render loop will need an explicit stage-order-preserving grouping pass. 94 94 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 95 95 pub struct Stage(pub &'static str); 96 96 ··· 105 105 pub const HTTP: Stage = Stage("HTTP"); 106 106 pub const SUBSCRIPTION: Stage = Stage("Subscription"); 107 107 pub const CRYPTO: Stage = Stage("Crypto"); 108 + pub const REPORT: Stage = Stage("Report"); 108 109 109 110 // OAuth client stages. Consumed by Phase 3+. 110 111 pub const DISCOVERY: Stage = Stage("Discovery");
+331 -9
tests/common/mod.rs
··· 8 8 #![allow(dead_code)] 9 9 10 10 use async_trait::async_trait; 11 + use atproto_devtool::commands::test::labeler::create_report::{ 12 + CreateReportStageError, CreateReportTee, PdsXrpcClient, RawCreateReportResponse, 13 + RawPdsXrpcResponse, 14 + }; 11 15 use atproto_devtool::commands::test::labeler::http::{HttpStageError, RawHttpTee, RawXrpcResponse}; 12 16 use atproto_devtool::commands::test::labeler::subscription::{ 13 17 FrameStream, SubscriptionStageError, WebSocketClient, ··· 17 21 }; 18 22 use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 19 23 use atproto_devtool::common::oauth::clock::Clock; 24 + use reqwest::StatusCode; 20 25 use std::collections::{HashMap, HashSet}; 21 26 use std::sync::{Arc, Mutex}; 22 27 use std::time::Duration; ··· 267 272 } 268 273 } 269 274 275 + /// Scripted response for a single `FakeCreateReportTee::post_create_report` 276 + /// call. A `Transport` variant short-circuits with an error; a `Response` 277 + /// variant returns a `RawCreateReportResponse` built from the supplied parts. 278 + #[derive(Debug, Clone)] 279 + pub enum FakeCreateReportResponse { 280 + /// Simulate a transport-level failure (no HTTP exchange took place). 281 + Transport { 282 + /// Error message the stage will surface. 283 + message: String, 284 + }, 285 + /// Simulate a well-formed HTTP response. 286 + Response { 287 + /// HTTP status (200, 401, 400, 500, ...). 288 + status: u16, 289 + /// Optional content-type header. Fake normalizes to lowercase. 290 + content_type: Option<String>, 291 + /// Raw response body bytes. 292 + body: Vec<u8>, 293 + }, 294 + } 295 + 296 + impl FakeCreateReportResponse { 297 + /// Convenience: a 200 OK with an empty atproto createReport#output body. 298 + pub fn ok_empty() -> Self { 299 + Self::Response { 300 + status: 200, 301 + content_type: Some("application/json".to_string()), 302 + body: br#"{"id":1,"reasonType":"com.atproto.moderation.defs#reasonOther","subject":{"$type":"com.atproto.admin.defs#repoRef","did":"did:plc:aaa22222222222222222bbbbbb"},"reportedBy":"did:web:127.0.0.1%3A0","createdAt":"2026-04-17T00:00:00.000Z"}"#.to_vec(), 303 + } 304 + } 305 + 306 + /// Convenience: a 401 Unauthorized with the atproto error envelope. 307 + pub fn unauthorized(error_name: &str, message: &str) -> Self { 308 + Self::Response { 309 + status: 401, 310 + content_type: Some("application/json".to_string()), 311 + body: serde_json::to_vec(&serde_json::json!({ 312 + "error": error_name, 313 + "message": message, 314 + })) 315 + .unwrap(), 316 + } 317 + } 318 + 319 + /// Convenience: a 400 Bad Request with the given error and message. 320 + pub fn bad_request(error_name: &str, message: &str) -> Self { 321 + Self::Response { 322 + status: 400, 323 + content_type: Some("application/json".to_string()), 324 + body: serde_json::to_vec(&serde_json::json!({ 325 + "error": error_name, 326 + "message": message, 327 + })) 328 + .unwrap(), 329 + } 330 + } 331 + } 332 + 333 + /// A recorded request observed by `FakeCreateReportTee`. 334 + #[derive(Debug, Clone)] 335 + pub struct RecordedCreateReportRequest { 336 + /// Authorization bearer token, if any (stripped of "Bearer " prefix). 337 + pub auth: Option<String>, 338 + /// JSON body as posted by the stage. 339 + pub body: serde_json::Value, 340 + } 341 + 342 + /// Fake `CreateReportTee` for integration tests. 343 + /// 344 + /// Scripted per-call-index responses: first call gets `responses[0]`, 345 + /// second gets `responses[1]`, etc. Panics if a call is made with no 346 + /// script queued — tests must declare every `post_create_report` the 347 + /// stage is expected to make. 348 + pub struct FakeCreateReportTee { 349 + /// Queued responses. 350 + scripts: Arc<Mutex<Vec<FakeCreateReportResponse>>>, 351 + /// Every request observed (in order). 352 + recorded: Arc<Mutex<Vec<RecordedCreateReportRequest>>>, 353 + } 354 + 355 + impl FakeCreateReportTee { 356 + /// Create a fake with no scripted responses. 357 + pub fn new() -> Self { 358 + Self { 359 + scripts: Arc::new(Mutex::new(Vec::new())), 360 + recorded: Arc::new(Mutex::new(Vec::new())), 361 + } 362 + } 363 + 364 + /// Queue a scripted response for the next `post_create_report` call. 365 + pub fn enqueue(&self, response: FakeCreateReportResponse) { 366 + self.scripts.lock().unwrap().push(response); 367 + } 368 + 369 + /// Return the recorded request history (cloned). 370 + pub fn recorded_requests(&self) -> Vec<RecordedCreateReportRequest> { 371 + self.recorded.lock().unwrap().clone() 372 + } 373 + 374 + /// Get the last recorded request, panicking if none. 375 + pub fn last_request(&self) -> RecordedCreateReportRequest { 376 + self.recorded 377 + .lock() 378 + .unwrap() 379 + .last() 380 + .cloned() 381 + .expect("FakeCreateReportTee: no requests recorded yet") 382 + } 383 + } 384 + 385 + impl Default for FakeCreateReportTee { 386 + fn default() -> Self { 387 + Self::new() 388 + } 389 + } 390 + 391 + #[async_trait] 392 + impl CreateReportTee for FakeCreateReportTee { 393 + async fn post_create_report( 394 + &self, 395 + auth: Option<&str>, 396 + body: &serde_json::Value, 397 + ) -> Result<RawCreateReportResponse, CreateReportStageError> { 398 + self.recorded 399 + .lock() 400 + .unwrap() 401 + .push(RecordedCreateReportRequest { 402 + auth: auth.map(|s| s.to_string()), 403 + body: body.clone(), 404 + }); 405 + 406 + let mut scripts = self.scripts.lock().unwrap(); 407 + if scripts.is_empty() { 408 + panic!( 409 + "FakeCreateReportTee: post_create_report called with no script queued. \ 410 + Each test must enqueue() exactly the responses it expects the stage to consume." 411 + ); 412 + } 413 + let script = scripts.remove(0); 414 + 415 + match script { 416 + FakeCreateReportResponse::Transport { message } => { 417 + Err(CreateReportStageError::Transport { 418 + source: Box::new(std::io::Error::other(message)), 419 + }) 420 + } 421 + FakeCreateReportResponse::Response { 422 + status, 423 + content_type, 424 + body, 425 + } => { 426 + let raw_body: Arc<[u8]> = Arc::from(body.as_slice()); 427 + Ok(RawCreateReportResponse { 428 + status: StatusCode::from_u16(status).expect("test must use valid HTTP status"), 429 + content_type: content_type.map(|s| s.to_ascii_lowercase()), 430 + raw_body, 431 + source_url: "https://labeler.test/xrpc/com.atproto.moderation.createReport" 432 + .to_string(), 433 + }) 434 + } 435 + } 436 + } 437 + } 438 + 439 + /// Scripted response for a single `FakePdsXrpcClient` call. A `Transport` 440 + /// variant short-circuits with an error; a `Response` variant returns a 441 + /// `RawPdsXrpcResponse` built from the supplied parts. 442 + #[derive(Debug, Clone)] 443 + pub enum FakePdsXrpcResponse { 444 + /// Simulate a transport-level failure (no HTTP exchange took place). 445 + Transport { message: String }, 446 + /// Simulate a well-formed HTTP response. 447 + Response { status: u16, body: Vec<u8> }, 448 + } 449 + 450 + /// A recorded request observed by `FakePdsXrpcClient`. 451 + #[derive(Debug, Clone)] 452 + pub struct RecordedPdsRequest { 453 + /// HTTP method: "POST" or "GET". 454 + pub method: &'static str, 455 + /// Request path (e.g., "xrpc/com.atproto.server.createSession"). 456 + pub path: String, 457 + /// Bearer token, if any (stripped of "Bearer " prefix). 458 + pub bearer: Option<String>, 459 + /// atproto-proxy header, if any. 460 + pub atproto_proxy: Option<String>, 461 + /// JSON body for POST requests; None for GET. 462 + pub body: Option<serde_json::Value>, 463 + /// Query parameters for GET requests; empty for POST. 464 + pub query: Vec<(String, String)>, 465 + } 466 + 467 + /// Fake `PdsXrpcClient` for integration tests. 468 + /// 469 + /// Scripted per-call-index responses: first call gets `responses[0]`, 470 + /// second gets `responses[1]`, etc. Panics if a call is made with no 471 + /// script queued — tests must declare every call the stage is expected to make. 472 + pub struct FakePdsXrpcClient { 473 + /// Queued responses. 474 + scripts: Arc<Mutex<Vec<FakePdsXrpcResponse>>>, 475 + /// Every request observed (in order). 476 + recorded: Arc<Mutex<Vec<RecordedPdsRequest>>>, 477 + } 478 + 479 + impl FakePdsXrpcClient { 480 + /// Create a fake with no scripted responses. 481 + pub fn new() -> Self { 482 + Self { 483 + scripts: Arc::new(Mutex::new(Vec::new())), 484 + recorded: Arc::new(Mutex::new(Vec::new())), 485 + } 486 + } 487 + 488 + /// Queue a scripted response for the next call. 489 + pub fn enqueue(&self, response: FakePdsXrpcResponse) { 490 + self.scripts.lock().unwrap().push(response); 491 + } 492 + 493 + /// Return the recorded request history (cloned). 494 + pub fn recorded_requests(&self) -> Vec<RecordedPdsRequest> { 495 + self.recorded.lock().unwrap().clone() 496 + } 497 + 498 + /// Get the last recorded request, panicking if none. 499 + pub fn last_request(&self) -> RecordedPdsRequest { 500 + self.recorded 501 + .lock() 502 + .unwrap() 503 + .last() 504 + .cloned() 505 + .expect("FakePdsXrpcClient: no requests recorded yet") 506 + } 507 + 508 + /// Pop the next script and return the result. Panics if no script queued. 509 + fn dispatch_next(&self) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 510 + let mut scripts = self.scripts.lock().unwrap(); 511 + if scripts.is_empty() { 512 + panic!( 513 + "FakePdsXrpcClient: call made with no script queued. \ 514 + Each test must enqueue() exactly the responses it expects." 515 + ); 516 + } 517 + let script = scripts.remove(0); 518 + 519 + match script { 520 + FakePdsXrpcResponse::Transport { message } => Err(CreateReportStageError::Transport { 521 + source: Box::new(std::io::Error::other(message)), 522 + }), 523 + FakePdsXrpcResponse::Response { status, body } => { 524 + let raw_body: Arc<[u8]> = Arc::from(body.as_slice()); 525 + Ok(RawPdsXrpcResponse { 526 + status: StatusCode::from_u16(status).expect("test must use valid HTTP status"), 527 + raw_body, 528 + content_type: Some("application/json".to_string()), 529 + source_url: "https://pds.test/xrpc".to_string(), 530 + }) 531 + } 532 + } 533 + } 534 + } 535 + 536 + impl Default for FakePdsXrpcClient { 537 + fn default() -> Self { 538 + Self::new() 539 + } 540 + } 541 + 542 + #[async_trait] 543 + impl PdsXrpcClient for FakePdsXrpcClient { 544 + async fn post( 545 + &self, 546 + path: &str, 547 + bearer: Option<&str>, 548 + atproto_proxy: Option<&str>, 549 + body: &serde_json::Value, 550 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 551 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 552 + method: "POST", 553 + path: path.to_string(), 554 + bearer: bearer.map(String::from), 555 + atproto_proxy: atproto_proxy.map(String::from), 556 + body: Some(body.clone()), 557 + query: Vec::new(), 558 + }); 559 + self.dispatch_next() 560 + } 561 + 562 + async fn get( 563 + &self, 564 + path: &str, 565 + bearer: Option<&str>, 566 + query: &[(&str, &str)], 567 + ) -> Result<RawPdsXrpcResponse, CreateReportStageError> { 568 + self.recorded.lock().unwrap().push(RecordedPdsRequest { 569 + method: "GET", 570 + path: path.to_string(), 571 + bearer: bearer.map(String::from), 572 + atproto_proxy: None, 573 + body: None, 574 + query: query 575 + .iter() 576 + .map(|(k, v)| (k.to_string(), v.to_string())) 577 + .collect(), 578 + }); 579 + self.dispatch_next() 580 + } 581 + } 582 + 270 583 /// Fake WebSocket client for testing subscription stage with scripted responses. 271 584 /// 272 585 /// Two construction styles are supported: ··· 436 749 } 437 750 } 438 751 439 - /// Helper to normalize timing in rendered reports for snapshot testing. 440 - pub(crate) fn normalize_timing(rendered: String) -> String { 441 - // Replace patterns like "elapsed: 1ms" with "elapsed: Xms" for consistent snapshots. 442 - let mut result = rendered.clone(); 443 - let start = result.find("elapsed: "); 444 - if let Some(pos) = start { 445 - let remaining = &result[pos + 9..]; 446 - if let Some(end) = remaining.find("ms") { 447 - result.replace_range(pos + 9..pos + 9 + end, "X"); 752 + /// Helper to normalize elapsed time in snapshots. 753 + /// 754 + /// Replaces `elapsed: <N>ms` with `elapsed: XXms`, advancing past each match 755 + /// to avoid re-matching the replacement on the next iteration. 756 + pub fn normalize_timing(rendered: String) -> String { 757 + let mut result = String::with_capacity(rendered.len()); 758 + let mut rest = rendered.as_str(); 759 + while let Some(pos) = rest.find("elapsed: ") { 760 + let after = pos + "elapsed: ".len(); 761 + result.push_str(&rest[..after]); 762 + let tail = &rest[after..]; 763 + if let Some(end) = tail.find("ms") { 764 + result.push_str("XXms"); 765 + rest = &tail[end + 2..]; 766 + } else { 767 + result.push_str(tail); 768 + return result; 448 769 } 449 770 } 771 + result.push_str(rest); 450 772 result 451 773 } 452 774
+47
tests/common_fakes.rs
··· 1 + //! Smoke tests for shared fake test helpers. 2 + 3 + mod common; 4 + 5 + use atproto_devtool::commands::test::labeler::create_report::CreateReportTee; 6 + use common::*; 7 + use reqwest::StatusCode; 8 + 9 + #[tokio::test] 10 + async fn fake_create_report_tee_serves_scripted_responses_and_records_requests() { 11 + let fake = FakeCreateReportTee::new(); 12 + fake.enqueue(FakeCreateReportResponse::unauthorized( 13 + "AuthenticationRequired", 14 + "jwt required", 15 + )); 16 + fake.enqueue(FakeCreateReportResponse::ok_empty()); 17 + 18 + let body1 = serde_json::json!({"a": 1}); 19 + let resp1 = fake 20 + .post_create_report(None, &body1) 21 + .await 22 + .expect("fake returns Ok"); 23 + assert_eq!(resp1.status, StatusCode::UNAUTHORIZED); 24 + let envelope: serde_json::Value = serde_json::from_slice(&resp1.raw_body).unwrap(); 25 + assert_eq!(envelope["error"], "AuthenticationRequired"); 26 + 27 + let body2 = serde_json::json!({"b": 2}); 28 + let resp2 = fake 29 + .post_create_report(Some("abc.def.ghi"), &body2) 30 + .await 31 + .expect("fake returns Ok"); 32 + assert_eq!(resp2.status, StatusCode::OK); 33 + 34 + let recorded = fake.recorded_requests(); 35 + assert_eq!(recorded.len(), 2); 36 + assert_eq!(recorded[0].auth, None); 37 + assert_eq!(recorded[0].body, body1); 38 + assert_eq!(recorded[1].auth.as_deref(), Some("abc.def.ghi")); 39 + assert_eq!(recorded[1].body, body2); 40 + } 41 + 42 + #[tokio::test] 43 + #[should_panic(expected = "no script queued")] 44 + async fn fake_create_report_tee_panics_on_unscripted_call() { 45 + let fake = FakeCreateReportTee::new(); 46 + let _ = fake.post_create_report(None, &serde_json::json!({})).await; 47 + }
+25
tests/fixtures/labeler/identity/report_stage_contract_present/labeler_record.json
··· 1 + { 2 + "uri": "at://did:plc:report_stage_test_123456789/app.bsky.labeler.service/self", 3 + "cid": "bafyreihxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 4 + "value": { 5 + "$type": "app.bsky.labeler.service", 6 + "createdAt": "2026-01-01T00:00:00.000Z", 7 + "policies": { 8 + "labelValues": [ 9 + "spam" 10 + ] 11 + }, 12 + "reasonTypes": [ 13 + "com.atproto.moderation.defs#reasonSpam", 14 + "com.atproto.moderation.defs#reasonSuspiciousLink" 15 + ], 16 + "subjectTypes": [ 17 + "account", 18 + "record" 19 + ], 20 + "subjectCollections": [ 21 + "app.bsky.feed.post", 22 + "app.bsky.actor.profile" 23 + ] 24 + } 25 + }
tests/fixtures/labeler/report/.gitkeep

This is a binary file and will not be displayed.

+64
tests/labeler_cli.rs
··· 26 26 "--subscribe-timeout", 27 27 "--verbose", 28 28 "--no-color", 29 + "--commit-report", 30 + "--force-self-mint", 31 + "--self-mint-curve", 32 + "--report-subject-did", 29 33 "<TARGET>", 30 34 ] { 31 35 assert!( ··· 94 98 "Stderr should contain DEBUG tracing output with --verbose, got:\n{stderr}", 95 99 ); 96 100 } 101 + 102 + /// AC8.1: `--handle` without `--app-password` produces a parse error. 103 + #[test] 104 + fn ac8_1_handle_without_app_password_fails() { 105 + let output = Command::cargo_bin("atproto-devtool") 106 + .expect("bin") 107 + .args([ 108 + "test", 109 + "labeler", 110 + "alice.bsky.social", 111 + "--handle", 112 + "alice.bsky.social", 113 + ]) 114 + .output() 115 + .expect("run"); 116 + assert!( 117 + !output.status.success(), 118 + "expected parse failure when --handle supplied without --app-password" 119 + ); 120 + let stderr = String::from_utf8_lossy(&output.stderr); 121 + assert!( 122 + stderr.contains("--app-password") || stderr.contains("app_password"), 123 + "stderr should mention missing --app-password, got: {stderr}" 124 + ); 125 + } 126 + 127 + /// AC8.1: `--app-password` without `--handle` produces a parse error. 128 + #[test] 129 + fn ac8_1_app_password_without_handle_fails() { 130 + let output = Command::cargo_bin("atproto-devtool") 131 + .expect("bin") 132 + .args([ 133 + "test", 134 + "labeler", 135 + "alice.bsky.social", 136 + "--app-password", 137 + "xxxx-xxxx-xxxx-xxxx", 138 + ]) 139 + .output() 140 + .expect("run"); 141 + assert!( 142 + !output.status.success(), 143 + "expected parse failure when --app-password supplied without --handle" 144 + ); 145 + } 146 + 147 + /// AC8.4: Exit code is non-zero when endpoint is unreachable (NetworkError). 148 + #[test] 149 + fn ac8_4_unreachable_endpoint_nonzero_exit() { 150 + let output = Command::cargo_bin("atproto-devtool") 151 + .expect("bin") 152 + .args(["test", "labeler", "https://doesnt-exist.example.test"]) 153 + .output() 154 + .expect("run"); 155 + assert_ne!( 156 + output.status.code(), 157 + Some(0), 158 + "expected non-zero exit code for unreachable endpoint" 159 + ); 160 + }
+134 -1
tests/labeler_endtoend.rs
··· 3 3 mod common; 4 4 5 5 use async_trait::async_trait; 6 + use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 6 7 use atproto_devtool::commands::test::labeler::crypto::canonicalize_label_for_signing; 7 8 use atproto_devtool::commands::test::labeler::pipeline::{ 8 - HttpTee, LabelerOptions, parse_target, run_pipeline, 9 + CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 9 10 }; 10 11 use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; 11 12 use atrium_api::com::atproto::label::defs::{Label, LabelData}; ··· 271 272 let fake_tee = common::FakeRawHttpTee::new(); 272 273 fake_tee.add_response(None, 200, labels_response); 273 274 let fake_ws = common::FakeWebSocketClient::empty(); 275 + let fake_report_tee = common::FakeCreateReportTee::new(); 274 276 275 277 let opts = LabelerOptions { 276 278 http: &http, ··· 279 281 ws_client: Some(&fake_ws), 280 282 subscribe_timeout: Duration::from_secs(5), 281 283 verbose: false, 284 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 285 + commit_report: false, 286 + force_self_mint: false, 287 + self_mint_curve: SelfMintCurve::Es256k, 288 + report_subject_override: None, 289 + self_mint_signer: None, 290 + pds_credentials: None, 291 + pds_xrpc_client: None, 292 + pds_xrpc_client_override: None, 293 + run_id: "test-run-id", 282 294 }; 283 295 284 296 let report = run_pipeline(target, opts).await; ··· 324 336 let target = parse_target("did:plc:test123456789abcdefghijklmnop", None).expect("parse failed"); 325 337 let fake_tee = common::FakeRawHttpTee::new(); 326 338 let fake_ws = common::FakeWebSocketClient::empty(); 339 + let fake_report_tee = common::FakeCreateReportTee::new(); 327 340 328 341 let opts = LabelerOptions { 329 342 http: &http, ··· 332 345 ws_client: Some(&fake_ws), 333 346 subscribe_timeout: Duration::from_secs(5), 334 347 verbose: false, 348 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 349 + commit_report: false, 350 + force_self_mint: false, 351 + self_mint_curve: SelfMintCurve::Es256k, 352 + report_subject_override: None, 353 + self_mint_signer: None, 354 + pds_credentials: None, 355 + pds_xrpc_client: None, 356 + pds_xrpc_client_override: None, 357 + run_id: "test-run-id", 335 358 }; 336 359 337 360 let report = run_pipeline(target, opts).await; ··· 369 392 let fake_tee = common::FakeRawHttpTee::new(); 370 393 fake_tee.add_response(None, 200, b"{malformed json".to_vec()); 371 394 let fake_ws = common::FakeWebSocketClient::empty(); 395 + let fake_report_tee = common::FakeCreateReportTee::new(); 372 396 373 397 let opts = LabelerOptions { 374 398 http: &http, ··· 377 401 ws_client: Some(&fake_ws), 378 402 subscribe_timeout: Duration::from_secs(5), 379 403 verbose: false, 404 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 405 + commit_report: false, 406 + force_self_mint: false, 407 + self_mint_curve: SelfMintCurve::Es256k, 408 + report_subject_override: None, 409 + self_mint_signer: None, 410 + pds_credentials: None, 411 + pds_xrpc_client: None, 412 + pds_xrpc_client_override: None, 413 + run_id: "test-run-id", 380 414 }; 381 415 382 416 let report = run_pipeline(target, opts).await; ··· 412 446 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 413 447 414 448 let fake_ws = common::FakeWebSocketClient::new(); 449 + let fake_report_tee = common::FakeCreateReportTee::new(); 415 450 // Add a script with a transport error to simulate subscription failure. 416 451 fake_ws.add_script(common::FakeScript { 417 452 frames: vec![], ··· 428 463 ws_client: Some(&fake_ws), 429 464 subscribe_timeout: Duration::from_secs(5), 430 465 verbose: false, 466 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 467 + commit_report: false, 468 + force_self_mint: false, 469 + self_mint_curve: SelfMintCurve::Es256k, 470 + report_subject_override: None, 471 + self_mint_signer: None, 472 + pds_credentials: None, 473 + pds_xrpc_client: None, 474 + pds_xrpc_client_override: None, 475 + run_id: "test-run-id", 431 476 }; 432 477 433 478 let report = run_pipeline(target, opts).await; ··· 504 549 let fake_tee = common::FakeRawHttpTee::new(); 505 550 fake_tee.add_response(None, 200, labels_response); 506 551 let fake_ws = common::FakeWebSocketClient::empty(); 552 + let fake_report_tee = common::FakeCreateReportTee::new(); 507 553 508 554 let opts = LabelerOptions { 509 555 http: &http, ··· 512 558 ws_client: Some(&fake_ws), 513 559 subscribe_timeout: Duration::from_secs(5), 514 560 verbose: false, 561 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 562 + commit_report: false, 563 + force_self_mint: false, 564 + self_mint_curve: SelfMintCurve::Es256k, 565 + report_subject_override: None, 566 + self_mint_signer: None, 567 + pds_credentials: None, 568 + pds_xrpc_client: None, 569 + pds_xrpc_client_override: None, 570 + run_id: "test-run-id", 515 571 }; 516 572 517 573 let report = run_pipeline(target, opts).await; ··· 586 642 let fake_tee = common::FakeRawHttpTee::new(); 587 643 fake_tee.add_response(None, 200, labels_response); 588 644 let fake_ws = common::FakeWebSocketClient::empty(); 645 + let fake_report_tee = common::FakeCreateReportTee::new(); 589 646 590 647 let opts = LabelerOptions { 591 648 http: &http, ··· 594 651 ws_client: Some(&fake_ws), 595 652 subscribe_timeout: Duration::from_secs(5), 596 653 verbose: false, 654 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 655 + commit_report: false, 656 + force_self_mint: false, 657 + self_mint_curve: SelfMintCurve::Es256k, 658 + report_subject_override: None, 659 + self_mint_signer: None, 660 + pds_credentials: None, 661 + pds_xrpc_client: None, 662 + pds_xrpc_client_override: None, 663 + run_id: "test-run-id", 597 664 }; 598 665 599 666 let report = run_pipeline(target, opts).await; ··· 665 732 let fake_tee = common::FakeRawHttpTee::new(); 666 733 fake_tee.add_response(None, 200, labels_response); 667 734 let fake_ws = common::FakeWebSocketClient::empty(); 735 + let fake_report_tee = common::FakeCreateReportTee::new(); 668 736 669 737 let opts = LabelerOptions { 670 738 http: &http, ··· 673 741 ws_client: Some(&fake_ws), 674 742 subscribe_timeout: Duration::from_secs(5), 675 743 verbose: false, 744 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 745 + commit_report: false, 746 + force_self_mint: false, 747 + self_mint_curve: SelfMintCurve::Es256k, 748 + report_subject_override: None, 749 + self_mint_signer: None, 750 + pds_credentials: None, 751 + pds_xrpc_client: None, 752 + pds_xrpc_client_override: None, 753 + run_id: "test-run-id", 676 754 }; 677 755 678 756 let report = run_pipeline(target, opts).await; ··· 709 787 let fake_tee = common::FakeRawHttpTee::new(); 710 788 fake_tee.add_response(None, 200, labels_response); 711 789 let fake_ws = common::FakeWebSocketClient::empty(); 790 + let fake_report_tee = common::FakeCreateReportTee::new(); 712 791 713 792 let opts = LabelerOptions { 714 793 http: &http, ··· 717 796 ws_client: Some(&fake_ws), 718 797 subscribe_timeout: Duration::from_secs(5), 719 798 verbose: false, 799 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 800 + commit_report: false, 801 + force_self_mint: false, 802 + self_mint_curve: SelfMintCurve::Es256k, 803 + report_subject_override: None, 804 + self_mint_signer: None, 805 + pds_credentials: None, 806 + pds_xrpc_client: None, 807 + pds_xrpc_client_override: None, 808 + run_id: "test-run-id", 720 809 }; 721 810 722 811 let report = run_pipeline(target, opts).await; ··· 778 867 let fake_tee = common::FakeRawHttpTee::new(); 779 868 fake_tee.add_response(None, 200, labels_response); 780 869 let fake_ws = common::FakeWebSocketClient::empty(); 870 + let fake_report_tee = common::FakeCreateReportTee::new(); 781 871 782 872 let opts = LabelerOptions { 783 873 http: &http, ··· 786 876 ws_client: Some(&fake_ws), 787 877 subscribe_timeout: Duration::from_secs(5), 788 878 verbose: false, 879 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 880 + commit_report: false, 881 + force_self_mint: false, 882 + self_mint_curve: SelfMintCurve::Es256k, 883 + report_subject_override: None, 884 + self_mint_signer: None, 885 + pds_credentials: None, 886 + pds_xrpc_client: None, 887 + pds_xrpc_client_override: None, 888 + run_id: "test-run-id", 789 889 }; 790 890 791 891 let report = run_pipeline(target, opts).await; ··· 823 923 let fake_tee = common::FakeRawHttpTee::new(); 824 924 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 825 925 let fake_ws = common::FakeWebSocketClient::empty(); 926 + let fake_report_tee = common::FakeCreateReportTee::new(); 826 927 827 928 let opts = LabelerOptions { 828 929 http: &http, ··· 831 932 ws_client: Some(&fake_ws), 832 933 subscribe_timeout: Duration::from_secs(5), 833 934 verbose: false, 935 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 936 + commit_report: false, 937 + force_self_mint: false, 938 + self_mint_curve: SelfMintCurve::Es256k, 939 + report_subject_override: None, 940 + self_mint_signer: None, 941 + pds_credentials: None, 942 + pds_xrpc_client: None, 943 + pds_xrpc_client_override: None, 944 + run_id: "test-run-id", 834 945 }; 835 946 836 947 let report = run_pipeline(target, opts).await; ··· 872 983 let fake_tee = common::FakeRawHttpTee::new(); 873 984 fake_tee.set_transport_error(); // Force HTTP stage transport error. 874 985 let fake_ws = common::FakeWebSocketClient::empty(); 986 + let fake_report_tee = common::FakeCreateReportTee::new(); 875 987 876 988 let opts = LabelerOptions { 877 989 http: &http, ··· 880 992 ws_client: Some(&fake_ws), 881 993 subscribe_timeout: Duration::from_secs(5), 882 994 verbose: false, 995 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 996 + commit_report: false, 997 + force_self_mint: false, 998 + self_mint_curve: SelfMintCurve::Es256k, 999 + report_subject_override: None, 1000 + self_mint_signer: None, 1001 + pds_credentials: None, 1002 + pds_xrpc_client: None, 1003 + pds_xrpc_client_override: None, 1004 + run_id: "test-run-id", 883 1005 }; 884 1006 885 1007 let report = run_pipeline(target, opts).await; ··· 906 1028 let fake_tee = common::FakeRawHttpTee::new(); 907 1029 fake_tee.add_response(None, 200, br#"{"cursor": null, "labels": []}"#.to_vec()); 908 1030 let fake_ws = common::FakeWebSocketClient::empty(); 1031 + let fake_report_tee = common::FakeCreateReportTee::new(); 909 1032 910 1033 let opts = LabelerOptions { 911 1034 http: &http, ··· 914 1037 ws_client: Some(&fake_ws), 915 1038 subscribe_timeout: Duration::from_secs(5), 916 1039 verbose: false, 1040 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 1041 + commit_report: false, 1042 + force_self_mint: false, 1043 + self_mint_curve: SelfMintCurve::Es256k, 1044 + report_subject_override: None, 1045 + self_mint_signer: None, 1046 + pds_credentials: None, 1047 + pds_xrpc_client: None, 1048 + pds_xrpc_client_override: None, 1049 + run_id: "test-run-id", 917 1050 }; 918 1051 919 1052 let report = run_pipeline(target, opts).await;
+219 -14
tests/labeler_identity.rs
··· 2 2 3 3 mod common; 4 4 5 + use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 5 6 use atproto_devtool::commands::test::labeler::pipeline::{ 6 - HttpTee, LabelerOptions, parse_target, run_pipeline, 7 + CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 7 8 }; 8 9 use url::Url; 9 10 ··· 19 20 String::from_utf8(buf).expect("invalid utf-8") 20 21 } 21 22 22 - /// Helper to normalize timing in rendered reports for snapshot testing. 23 - fn normalize_timing(rendered: String) -> String { 24 - // Replace patterns like "elapsed: 1ms" with "elapsed: Xms" for consistent snapshots. 25 - let mut result = rendered.clone(); 26 - let start = result.find("elapsed: "); 27 - if let Some(pos) = start { 28 - let remaining = &result[pos + 9..]; 29 - if let Some(end) = remaining.find("ms") { 30 - result.replace_range(pos + 9..pos + 9 + end, "X"); 31 - } 32 - } 33 - result 34 - } 23 + use common::normalize_timing; 35 24 36 25 /// Helper function to return a healthy labels response (non-empty with valid labels). 37 26 fn healthy_labels_response() -> Vec<u8> { ··· 75 64 let fake_tee = common::FakeRawHttpTee::new(); 76 65 fake_tee.add_response(None, 200, healthy_labels_response()); 77 66 let fake_ws = common::FakeWebSocketClient::empty(); 67 + let fake_report_tee = common::FakeCreateReportTee::new(); 78 68 79 69 let opts = LabelerOptions { 80 70 http: &http, ··· 83 73 ws_client: Some(&fake_ws), 84 74 subscribe_timeout: std::time::Duration::from_secs(5), 85 75 verbose: false, 76 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 77 + commit_report: false, 78 + force_self_mint: false, 79 + self_mint_curve: SelfMintCurve::Es256k, 80 + report_subject_override: None, 81 + self_mint_signer: None, 82 + pds_credentials: None, 83 + pds_xrpc_client: None, 84 + pds_xrpc_client_override: None, 85 + run_id: "test-run-id", 86 86 }; 87 87 88 88 let report = run_pipeline(target, opts).await; ··· 102 102 fake_tee.add_response(None, 200, empty_response); 103 103 104 104 let fake_ws = common::FakeWebSocketClient::empty(); 105 + let fake_report_tee = common::FakeCreateReportTee::new(); 105 106 106 107 let opts = LabelerOptions { 107 108 http: &http, ··· 110 111 ws_client: Some(&fake_ws), 111 112 subscribe_timeout: std::time::Duration::from_secs(5), 112 113 verbose: false, 114 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 115 + commit_report: false, 116 + force_self_mint: false, 117 + self_mint_curve: SelfMintCurve::Es256k, 118 + report_subject_override: None, 119 + self_mint_signer: None, 120 + pds_credentials: None, 121 + pds_xrpc_client: None, 122 + pds_xrpc_client_override: None, 123 + run_id: "test-run-id", 113 124 }; 114 125 115 126 let report = run_pipeline(target, opts).await; ··· 151 162 fake_tee.add_response(None, 200, empty_response); 152 163 153 164 let fake_ws = common::FakeWebSocketClient::empty(); 165 + let fake_report_tee = common::FakeCreateReportTee::new(); 154 166 155 167 let opts = LabelerOptions { 156 168 http: &http, ··· 159 171 ws_client: Some(&fake_ws), 160 172 subscribe_timeout: std::time::Duration::from_secs(5), 161 173 verbose: false, 174 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 175 + commit_report: false, 176 + force_self_mint: false, 177 + self_mint_curve: SelfMintCurve::Es256k, 178 + report_subject_override: None, 179 + self_mint_signer: None, 180 + pds_credentials: None, 181 + pds_xrpc_client: None, 182 + pds_xrpc_client_override: None, 183 + run_id: "test-run-id", 162 184 }; 163 185 164 186 let report = run_pipeline(target, opts).await; ··· 202 224 let fake_tee = common::FakeRawHttpTee::new(); 203 225 fake_tee.add_response(None, 200, healthy_labels_response()); 204 226 let fake_ws = common::FakeWebSocketClient::empty(); 227 + let fake_report_tee = common::FakeCreateReportTee::new(); 205 228 206 229 let opts = LabelerOptions { 207 230 http: &http, ··· 210 233 ws_client: Some(&fake_ws), 211 234 subscribe_timeout: std::time::Duration::from_secs(5), 212 235 verbose: false, 236 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 237 + commit_report: false, 238 + force_self_mint: false, 239 + self_mint_curve: SelfMintCurve::Es256k, 240 + report_subject_override: None, 241 + self_mint_signer: None, 242 + pds_credentials: None, 243 + pds_xrpc_client: None, 244 + pds_xrpc_client_override: None, 245 + run_id: "test-run-id", 213 246 }; 214 247 215 248 let report = run_pipeline(target, opts).await; ··· 245 278 fake_tee.add_response(None, 200, empty_response); 246 279 247 280 let fake_ws = common::FakeWebSocketClient::empty(); 281 + let fake_report_tee = common::FakeCreateReportTee::new(); 248 282 249 283 let opts = LabelerOptions { 250 284 http: &http, ··· 253 287 ws_client: Some(&fake_ws), 254 288 subscribe_timeout: std::time::Duration::from_secs(5), 255 289 verbose: false, 290 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 291 + commit_report: false, 292 + force_self_mint: false, 293 + self_mint_curve: SelfMintCurve::Es256k, 294 + report_subject_override: None, 295 + self_mint_signer: None, 296 + pds_credentials: None, 297 + pds_xrpc_client: None, 298 + pds_xrpc_client_override: None, 299 + run_id: "test-run-id", 256 300 }; 257 301 258 302 let report = run_pipeline(target, opts).await; ··· 292 336 fake_tee.add_response(None, 200, empty_response); 293 337 294 338 let fake_ws = common::FakeWebSocketClient::empty(); 339 + let fake_report_tee = common::FakeCreateReportTee::new(); 295 340 296 341 let opts = LabelerOptions { 297 342 http: &http, ··· 300 345 ws_client: Some(&fake_ws), 301 346 subscribe_timeout: std::time::Duration::from_secs(5), 302 347 verbose: false, 348 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 349 + commit_report: false, 350 + force_self_mint: false, 351 + self_mint_curve: SelfMintCurve::Es256k, 352 + report_subject_override: None, 353 + self_mint_signer: None, 354 + pds_credentials: None, 355 + pds_xrpc_client: None, 356 + pds_xrpc_client_override: None, 357 + run_id: "test-run-id", 303 358 }; 304 359 305 360 let report = run_pipeline(target, opts).await; ··· 331 386 let fake_tee = common::FakeRawHttpTee::new(); 332 387 fake_tee.add_response(None, 200, healthy_labels_response()); 333 388 let fake_ws = common::FakeWebSocketClient::empty(); 389 + let fake_report_tee = common::FakeCreateReportTee::new(); 334 390 335 391 let opts = LabelerOptions { 336 392 http: &http, ··· 339 395 ws_client: Some(&fake_ws), 340 396 subscribe_timeout: std::time::Duration::from_secs(5), 341 397 verbose: false, 398 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 399 + commit_report: false, 400 + force_self_mint: false, 401 + self_mint_curve: SelfMintCurve::Es256k, 402 + report_subject_override: None, 403 + self_mint_signer: None, 404 + pds_credentials: None, 405 + pds_xrpc_client: None, 406 + pds_xrpc_client_override: None, 407 + run_id: "test-run-id", 342 408 }; 343 409 344 410 let report = run_pipeline(target, opts).await; ··· 375 441 fake_tee.add_response(None, 200, empty_response); 376 442 377 443 let fake_ws = common::FakeWebSocketClient::empty(); 444 + let fake_report_tee = common::FakeCreateReportTee::new(); 378 445 379 446 let opts = LabelerOptions { 380 447 http: &http, ··· 383 450 ws_client: Some(&fake_ws), 384 451 subscribe_timeout: std::time::Duration::from_secs(5), 385 452 verbose: false, 453 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 454 + commit_report: false, 455 + force_self_mint: false, 456 + self_mint_curve: SelfMintCurve::Es256k, 457 + report_subject_override: None, 458 + self_mint_signer: None, 459 + pds_credentials: None, 460 + pds_xrpc_client: None, 461 + pds_xrpc_client_override: None, 462 + run_id: "test-run-id", 386 463 }; 387 464 388 465 let report = run_pipeline(target, opts).await; ··· 421 498 fake_tee.add_response(None, 200, empty_response); 422 499 423 500 let fake_ws = common::FakeWebSocketClient::empty(); 501 + let fake_report_tee = common::FakeCreateReportTee::new(); 424 502 425 503 let opts = LabelerOptions { 426 504 http: &http, ··· 429 507 ws_client: Some(&fake_ws), 430 508 subscribe_timeout: std::time::Duration::from_secs(5), 431 509 verbose: false, 510 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 511 + commit_report: false, 512 + force_self_mint: false, 513 + self_mint_curve: SelfMintCurve::Es256k, 514 + report_subject_override: None, 515 + self_mint_signer: None, 516 + pds_credentials: None, 517 + pds_xrpc_client: None, 518 + pds_xrpc_client_override: None, 519 + run_id: "test-run-id", 432 520 }; 433 521 434 522 let report = run_pipeline(target, opts).await; ··· 465 553 fake_tee.add_response(None, 200, empty_response); 466 554 467 555 let fake_ws = common::FakeWebSocketClient::empty(); 556 + let fake_report_tee = common::FakeCreateReportTee::new(); 468 557 469 558 let opts = LabelerOptions { 470 559 http: &http, ··· 473 562 ws_client: Some(&fake_ws), 474 563 subscribe_timeout: std::time::Duration::from_secs(5), 475 564 verbose: false, 565 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 566 + commit_report: false, 567 + force_self_mint: false, 568 + self_mint_curve: SelfMintCurve::Es256k, 569 + report_subject_override: None, 570 + self_mint_signer: None, 571 + pds_credentials: None, 572 + pds_xrpc_client: None, 573 + pds_xrpc_client_override: None, 574 + run_id: "test-run-id", 476 575 }; 477 576 478 577 let report = run_pipeline(target, opts).await; ··· 509 608 fake_tee.add_response(None, 200, empty_response); 510 609 511 610 let fake_ws = common::FakeWebSocketClient::empty(); 611 + let fake_report_tee = common::FakeCreateReportTee::new(); 512 612 513 613 let opts = LabelerOptions { 514 614 http: &http, ··· 517 617 ws_client: Some(&fake_ws), 518 618 subscribe_timeout: std::time::Duration::from_secs(5), 519 619 verbose: false, 620 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 621 + commit_report: false, 622 + force_self_mint: false, 623 + self_mint_curve: SelfMintCurve::Es256k, 624 + report_subject_override: None, 625 + self_mint_signer: None, 626 + pds_credentials: None, 627 + pds_xrpc_client: None, 628 + pds_xrpc_client_override: None, 629 + run_id: "test-run-id", 520 630 }; 521 631 522 632 let report = run_pipeline(target, opts).await; ··· 557 667 fake_tee.add_response(None, 200, empty_response); 558 668 559 669 let fake_ws = common::FakeWebSocketClient::empty(); 670 + let fake_report_tee = common::FakeCreateReportTee::new(); 560 671 561 672 let opts = LabelerOptions { 562 673 http: &http, ··· 565 676 ws_client: Some(&fake_ws), 566 677 subscribe_timeout: std::time::Duration::from_secs(5), 567 678 verbose: false, 679 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 680 + commit_report: false, 681 + force_self_mint: false, 682 + self_mint_curve: SelfMintCurve::Es256k, 683 + report_subject_override: None, 684 + self_mint_signer: None, 685 + pds_credentials: None, 686 + pds_xrpc_client: None, 687 + pds_xrpc_client_override: None, 688 + run_id: "test-run-id", 568 689 }; 569 690 570 691 let report = run_pipeline(target, opts).await; ··· 572 693 573 694 insta::assert_snapshot!(rendered); 574 695 } 696 + 697 + /// Local override: user supplies a local `http://` target together with a 698 + /// `--did` that resolves to a production DID document whose 699 + /// `#atproto_labeler` service advertises a remote endpoint. Expected 700 + /// behaviour: 701 + /// 702 + /// - `identity::resolved_did_matches_flag` emits `Advisory`, NOT 703 + /// `SpecViolation` — the mismatch is expected when testing a local copy 704 + /// of a production labeler. 705 + /// - The rendered header's labeler endpoint is the local override, so 706 + /// HTTP / subscription / report stages talk to the local copy. 707 + /// - Downstream stages run to completion rather than being blocked by 708 + /// identity. 709 + #[tokio::test] 710 + async fn local_http_override_mismatch_is_advisory() { 711 + let http = common::FakeHttpClient::new(); 712 + let dns = common::FakeDnsResolver::new(); 713 + 714 + let did_json = include_bytes!("fixtures/labeler/identity/endpoint_mismatch/did.json").to_vec(); 715 + http.add_response( 716 + &Url::parse("https://plc.directory/did:plc:endpoint_mismatch_test_123456789").unwrap(), 717 + 200, 718 + did_json, 719 + ); 720 + 721 + let labeler_record_json = 722 + include_bytes!("fixtures/labeler/identity/endpoint_mismatch/labeler_record.json").to_vec(); 723 + http.add_response( 724 + &Url::parse("https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did:plc:endpoint_mismatch_test_123456789&collection=app.bsky.labeler.service&rkey=self").unwrap(), 725 + 200, 726 + labeler_record_json, 727 + ); 728 + 729 + // Target is a local labeler copy; the DID document points somewhere else. 730 + let target = parse_target( 731 + "http://localhost:8080", 732 + Some("did:plc:endpoint_mismatch_test_123456789"), 733 + ) 734 + .expect("parse failed"); 735 + 736 + let fake_tee = common::FakeRawHttpTee::new(); 737 + let empty_response = br#"{"cursor":null,"labels":[]}"#.to_vec(); 738 + fake_tee.add_response(None, 200, empty_response); 739 + 740 + let fake_ws = common::FakeWebSocketClient::empty(); 741 + let fake_report_tee = common::FakeCreateReportTee::new(); 742 + 743 + let opts = LabelerOptions { 744 + http: &http, 745 + dns: &dns, 746 + http_tee: HttpTee::Test(&fake_tee), 747 + ws_client: Some(&fake_ws), 748 + subscribe_timeout: std::time::Duration::from_secs(5), 749 + verbose: false, 750 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 751 + commit_report: false, 752 + force_self_mint: false, 753 + self_mint_curve: SelfMintCurve::Es256k, 754 + report_subject_override: None, 755 + self_mint_signer: None, 756 + pds_credentials: None, 757 + pds_xrpc_client: None, 758 + pds_xrpc_client_override: None, 759 + run_id: "test-run-id", 760 + }; 761 + 762 + let report = run_pipeline(target, opts).await; 763 + let rendered = normalize_timing(render_report_to_string(&report)); 764 + 765 + // The rendered output must show the Advisory status for the mismatch 766 + // check and the local URL as the labeler endpoint. Asserting against 767 + // the rendered string keeps the test independent of LabelerReport 768 + // internals while still catching regressions in either property. 769 + assert!( 770 + rendered.contains("[WARN] resolved DID matches --did flag"), 771 + "expected Advisory on resolved_did_matches_flag, got:\n{rendered}" 772 + ); 773 + assert!( 774 + rendered.contains("Labeler endpoint: http://localhost:8080/"), 775 + "expected header to show the local labeler endpoint override, got:\n{rendered}" 776 + ); 777 + 778 + insta::assert_snapshot!(rendered); 779 + }
+1713
tests/labeler_report.rs
··· 1 + //! Integration tests for the `report` stage. 2 + //! 3 + //! Uses `FakeCreateReportTee` from `tests/common/mod.rs` to drive each 4 + //! stage-gating scenario, then snapshots the rendered output to pin the 5 + //! exact 10-row sequence. 6 + 7 + mod common; 8 + 9 + use async_trait::async_trait; 10 + use std::borrow::Cow; 11 + use std::sync::Arc; 12 + use std::time::Duration; 13 + 14 + use atproto_devtool::commands::test::labeler::create_report; 15 + use atproto_devtool::commands::test::labeler::create_report::Check; 16 + use atproto_devtool::commands::test::labeler::create_report::self_mint::{ 17 + SelfMintCurve, SelfMintSigner, 18 + }; 19 + use atproto_devtool::commands::test::labeler::identity::IdentityFacts; 20 + use atproto_devtool::commands::test::labeler::pipeline::{ 21 + CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 22 + }; 23 + use atproto_devtool::common::identity::{Did, DnsResolver, HttpClient, IdentityError}; 24 + use atproto_devtool::common::report::{ 25 + CheckResult, CheckStatus, LabelerReport, RenderConfig, ReportHeader, 26 + }; 27 + 28 + use atrium_api::app::bsky::labeler::defs::LabelerPolicies; 29 + 30 + use common::FakeCreateReportTee; 31 + 32 + /// Stub HTTP client for testing that always returns an identity error. 33 + struct StubHttpClient; 34 + 35 + #[async_trait] 36 + impl HttpClient for StubHttpClient { 37 + async fn get_bytes(&self, _url: &url::Url) -> Result<(u16, Vec<u8>), IdentityError> { 38 + Err(IdentityError::InvalidHandle) 39 + } 40 + } 41 + 42 + /// Stub DNS resolver for testing that always returns an identity error. 43 + struct StubDnsResolver; 44 + 45 + #[async_trait] 46 + impl DnsResolver for StubDnsResolver { 47 + async fn txt_lookup(&self, _name: &str) -> Result<Vec<String>, IdentityError> { 48 + Err(IdentityError::InvalidHandle) 49 + } 50 + } 51 + 52 + /// Build a synthetic `IdentityFacts` with the requested contract shape. 53 + /// 54 + /// Populates every required field with stable, known-valid defaults so the 55 + /// fixture is safe to reuse across all AC tests. The labeler endpoint is a 56 + /// public HTTPS URL by default (non-local per the viability heuristic); 57 + /// tests that need a local endpoint override `facts.labeler_endpoint` 58 + /// directly before passing in. 59 + fn make_identity_facts( 60 + reason_types: Option<Vec<String>>, 61 + subject_types: Option<Vec<String>>, 62 + ) -> IdentityFacts { 63 + use atproto_devtool::common::identity::{ 64 + AnyVerifyingKey, DidDocument, RawDidDocument, parse_multikey, 65 + }; 66 + 67 + // A stable secp256k1 multikey drawn from an existing test fixture. 68 + // The exact value is load-bearing only insofar as it must parse via 69 + // `parse_multikey` — any valid secp256k1 multikey works. 70 + let multikey = "zQ3shNcc9CfAhG1vLj3UEV3SA4VESNiJKJiFLgs6WfGo4qG7B"; 71 + let parsed = parse_multikey(multikey).expect("test multikey parses"); 72 + let verifying_key: AnyVerifyingKey = parsed.verifying_key; 73 + 74 + // Minimal DID document with the one verification method the stages 75 + // care about. Raw bytes must match the parsed form so `NamedSource` 76 + // diagnostics land correctly; the exact bytes aren't snapshotted in 77 + // these tests, so a small JSON is fine. 78 + let did_string = "did:plc:aaa22222222222222222bbbbbb"; 79 + let doc_json = serde_json::json!({ 80 + "id": did_string, 81 + "verificationMethod": [ 82 + { 83 + "id": format!("{}#atproto_label", did_string), 84 + "type": "Multikey", 85 + "controller": did_string, 86 + "publicKeyMultibase": multikey, 87 + } 88 + ], 89 + "service": [ 90 + { 91 + "id": "#atproto_labeler", 92 + "type": "AtprotoLabeler", 93 + "serviceEndpoint": "https://labeler.example.com", 94 + }, 95 + { 96 + "id": "#atproto_pds", 97 + "type": "AtprotoPersonalDataServer", 98 + "serviceEndpoint": "https://pds.example.com", 99 + } 100 + ] 101 + }) 102 + .to_string(); 103 + let doc: DidDocument = serde_json::from_str(&doc_json).expect("test DID doc parses"); 104 + let raw_did_doc = RawDidDocument { 105 + parsed: doc, 106 + source_bytes: Arc::<[u8]>::from(doc_json.as_bytes()), 107 + source_name: "test DID document".to_string(), 108 + }; 109 + 110 + // Empty `LabelerPolicies` is always valid — the AC1 gate reads 111 + // `reason_types`/`subject_types` on `IdentityFacts` directly (Task 0), 112 + // not on `labeler_policies`. 113 + let labeler_policies: LabelerPolicies = serde_json::from_value(serde_json::json!({ 114 + "labelValues": [], 115 + })) 116 + .expect("LabelerPolicies deserializes"); 117 + 118 + IdentityFacts { 119 + did: Did(did_string.to_string()), 120 + raw_did_doc, 121 + labeler_endpoint: url::Url::parse("https://labeler.example.com").unwrap(), 122 + pds_endpoint: url::Url::parse("https://pds.example.com").unwrap(), 123 + signing_key_id: format!("{did_string}#atproto_label"), 124 + signing_key_multikey: multikey.to_string(), 125 + signing_key: verifying_key, 126 + labeler_record_bytes: Arc::<[u8]>::from(b"{}" as &[u8]), 127 + labeler_policies, 128 + reason_types, 129 + subject_types, 130 + subject_collections: None, 131 + } 132 + } 133 + 134 + /// Default options for report stage tests. Shared by AC1-AC8 tests to avoid duplication. 135 + fn default_opts() -> create_report::CreateReportRunOptions<'static> { 136 + create_report::CreateReportRunOptions { 137 + commit_report: false, 138 + force_self_mint: false, 139 + self_mint_curve: SelfMintCurve::Es256k, 140 + report_subject_override: None, 141 + self_mint_signer: None, 142 + pds_credentials: None, 143 + pds_xrpc_client: None, 144 + pds_resolution_error: None, 145 + run_id: "test-run-id-0000", 146 + } 147 + } 148 + 149 + /// Run the report stage directly (not through run_pipeline) with the 150 + /// given fake tee and options. Returns the 10 CheckResults. 151 + async fn run_report_stage( 152 + facts: &IdentityFacts, 153 + tee: &FakeCreateReportTee, 154 + opts: create_report::CreateReportRunOptions<'_>, 155 + ) -> Vec<CheckResult> { 156 + let out = create_report::run(Some(facts), tee, &opts).await; 157 + out.results 158 + } 159 + 160 + #[tokio::test] 161 + async fn ac1_1_contract_present_emits_pass() { 162 + let facts = make_identity_facts( 163 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 164 + Some(vec!["account".to_string()]), 165 + ); 166 + let tee = FakeCreateReportTee::new(); 167 + // Queue fake responses for the two no-JWT checks that now actually POST. 168 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 169 + "AuthenticationRequired", 170 + "jwt required", 171 + )); 172 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 173 + "BadJwt", 174 + "invalid bearer", 175 + )); 176 + let results = run_report_stage(&facts, &tee, default_opts()).await; 177 + 178 + assert_eq!(results.len(), 10, "AC7.1 requires exactly 10 rows"); 179 + assert_eq!(results[0].id, "report::contract_published"); 180 + assert_eq!(results[0].status, CheckStatus::Pass); 181 + } 182 + 183 + #[tokio::test] 184 + async fn ac1_2_contract_missing_without_commit_skips_stage() { 185 + let facts = make_identity_facts(None, None); 186 + let tee = FakeCreateReportTee::new(); 187 + let results = run_report_stage(&facts, &tee, default_opts()).await; 188 + 189 + assert_eq!(results.len(), 10); 190 + for r in &results { 191 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 192 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 193 + assert_eq!(reason, "labeler does not advertise report acceptance"); 194 + } 195 + } 196 + 197 + #[tokio::test] 198 + async fn ac1_3_contract_missing_with_commit_is_spec_violation() { 199 + let facts = make_identity_facts(None, None); 200 + let tee = FakeCreateReportTee::new(); 201 + let opts = create_report::CreateReportRunOptions { 202 + commit_report: true, 203 + force_self_mint: false, 204 + self_mint_curve: SelfMintCurve::Es256k, 205 + report_subject_override: None, 206 + self_mint_signer: None, 207 + pds_credentials: None, 208 + pds_xrpc_client: None, 209 + pds_resolution_error: None, 210 + run_id: "test-run-id-0000", 211 + }; 212 + let results = run_report_stage(&facts, &tee, opts).await; 213 + 214 + assert_eq!(results.len(), 10); 215 + assert_eq!(results[0].id, "report::contract_published"); 216 + assert_eq!(results[0].status, CheckStatus::SpecViolation); 217 + 218 + for r in &results[1..] { 219 + assert_eq!(r.status, CheckStatus::Skipped, "{}", r.id); 220 + let reason = r.skipped_reason.as_deref().unwrap_or(""); 221 + assert_eq!(reason, "blocked by `report::contract_published`"); 222 + } 223 + } 224 + 225 + #[tokio::test] 226 + async fn ac1_4_empty_arrays_equivalent_to_absent() { 227 + // Empty Vecs treated the same as None per AC1.4. 228 + let facts = make_identity_facts(Some(vec![]), Some(vec![])); 229 + let tee = FakeCreateReportTee::new(); 230 + let results = run_report_stage(&facts, &tee, default_opts()).await; 231 + assert_eq!(results[0].status, CheckStatus::Skipped); 232 + assert_eq!( 233 + results[0].skipped_reason.as_deref(), 234 + Some("labeler does not advertise report acceptance"), 235 + ); 236 + } 237 + 238 + #[tokio::test] 239 + async fn ac7_2_row_order_is_stable() { 240 + let facts = make_identity_facts( 241 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 242 + Some(vec!["account".to_string()]), 243 + ); 244 + let tee = FakeCreateReportTee::new(); 245 + // Queue fake responses for the two no-JWT checks that now actually POST. 246 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 247 + "AuthenticationRequired", 248 + "jwt required", 249 + )); 250 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 251 + "BadJwt", 252 + "invalid bearer", 253 + )); 254 + let results = run_report_stage(&facts, &tee, default_opts()).await; 255 + let ids: Vec<&str> = results.iter().map(|r| r.id).collect(); 256 + assert_eq!( 257 + ids, 258 + vec![ 259 + "report::contract_published", 260 + "report::unauthenticated_rejected", 261 + "report::malformed_bearer_rejected", 262 + "report::wrong_aud_rejected", 263 + "report::wrong_lxm_rejected", 264 + "report::expired_rejected", 265 + "report::rejected_shape_returns_400", 266 + "report::self_mint_accepted", 267 + "report::pds_service_auth_accepted", 268 + "report::pds_proxied_accepted", 269 + ], 270 + ); 271 + } 272 + 273 + async fn render_results_to_string_with_facts( 274 + results: Vec<CheckResult>, 275 + facts: &IdentityFacts, 276 + ) -> String { 277 + // Mirror the pipeline's header population (src/commands/test/labeler/ 278 + // pipeline.rs:212-216) so snapshot output matches what a real CLI run 279 + // would produce. Use the provided facts to populate endpoints. 280 + let mut report = LabelerReport::new(ReportHeader { 281 + target: "test-labeler".to_string(), 282 + resolved_did: Some(facts.did.0.clone()), 283 + pds_endpoint: Some(facts.pds_endpoint.to_string()), 284 + labeler_endpoint: Some(facts.labeler_endpoint.to_string()), 285 + }); 286 + for r in results { 287 + report.record(r); 288 + } 289 + report.finish(); 290 + let mut buf = Vec::new(); 291 + report 292 + .render(&mut buf, &RenderConfig { no_color: true }) 293 + .expect("render"); 294 + let rendered = String::from_utf8(buf).expect("utf-8"); 295 + common::normalize_timing(rendered) 296 + } 297 + 298 + async fn render_results_to_string(results: Vec<CheckResult>) -> String { 299 + // Backward-compatible wrapper using default facts. 300 + let facts = make_identity_facts( 301 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 302 + Some(vec!["account".to_string()]), 303 + ); 304 + render_results_to_string_with_facts(results, &facts).await 305 + } 306 + 307 + #[tokio::test] 308 + async fn snapshot_contract_present_no_commit() { 309 + let facts = make_identity_facts( 310 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 311 + Some(vec!["account".to_string()]), 312 + ); 313 + let tee = FakeCreateReportTee::new(); 314 + // Queue fake responses for the two no-JWT checks that now actually POST. 315 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 316 + "AuthenticationRequired", 317 + "jwt required", 318 + )); 319 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 320 + "BadJwt", 321 + "invalid bearer", 322 + )); 323 + let results = run_report_stage(&facts, &tee, default_opts()).await; 324 + insta::assert_snapshot!( 325 + "report_contract_present_no_commit", 326 + render_results_to_string(results).await 327 + ); 328 + } 329 + 330 + #[tokio::test] 331 + async fn snapshot_contract_present_with_commit() { 332 + let facts = make_identity_facts( 333 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 334 + Some(vec!["account".to_string()]), 335 + ); 336 + let tee = FakeCreateReportTee::new(); 337 + // Queue fake responses for the two no-JWT checks that now actually POST. 338 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 339 + "AuthenticationRequired", 340 + "jwt required", 341 + )); 342 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 343 + "BadJwt", 344 + "invalid bearer", 345 + )); 346 + let mut opts = default_opts(); 347 + opts.commit_report = true; 348 + let results = run_report_stage(&facts, &tee, opts).await; 349 + insta::assert_snapshot!( 350 + "report_contract_present_with_commit", 351 + render_results_to_string(results).await 352 + ); 353 + } 354 + 355 + #[tokio::test] 356 + async fn snapshot_contract_missing_no_commit() { 357 + let facts = make_identity_facts(None, None); 358 + let tee = FakeCreateReportTee::new(); 359 + let results = run_report_stage(&facts, &tee, default_opts()).await; 360 + insta::assert_snapshot!( 361 + "report_contract_missing_no_commit", 362 + render_results_to_string(results).await 363 + ); 364 + } 365 + 366 + #[tokio::test] 367 + async fn snapshot_contract_missing_with_commit() { 368 + let facts = make_identity_facts(None, None); 369 + let tee = FakeCreateReportTee::new(); 370 + let mut opts = default_opts(); 371 + opts.commit_report = true; 372 + let results = run_report_stage(&facts, &tee, opts).await; 373 + insta::assert_snapshot!( 374 + "report_contract_missing_with_commit", 375 + render_results_to_string(results).await 376 + ); 377 + } 378 + 379 + #[tokio::test] 380 + async fn ac2_1_unauthenticated_401_with_envelope_passes() { 381 + let facts = make_identity_facts( 382 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 383 + Some(vec!["account".to_string()]), 384 + ); 385 + let tee = FakeCreateReportTee::new(); 386 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 387 + "AuthenticationRequired", 388 + "jwt required", 389 + )); 390 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 391 + "BadJwt", 392 + "invalid bearer", 393 + )); 394 + let results = run_report_stage(&facts, &tee, default_opts()).await; 395 + 396 + assert_eq!(results[1].id, "report::unauthenticated_rejected"); 397 + assert_eq!(results[1].status, CheckStatus::Pass); 398 + assert_eq!(results[2].id, "report::malformed_bearer_rejected"); 399 + assert_eq!(results[2].status, CheckStatus::Pass); 400 + } 401 + 402 + #[tokio::test] 403 + async fn ac2_2_unauthenticated_200_is_spec_violation() { 404 + let facts = make_identity_facts( 405 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 406 + Some(vec!["account".to_string()]), 407 + ); 408 + let tee = FakeCreateReportTee::new(); 409 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 410 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 411 + "BadJwt", "x", 412 + )); 413 + let results = run_report_stage(&facts, &tee, default_opts()).await; 414 + 415 + assert_eq!(results[1].status, CheckStatus::SpecViolation); 416 + let diag = results[1].diagnostic.as_ref().expect("diagnostic present"); 417 + assert_eq!( 418 + diag.code().map(|c| c.to_string()), 419 + Some("labeler::report::unauthenticated_accepted".to_string()), 420 + ); 421 + } 422 + 423 + #[tokio::test] 424 + async fn ac2_4_malformed_bearer_200_is_spec_violation() { 425 + let facts = make_identity_facts( 426 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 427 + Some(vec!["account".to_string()]), 428 + ); 429 + let tee = FakeCreateReportTee::new(); 430 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 431 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 432 + let results = run_report_stage(&facts, &tee, default_opts()).await; 433 + 434 + assert_eq!(results[2].status, CheckStatus::SpecViolation); 435 + let diag = results[2].diagnostic.as_ref().expect("diagnostic present"); 436 + assert_eq!( 437 + diag.code().map(|c| c.to_string()), 438 + Some("labeler::report::malformed_bearer_accepted".to_string()), 439 + ); 440 + } 441 + 442 + #[tokio::test] 443 + async fn ac2_5_401_without_envelope_still_passes() { 444 + let facts = make_identity_facts( 445 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 446 + Some(vec!["account".to_string()]), 447 + ); 448 + let tee = FakeCreateReportTee::new(); 449 + // 401 with empty body — non-conformant envelope, but status still Pass per AC2.5. 450 + tee.enqueue(common::FakeCreateReportResponse::Response { 451 + status: 401, 452 + content_type: Some("application/json".to_string()), 453 + body: b"{}".to_vec(), 454 + }); 455 + tee.enqueue(common::FakeCreateReportResponse::Response { 456 + status: 401, 457 + content_type: None, 458 + body: b"<html>".to_vec(), 459 + }); 460 + let results = run_report_stage(&facts, &tee, default_opts()).await; 461 + 462 + assert_eq!(results[1].status, CheckStatus::Pass); 463 + assert!(results[1].summary.contains("non-conformant envelope")); 464 + assert_eq!(results[2].status, CheckStatus::Pass); 465 + assert!(results[2].summary.contains("non-conformant envelope")); 466 + } 467 + 468 + #[tokio::test] 469 + async fn pipeline_integration_happy_path_via_endpoint() { 470 + // Test that run_pipeline correctly orchestrates stages via endpoint target 471 + // (no DID resolution required). This exercises the full pipeline integration 472 + // rather than testing the report stage in isolation through create_report::run. 473 + 474 + // Set up minimal fakes for each I/O seam. 475 + let fake_http_tee = common::FakeRawHttpTee::new(); 476 + fake_http_tee.add_response(None, 200, br#"{"cursor":null,"labels":[]}"#.to_vec()); 477 + 478 + let fake_create_report_tee = common::FakeCreateReportTee::new(); 479 + 480 + // Use endpoint target (no identity stage needed). 481 + let target = parse_target("https://labeler.example.com", None).expect("parse endpoint target"); 482 + 483 + let opts = LabelerOptions { 484 + http: &StubHttpClient, 485 + dns: &StubDnsResolver, 486 + http_tee: HttpTee::Test(&fake_http_tee), 487 + ws_client: None, 488 + subscribe_timeout: Duration::from_secs(5), 489 + verbose: false, 490 + create_report_tee: CreateReportTeeKind::Test(&fake_create_report_tee), 491 + commit_report: false, 492 + force_self_mint: false, 493 + self_mint_curve: SelfMintCurve::Es256k, 494 + report_subject_override: None, 495 + self_mint_signer: None, 496 + pds_credentials: None, 497 + pds_xrpc_client: None, 498 + pds_xrpc_client_override: None, 499 + run_id: "test-run-id", 500 + }; 501 + 502 + let report = run_pipeline(target, opts).await; 503 + 504 + // Verify the report was generated and contains exactly 10 report:: rows in canonical order. 505 + let report_rows: Vec<_> = report 506 + .results 507 + .iter() 508 + .filter(|r| r.id.starts_with("report::")) 509 + .collect(); 510 + 511 + assert_eq!( 512 + report_rows.len(), 513 + 10, 514 + "AC7.1 requires exactly 10 report stage rows" 515 + ); 516 + 517 + let expected_ids = vec![ 518 + "report::contract_published", 519 + "report::unauthenticated_rejected", 520 + "report::malformed_bearer_rejected", 521 + "report::wrong_aud_rejected", 522 + "report::wrong_lxm_rejected", 523 + "report::expired_rejected", 524 + "report::rejected_shape_returns_400", 525 + "report::self_mint_accepted", 526 + "report::pds_service_auth_accepted", 527 + "report::pds_proxied_accepted", 528 + ]; 529 + 530 + let actual_ids: Vec<_> = report_rows.iter().map(|r| r.id).collect(); 531 + assert_eq!( 532 + actual_ids, expected_ids, 533 + "report stage rows must be in canonical order" 534 + ); 535 + } 536 + 537 + /// Helper: an IdentityFacts fixture whose labeler_endpoint is a 538 + /// localhost URL (self_mint_viable = true). 539 + fn local_identity_facts() -> IdentityFacts { 540 + let mut facts = make_identity_facts( 541 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 542 + Some(vec!["account".to_string()]), 543 + ); 544 + // Override endpoint to localhost. Exact field name per 545 + // the IdentityFacts struct. 546 + facts.labeler_endpoint = url::Url::parse("http://localhost:8080").unwrap(); 547 + facts 548 + } 549 + 550 + #[tokio::test] 551 + async fn ac3_1_wrong_aud_401_passes() { 552 + let facts = local_identity_facts(); 553 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 554 + let tee = FakeCreateReportTee::new(); 555 + // Six POSTs expected: unauthenticated, malformed, wrong_aud, 556 + // wrong_lxm, expired, rejected_shape. Enqueue each: 557 + for _ in 0..2 { 558 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); // no-JWT negative checks 559 + } 560 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 561 + "BadJwtAudience", 562 + "aud mismatch", 563 + )); 564 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 565 + "BadJwtLexiconMethod", 566 + "lxm mismatch", 567 + )); 568 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 569 + "JwtExpired", 570 + "expired", 571 + )); 572 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 573 + "InvalidRequest", 574 + "unadvertised reasonType", 575 + )); 576 + 577 + let mut opts = default_opts(); 578 + opts.self_mint_signer = Some(&signer); 579 + let results = run_report_stage(&facts, &tee, opts).await; 580 + 581 + // Rows 3-5 are AC3.1-AC3.4, row 6 is AC3.5. 582 + assert_eq!(results[3].id, "report::wrong_aud_rejected"); 583 + assert_eq!(results[3].status, CheckStatus::Pass); 584 + assert_eq!(results[4].status, CheckStatus::Pass); 585 + assert_eq!(results[5].status, CheckStatus::Pass); 586 + assert_eq!(results[6].id, "report::rejected_shape_returns_400"); 587 + assert_eq!(results[6].status, CheckStatus::Pass); 588 + } 589 + 590 + #[tokio::test] 591 + async fn ac3_2_wrong_aud_200_is_spec_violation() { 592 + let facts = local_identity_facts(); 593 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 594 + let tee = FakeCreateReportTee::new(); 595 + // Two no-JWT negative checks, then wrong_aud with 200 OK. 596 + for _ in 0..2 { 597 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 598 + } 599 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); // wrong_aud with 200 600 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 601 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 602 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 603 + "InvalidRequest", 604 + "x", 605 + )); 606 + 607 + let mut opts = default_opts(); 608 + opts.self_mint_signer = Some(&signer); 609 + let results = run_report_stage(&facts, &tee, opts).await; 610 + 611 + assert_eq!(results[3].id, "report::wrong_aud_rejected"); 612 + assert_eq!(results[3].status, CheckStatus::SpecViolation); 613 + let diag = results[3].diagnostic.as_ref().expect("diagnostic present"); 614 + assert_eq!( 615 + diag.code().map(|c| c.to_string()), 616 + Some("labeler::report::wrong_aud_accepted".to_string()), 617 + ); 618 + } 619 + 620 + #[tokio::test] 621 + async fn ac3_3_wrong_lxm_401_passes() { 622 + let facts = local_identity_facts(); 623 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 624 + let tee = FakeCreateReportTee::new(); 625 + for _ in 0..2 { 626 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 627 + } 628 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 629 + "BadJwtAudience", 630 + "x", 631 + )); 632 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 633 + "BadJwtLexiconMethod", 634 + "lxm mismatch", 635 + )); 636 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 637 + "JwtExpired", 638 + "x", 639 + )); 640 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 641 + "InvalidRequest", 642 + "x", 643 + )); 644 + 645 + let mut opts = default_opts(); 646 + opts.self_mint_signer = Some(&signer); 647 + let results = run_report_stage(&facts, &tee, opts).await; 648 + 649 + assert_eq!(results[4].id, "report::wrong_lxm_rejected"); 650 + assert_eq!(results[4].status, CheckStatus::Pass); 651 + } 652 + 653 + #[tokio::test] 654 + async fn ac3_4_wrong_lxm_200_is_spec_violation() { 655 + let facts = local_identity_facts(); 656 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 657 + let tee = FakeCreateReportTee::new(); 658 + for _ in 0..2 { 659 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 660 + } 661 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 662 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); // wrong_lxm with 200 663 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 664 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 665 + "InvalidRequest", 666 + "x", 667 + )); 668 + 669 + let mut opts = default_opts(); 670 + opts.self_mint_signer = Some(&signer); 671 + let results = run_report_stage(&facts, &tee, opts).await; 672 + 673 + assert_eq!(results[4].id, "report::wrong_lxm_rejected"); 674 + assert_eq!(results[4].status, CheckStatus::SpecViolation); 675 + let diag = results[4].diagnostic.as_ref().expect("diagnostic present"); 676 + assert_eq!( 677 + diag.code().map(|c| c.to_string()), 678 + Some("labeler::report::wrong_lxm_accepted".to_string()), 679 + ); 680 + } 681 + 682 + #[tokio::test] 683 + async fn ac3_5_expired_401_passes() { 684 + let facts = local_identity_facts(); 685 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 686 + let tee = FakeCreateReportTee::new(); 687 + for _ in 0..2 { 688 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 689 + } 690 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 691 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 692 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 693 + "JwtExpired", 694 + "expired", 695 + )); 696 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 697 + "InvalidRequest", 698 + "x", 699 + )); 700 + 701 + let mut opts = default_opts(); 702 + opts.self_mint_signer = Some(&signer); 703 + let results = run_report_stage(&facts, &tee, opts).await; 704 + 705 + assert_eq!(results[5].id, "report::expired_rejected"); 706 + assert_eq!(results[5].status, CheckStatus::Pass); 707 + } 708 + 709 + #[tokio::test] 710 + async fn ac3_6_shape_not_400_emits_advisory() { 711 + let facts = local_identity_facts(); 712 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 713 + let tee = FakeCreateReportTee::new(); 714 + for _ in 0..2 { 715 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 716 + } 717 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 718 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 719 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 720 + // rejected_shape with 401 instead of 400 → Advisory. 721 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 722 + "BadReason", 723 + "wrong status", 724 + )); 725 + 726 + let mut opts = default_opts(); 727 + opts.self_mint_signer = Some(&signer); 728 + let results = run_report_stage(&facts, &tee, opts).await; 729 + 730 + assert_eq!(results[6].id, "report::rejected_shape_returns_400"); 731 + assert_eq!(results[6].status, CheckStatus::Advisory); 732 + let diag = results[6].diagnostic.as_ref().expect("diagnostic present"); 733 + assert_eq!( 734 + diag.code().map(|c| c.to_string()), 735 + Some("labeler::report::shape_not_400".to_string()), 736 + ); 737 + } 738 + 739 + #[tokio::test] 740 + async fn ac3_7_non_local_labeler_skips_self_mint_checks() { 741 + let mut facts = make_identity_facts( 742 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 743 + Some(vec!["account".to_string()]), 744 + ); 745 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 746 + let tee = FakeCreateReportTee::new(); 747 + // Only two no-JWT negative POSTs expected (unauth + malformed). 748 + for _ in 0..2 { 749 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 750 + } 751 + let mut opts = default_opts(); 752 + opts.self_mint_signer = None; 753 + let results = run_report_stage(&facts, &tee, opts).await; 754 + for (i, result) in results.iter().enumerate().skip(3).take(4) { 755 + assert_eq!( 756 + result.status, 757 + CheckStatus::Skipped, 758 + "row {} ({})", 759 + i, 760 + result.id 761 + ); 762 + assert!( 763 + result 764 + .skipped_reason 765 + .as_deref() 766 + .unwrap() 767 + .contains("--force-self-mint"), 768 + "row {}: {:?}", 769 + i, 770 + result.skipped_reason, 771 + ); 772 + } 773 + } 774 + 775 + #[tokio::test] 776 + async fn ac3_8_force_self_mint_overrides_non_local() { 777 + let mut facts = make_identity_facts( 778 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 779 + Some(vec!["account".to_string()]), 780 + ); 781 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 782 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 783 + let tee = FakeCreateReportTee::new(); 784 + for _ in 0..2 { 785 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 786 + } 787 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 788 + "BadJwtAudience", 789 + "x", 790 + )); 791 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 792 + "BadJwtLexiconMethod", 793 + "x", 794 + )); 795 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 796 + "JwtExpired", 797 + "x", 798 + )); 799 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 800 + "InvalidRequest", 801 + "x", 802 + )); 803 + let mut opts = default_opts(); 804 + opts.self_mint_signer = Some(&signer); 805 + opts.force_self_mint = true; 806 + let results = run_report_stage(&facts, &tee, opts).await; 807 + assert_eq!(results[3].status, CheckStatus::Pass); 808 + assert_eq!(results[6].status, CheckStatus::Pass); 809 + } 810 + 811 + #[tokio::test] 812 + async fn ac4_1_local_labeler_accepts_with_lex_first_reason_and_account_subject() { 813 + let facts = local_identity_facts(); 814 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 815 + let tee = FakeCreateReportTee::new(); 816 + // Enqueue responses for the no-JWT negatives (2), the self-mint 817 + // negatives (4), then the AC4 positive. 818 + for _ in 0..2 { 819 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 820 + } 821 + for _ in 0..4 { 822 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 823 + } 824 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 825 + 826 + let mut opts = default_opts(); 827 + opts.self_mint_signer = Some(&signer); 828 + opts.commit_report = true; 829 + let run_id = "test-run-1234567890".to_string(); 830 + opts.run_id = &run_id; 831 + let results = run_report_stage(&facts, &tee, opts).await; 832 + 833 + assert_eq!(results[7].id, "report::self_mint_accepted"); 834 + assert_eq!(results[7].status, CheckStatus::Pass); 835 + 836 + // AC4.6: last_request() body contains the sentinel. 837 + let last_req = tee.last_request(); 838 + let body_reason = last_req.body["reason"].as_str().unwrap_or(""); 839 + assert!(body_reason.starts_with("atproto-devtool conformance test")); 840 + assert!(body_reason.ends_with(&run_id)); 841 + 842 + // AC4.1: reasonType is lex-first (reasonSpam), subject is account. 843 + let body = &last_req.body; 844 + assert_eq!(body["reasonType"], "com.atproto.moderation.defs#reasonSpam"); 845 + assert_eq!(body["subject"]["$type"], "com.atproto.admin.defs#repoRef"); 846 + } 847 + 848 + #[tokio::test] 849 + async fn ac4_2_non_local_labeler_prefers_other_and_record() { 850 + let mut facts = make_identity_facts( 851 + Some(vec![ 852 + "com.atproto.moderation.defs#reasonSpam".to_string(), 853 + "com.atproto.moderation.defs#reasonOther".to_string(), 854 + ]), 855 + Some(vec!["account".to_string(), "record".to_string()]), 856 + ); 857 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 858 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 859 + let tee = FakeCreateReportTee::new(); 860 + // No-JWT negatives: 2 POSTs (unauth, malformed). 861 + for _ in 0..2 { 862 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 863 + } 864 + // Self-mint negatives: 4 POSTs (wrong_aud, wrong_lxm, expired, rejected_shape). 865 + for _ in 0..4 { 866 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 867 + } 868 + // AC4: 1 POST (self_mint_accepted). 869 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 870 + let mut opts = default_opts(); 871 + opts.self_mint_signer = Some(&signer); 872 + opts.commit_report = true; 873 + opts.force_self_mint = true; // Force self-mint to enable the self-mint negatives for non-local. 874 + 875 + let results = run_report_stage(&facts, &tee, opts).await; 876 + assert_eq!(results[7].status, CheckStatus::Pass); 877 + 878 + let last_req = tee.last_request(); 879 + assert_eq!( 880 + last_req.body["reasonType"], 881 + "com.atproto.moderation.defs#reasonOther" 882 + ); 883 + assert_eq!( 884 + last_req.body["subject"]["$type"], 885 + "com.atproto.repo.strongRef" 886 + ); 887 + } 888 + 889 + #[tokio::test] 890 + async fn ac4_3_non_2xx_is_spec_violation() { 891 + let facts = local_identity_facts(); 892 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 893 + let tee = FakeCreateReportTee::new(); 894 + // 2 no-JWT negatives + 4 self-mint negatives + 1 AC4. 895 + for _ in 0..2 { 896 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 897 + } 898 + for _ in 0..4 { 899 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 900 + } 901 + tee.enqueue(common::FakeCreateReportResponse::Response { 902 + status: 400, 903 + content_type: Some("application/json".to_string()), 904 + body: br#"{"error":"InvalidRequest","message":"nope"}"#.to_vec(), 905 + }); 906 + 907 + let mut opts = default_opts(); 908 + opts.self_mint_signer = Some(&signer); 909 + opts.commit_report = true; 910 + let run_id = "x".to_string(); 911 + opts.run_id = &run_id; 912 + let results = run_report_stage(&facts, &tee, opts).await; 913 + 914 + assert_eq!(results[7].status, CheckStatus::SpecViolation); 915 + assert_eq!( 916 + results[7] 917 + .diagnostic 918 + .as_ref() 919 + .unwrap() 920 + .code() 921 + .map(|c| c.to_string()), 922 + Some("labeler::report::self_mint_rejected".to_string()), 923 + ); 924 + } 925 + 926 + #[tokio::test] 927 + async fn ac4_4_commit_false_skips() { 928 + let facts = local_identity_facts(); 929 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 930 + let tee = FakeCreateReportTee::new(); 931 + for _ in 0..2 { 932 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 933 + } 934 + for _ in 0..4 { 935 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 936 + } 937 + // No POST for self_mint_accepted because commit is false. 938 + 939 + let mut opts = default_opts(); 940 + opts.self_mint_signer = Some(&signer); 941 + opts.commit_report = false; 942 + let results = run_report_stage(&facts, &tee, opts).await; 943 + 944 + assert_eq!(results[7].status, CheckStatus::Skipped); 945 + assert!( 946 + results[7] 947 + .skipped_reason 948 + .as_deref() 949 + .unwrap() 950 + .contains("--commit-report"), 951 + "expected skip reason to mention --commit-report", 952 + ); 953 + } 954 + 955 + #[tokio::test] 956 + async fn ac4_5_non_viable_skip_matches_self_mint_viability_reason() { 957 + // When self_mint_viable=false AND commit_report=true, self_mint_accepted 958 + // is Skipped with the self-mint viability reason (already tested, retest 959 + // that the row is Skipped here for completeness). 960 + let mut facts = make_identity_facts( 961 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 962 + Some(vec!["account".to_string()]), 963 + ); 964 + facts.labeler_endpoint = url::Url::parse("https://labeler.example.com").unwrap(); 965 + let tee = FakeCreateReportTee::new(); 966 + for _ in 0..2 { 967 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 968 + } 969 + let mut opts = default_opts(); 970 + opts.self_mint_signer = None; 971 + opts.commit_report = true; 972 + let results = run_report_stage(&facts, &tee, opts).await; 973 + assert_eq!(results[7].status, CheckStatus::Skipped); 974 + } 975 + 976 + #[tokio::test] 977 + async fn ac4_2xx_without_id_is_pass_with_note() { 978 + let facts = local_identity_facts(); 979 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 980 + let tee = FakeCreateReportTee::new(); 981 + // Enqueue responses for the no-JWT negatives (2), self-mint negatives (4), then the AC4 positive with missing ID. 982 + for _ in 0..2 { 983 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 984 + } 985 + for _ in 0..4 { 986 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 987 + } 988 + // 2xx response with body that doesn't contain a valid numeric ID. 989 + tee.enqueue(common::FakeCreateReportResponse::Response { 990 + status: 200, 991 + content_type: Some("application/json".to_string()), 992 + body: b"{\"foo\":\"bar\"}".to_vec(), 993 + }); 994 + 995 + let mut opts = default_opts(); 996 + opts.self_mint_signer = Some(&signer); 997 + opts.commit_report = true; 998 + let results = run_report_stage(&facts, &tee, opts).await; 999 + 1000 + // Should be Pass (2xx is sufficient), but with a note about the body. 1001 + assert_eq!(results[7].id, "report::self_mint_accepted"); 1002 + assert_eq!(results[7].status, CheckStatus::Pass); 1003 + assert!( 1004 + results[7].summary.contains("body did not match"), 1005 + "expected summary to mention non-matching body, got: {}", 1006 + results[7].summary 1007 + ); 1008 + } 1009 + 1010 + // Task 4 tests: AC5 and AC6 PDS-mode integration tests 1011 + 1012 + #[tokio::test] 1013 + async fn ac5_1_full_flow_passes() { 1014 + let facts = local_identity_facts(); 1015 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 1016 + let tee = FakeCreateReportTee::new(); 1017 + let pds_client = common::FakePdsXrpcClient::new(); 1018 + 1019 + // Queue responses for labeler tee: 1020 + // No-JWT negatives (2): unauth_rejected, malformed_bearer_rejected. 1021 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1022 + "AuthenticationRequired", 1023 + "jwt required", 1024 + )); 1025 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1026 + "BadJwt", 1027 + "invalid bearer", 1028 + )); 1029 + // Self-mint negatives (4): wrong_aud_rejected, wrong_lxm_rejected, expired_rejected, rejected_shape_returns_400. 1030 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1031 + "BadJwtAudience", 1032 + "aud mismatch", 1033 + )); 1034 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1035 + "BadJwtLexiconMethod", 1036 + "lxm mismatch", 1037 + )); 1038 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1039 + "JwtExpired", 1040 + "expired", 1041 + )); 1042 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 1043 + "InvalidRequest", 1044 + "unadvertised reasonType", 1045 + )); 1046 + // Self-mint positive (1): self_mint_accepted. 1047 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1048 + // PDS service-auth (1): pds_service_auth_accepted (service-auth mode POST). 1049 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1050 + 1051 + // Queue PDS responses: 1052 + // 1. createSession (for fetch_session_and_did). 1053 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1054 + status: 200, 1055 + body: serde_json::json!({ 1056 + "accessJwt": "test-jwt-token", 1057 + "did": "did:plc:test-user-did" 1058 + }) 1059 + .to_string() 1060 + .into_bytes(), 1061 + }); 1062 + // 2. getServiceAuth (for PdsJwtFetcher::fetch_with_jwt). 1063 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1064 + status: 200, 1065 + body: serde_json::json!({ 1066 + "token": "service-auth-jwt" 1067 + }) 1068 + .to_string() 1069 + .into_bytes(), 1070 + }); 1071 + // 3. PDS-proxied POST to /xrpc/com.atproto.moderation.createReport returns 200. 1072 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1073 + status: 200, 1074 + body: serde_json::json!({ "id": "report-id-123" }) 1075 + .to_string() 1076 + .into_bytes(), 1077 + }); 1078 + 1079 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1080 + handle: "alice.bsky.social".to_string(), 1081 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1082 + }; 1083 + let mut opts = default_opts(); 1084 + opts.self_mint_signer = Some(&signer); 1085 + opts.pds_credentials = Some(&pds_creds); 1086 + opts.pds_xrpc_client = Some(&pds_client); 1087 + opts.commit_report = true; 1088 + let run_id = "test-run-1234567890".to_string(); 1089 + opts.run_id = &run_id; 1090 + 1091 + let results = run_report_stage(&facts, &tee, opts).await; 1092 + 1093 + assert_eq!(results.len(), 10, "AC7.1 requires exactly 10 rows"); 1094 + assert_eq!(results[7].id, "report::self_mint_accepted"); 1095 + assert_eq!(results[7].status, CheckStatus::Pass); 1096 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1097 + assert_eq!(results[8].status, CheckStatus::Pass); 1098 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1099 + assert_eq!(results[9].status, CheckStatus::Pass); 1100 + } 1101 + 1102 + #[tokio::test] 1103 + async fn ac5_2_labeler_rejects_service_auth_jwt() { 1104 + let facts = local_identity_facts(); 1105 + let tee = FakeCreateReportTee::new(); 1106 + let pds_client = common::FakePdsXrpcClient::new(); 1107 + 1108 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive): 7 unauthorized. 1109 + for _ in 0..7 { 1110 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1111 + } 1112 + 1113 + // PDS: createSession (for fetch_session_and_did) OK. 1114 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1115 + status: 200, 1116 + body: serde_json::json!({ 1117 + "accessJwt": "test-jwt-token", 1118 + "did": "did:plc:test-user-did" 1119 + }) 1120 + .to_string() 1121 + .into_bytes(), 1122 + }); 1123 + // PDS: getServiceAuth OK. 1124 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1125 + status: 200, 1126 + body: serde_json::json!({ 1127 + "token": "service-auth-jwt" 1128 + }) 1129 + .to_string() 1130 + .into_bytes(), 1131 + }); 1132 + // PDS-proxied POST to /xrpc/com.atproto.moderation.createReport also rejects. 1133 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1134 + status: 401, 1135 + body: serde_json::json!({ "error": "Unauthorized" }) 1136 + .to_string() 1137 + .into_bytes(), 1138 + }); 1139 + 1140 + // Labeler rejects service-auth JWT with 401. 1141 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1142 + "rejected", 1143 + "service auth jwt rejected", 1144 + )); 1145 + 1146 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1147 + handle: "alice.bsky.social".to_string(), 1148 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1149 + }; 1150 + let mut opts = default_opts(); 1151 + opts.pds_credentials = Some(&pds_creds); 1152 + opts.pds_xrpc_client = Some(&pds_client); 1153 + opts.commit_report = true; 1154 + 1155 + let results = run_report_stage(&facts, &tee, opts).await; 1156 + 1157 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1158 + assert_eq!(results[8].status, CheckStatus::SpecViolation); 1159 + // Check that a diagnostic is present. 1160 + assert!( 1161 + results[8].diagnostic.is_some(), 1162 + "expected diagnostic for pds_service_auth_rejected" 1163 + ); 1164 + } 1165 + 1166 + #[tokio::test] 1167 + async fn ac5_3_pds_unreachable() { 1168 + let facts = local_identity_facts(); 1169 + let tee = FakeCreateReportTee::new(); 1170 + let pds_client = common::FakePdsXrpcClient::new(); 1171 + 1172 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1173 + for _ in 0..7 { 1174 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1175 + } 1176 + 1177 + // PDS createSession fails with transport error. 1178 + pds_client.enqueue(common::FakePdsXrpcResponse::Transport { 1179 + message: "connection refused".to_string(), 1180 + }); 1181 + 1182 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1183 + handle: "alice.bsky.social".to_string(), 1184 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1185 + }; 1186 + let mut opts = default_opts(); 1187 + opts.pds_credentials = Some(&pds_creds); 1188 + opts.pds_xrpc_client = Some(&pds_client); 1189 + opts.commit_report = true; 1190 + 1191 + let results = run_report_stage(&facts, &tee, opts).await; 1192 + 1193 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1194 + assert_eq!(results[8].status, CheckStatus::NetworkError); 1195 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1196 + assert_eq!(results[9].status, CheckStatus::NetworkError); 1197 + } 1198 + 1199 + #[tokio::test] 1200 + async fn ac5_4_missing_creds_or_commit_skips() { 1201 + // Test with missing credentials: should skip both PDS checks. 1202 + let facts = local_identity_facts(); 1203 + let tee = FakeCreateReportTee::new(); 1204 + 1205 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1206 + for _ in 0..7 { 1207 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1208 + } 1209 + 1210 + let mut opts = default_opts(); 1211 + opts.pds_credentials = None; // No credentials. 1212 + opts.commit_report = true; 1213 + 1214 + let results = run_report_stage(&facts, &tee, opts).await; 1215 + 1216 + assert_eq!(results[8].id, "report::pds_service_auth_accepted"); 1217 + assert_eq!(results[8].status, CheckStatus::Skipped); 1218 + assert_eq!( 1219 + results[8].skipped_reason, 1220 + Some(Cow::Borrowed( 1221 + "requires --handle, --app-password, and --commit-report" 1222 + )) 1223 + ); 1224 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1225 + assert_eq!(results[9].status, CheckStatus::Skipped); 1226 + } 1227 + 1228 + #[tokio::test] 1229 + async fn ac6_1_proxied_pass() { 1230 + let facts = local_identity_facts(); 1231 + let tee = FakeCreateReportTee::new(); 1232 + let pds_client = common::FakePdsXrpcClient::new(); 1233 + 1234 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1235 + for _ in 0..7 { 1236 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1237 + } 1238 + 1239 + // PDS: createSession (for fetch_session_and_did) OK. 1240 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1241 + status: 200, 1242 + body: serde_json::json!({ 1243 + "accessJwt": "test-jwt-token", 1244 + "did": "did:plc:test-user-did" 1245 + }) 1246 + .to_string() 1247 + .into_bytes(), 1248 + }); 1249 + // PDS: getServiceAuth OK (for service-auth mode). 1250 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1251 + status: 200, 1252 + body: serde_json::json!({ 1253 + "token": "service-auth-jwt" 1254 + }) 1255 + .to_string() 1256 + .into_bytes(), 1257 + }); 1258 + // PDS-proxied POST to /xrpc/com.atproto.moderation.createReport succeeds. 1259 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1260 + status: 200, 1261 + body: serde_json::json!({ "id": "report-id-123" }) 1262 + .to_string() 1263 + .into_bytes(), 1264 + }); 1265 + 1266 + // Service-auth mode POST succeeds. 1267 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1268 + 1269 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1270 + handle: "alice.bsky.social".to_string(), 1271 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1272 + }; 1273 + let mut opts = default_opts(); 1274 + opts.pds_credentials = Some(&pds_creds); 1275 + opts.pds_xrpc_client = Some(&pds_client); 1276 + opts.commit_report = true; 1277 + 1278 + let results = run_report_stage(&facts, &tee, opts).await; 1279 + 1280 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1281 + assert_eq!(results[9].status, CheckStatus::Pass); 1282 + } 1283 + 1284 + #[tokio::test] 1285 + async fn ac6_2_labeler_side_rejection_via_proxy() { 1286 + let facts = local_identity_facts(); 1287 + let tee = FakeCreateReportTee::new(); 1288 + let pds_client = common::FakePdsXrpcClient::new(); 1289 + 1290 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1291 + for _ in 0..7 { 1292 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1293 + } 1294 + 1295 + // PDS: createSession (for fetch_session_and_did) OK. 1296 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1297 + status: 200, 1298 + body: serde_json::json!({ 1299 + "accessJwt": "test-jwt-token", 1300 + "did": "did:plc:test-user-did" 1301 + }) 1302 + .to_string() 1303 + .into_bytes(), 1304 + }); 1305 + // PDS: getServiceAuth OK. 1306 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1307 + status: 200, 1308 + body: serde_json::json!({ 1309 + "token": "service-auth-jwt" 1310 + }) 1311 + .to_string() 1312 + .into_bytes(), 1313 + }); 1314 + // Proxied POST returns 502 with UpstreamError (labeler-side rejection via PDS). 1315 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1316 + status: 502, 1317 + body: serde_json::json!({ "error": "UpstreamError", "message": "labeler rejected" }) 1318 + .to_string() 1319 + .into_bytes(), 1320 + }); 1321 + 1322 + // Service-auth mode POST succeeds. 1323 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1324 + 1325 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1326 + handle: "alice.bsky.social".to_string(), 1327 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1328 + }; 1329 + let mut opts = default_opts(); 1330 + opts.pds_credentials = Some(&pds_creds); 1331 + opts.pds_xrpc_client = Some(&pds_client); 1332 + opts.commit_report = true; 1333 + 1334 + let results = run_report_stage(&facts, &tee, opts).await; 1335 + 1336 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1337 + assert_eq!(results[9].status, CheckStatus::SpecViolation); 1338 + // Check that a diagnostic is present. 1339 + assert!( 1340 + results[9].diagnostic.is_some(), 1341 + "expected diagnostic for pds_proxied_rejected" 1342 + ); 1343 + } 1344 + 1345 + #[tokio::test] 1346 + async fn ac6_3_pds_rejects_proxy() { 1347 + let facts = local_identity_facts(); 1348 + let tee = FakeCreateReportTee::new(); 1349 + let pds_client = common::FakePdsXrpcClient::new(); 1350 + 1351 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1352 + for _ in 0..7 { 1353 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1354 + } 1355 + 1356 + // PDS: createSession (for fetch_session_and_did) OK. 1357 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1358 + status: 200, 1359 + body: serde_json::json!({ 1360 + "accessJwt": "test-jwt-token", 1361 + "did": "did:plc:test-user-did" 1362 + }) 1363 + .to_string() 1364 + .into_bytes(), 1365 + }); 1366 + // PDS: getServiceAuth OK. 1367 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1368 + status: 200, 1369 + body: serde_json::json!({ 1370 + "token": "service-auth-jwt" 1371 + }) 1372 + .to_string() 1373 + .into_bytes(), 1374 + }); 1375 + // Proxied POST: PDS rejects with 400 InvalidRequest (not an upstream error). 1376 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1377 + status: 400, 1378 + body: serde_json::json!({ "error": "InvalidRequest", "message": "bad request" }) 1379 + .to_string() 1380 + .into_bytes(), 1381 + }); 1382 + 1383 + // Service-auth mode POST succeeds. 1384 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1385 + 1386 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1387 + handle: "alice.bsky.social".to_string(), 1388 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1389 + }; 1390 + let mut opts = default_opts(); 1391 + opts.pds_credentials = Some(&pds_creds); 1392 + opts.pds_xrpc_client = Some(&pds_client); 1393 + opts.commit_report = true; 1394 + 1395 + let results = run_report_stage(&facts, &tee, opts).await; 1396 + 1397 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1398 + assert_eq!(results[9].status, CheckStatus::NetworkError); 1399 + } 1400 + 1401 + #[tokio::test] 1402 + async fn ac6_4_missing_creds_or_commit_skips() { 1403 + // Test that proxied mode also skips when credentials missing. 1404 + let facts = local_identity_facts(); 1405 + let tee = FakeCreateReportTee::new(); 1406 + 1407 + // Queue responses for the 7 labeler-only checks (2 no-JWT + 4 self-mint negatives + 1 self-mint positive). 1408 + for _ in 0..7 { 1409 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1410 + } 1411 + 1412 + let mut opts = default_opts(); 1413 + opts.pds_credentials = None; 1414 + opts.commit_report = false; // Missing commit-report. 1415 + 1416 + let results = run_report_stage(&facts, &tee, opts).await; 1417 + 1418 + assert_eq!(results[9].id, "report::pds_proxied_accepted"); 1419 + assert_eq!(results[9].status, CheckStatus::Skipped); 1420 + assert_eq!( 1421 + results[9].skipped_reason, 1422 + Some(Cow::Borrowed( 1423 + "requires --handle, --app-password, and --commit-report" 1424 + )) 1425 + ); 1426 + } 1427 + 1428 + // Task 5 tests: AC8.3 subject override 1429 + 1430 + #[tokio::test] 1431 + async fn ac8_3_report_subject_did_overrides_subject() { 1432 + // Verify that --report-subject-did overrides the computed subject DID. 1433 + let facts = local_identity_facts(); 1434 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 1435 + let tee = FakeCreateReportTee::new(); 1436 + 1437 + // Enqueue responses for the 2 no-JWT negative checks. 1438 + for _ in 0..2 { 1439 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1440 + } 1441 + // Enqueue responses for the 4 self-mint negative checks. 1442 + for _ in 0..4 { 1443 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1444 + } 1445 + // Enqueue response for AC4 positive check. 1446 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1447 + 1448 + let override_did = Did("did:plc:override_test_subject".to_string()); 1449 + let mut opts = default_opts(); 1450 + opts.self_mint_signer = Some(&signer); 1451 + opts.commit_report = true; 1452 + opts.report_subject_override = Some(&override_did); 1453 + 1454 + let results = run_report_stage(&facts, &tee, opts).await; 1455 + 1456 + // AC4 check should pass. 1457 + assert_eq!(results[7].status, CheckStatus::Pass); 1458 + 1459 + // Verify that the last request (self_mint_accepted) has the override subject. 1460 + let last_req = tee.last_request(); 1461 + let body = &last_req.body; 1462 + assert_eq!( 1463 + body["subject"]["did"].as_str().unwrap(), 1464 + "did:plc:override_test_subject", 1465 + "expected subject to use override DID" 1466 + ); 1467 + } 1468 + 1469 + // Task 6 tests: End-to-end snapshots and AC7.1/AC8.4 1470 + 1471 + #[tokio::test] 1472 + async fn ac7_1_row_count_is_always_10() { 1473 + // Re-verify that the report stage always emits exactly 10 rows regardless of flag configuration. 1474 + let facts = make_identity_facts( 1475 + Some(vec!["com.atproto.moderation.defs#reasonSpam".to_string()]), 1476 + Some(vec!["account".to_string()]), 1477 + ); 1478 + 1479 + // Test with different flag combinations covering the main axes. 1480 + let test_cases = vec![ 1481 + (false, false, false), // no commit, no force, no PDS 1482 + (true, false, false), // commit only 1483 + (false, true, false), // force-self-mint only 1484 + (true, true, false), // commit + force 1485 + (true, false, true), // commit + PDS 1486 + (true, true, true), // commit + force + PDS 1487 + ]; 1488 + 1489 + for (commit, force, pds) in test_cases { 1490 + let tee = FakeCreateReportTee::new(); 1491 + // Queue minimal responses. 1492 + for _ in 0..2 { 1493 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1494 + } 1495 + for _ in 0..4 { 1496 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1497 + } 1498 + for _ in 0..4 { 1499 + tee.enqueue(common::FakeCreateReportResponse::unauthorized("x", "y")); 1500 + } 1501 + 1502 + let mut opts = default_opts(); 1503 + opts.commit_report = commit; 1504 + opts.force_self_mint = force; 1505 + opts.self_mint_signer = if force { 1506 + // Would need actual signer, but the point is to test row count. 1507 + // For this test we just need to verify rows are emitted. 1508 + None 1509 + } else { 1510 + None 1511 + }; 1512 + // Toggle PDS credentials presence to test PDS-mode gating. 1513 + if !pds { 1514 + opts.pds_credentials = None; 1515 + opts.pds_xrpc_client = None; 1516 + } 1517 + 1518 + let results = run_report_stage(&facts, &tee, opts).await; 1519 + assert_eq!( 1520 + results.len(), 1521 + 10, 1522 + "AC7.1 failed: expected 10 rows with commit={commit}, force={force}, pds={pds}" 1523 + ); 1524 + // Verify Check::ORDER is respected. 1525 + let expected_ids: Vec<_> = Check::ORDER.iter().map(|c| c.id()).collect(); 1526 + let actual_ids: Vec<_> = results.iter().map(|r| r.id).collect(); 1527 + assert_eq!(actual_ids, expected_ids, "Check order mismatch"); 1528 + } 1529 + } 1530 + 1531 + #[tokio::test] 1532 + async fn report_all_pass_local_labeler_snapshot() { 1533 + // E2E test: local labeler, self-mint signer, commit=true, NO PDS credentials. 1534 + // Expected: 8 Pass + 2 Skipped (PDS not provided). 1535 + let facts = local_identity_facts(); 1536 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 1537 + let tee = FakeCreateReportTee::new(); 1538 + 1539 + // Queue responses for the no-JWT negatives (2), self-mint negatives (4), self-mint positive (1). 1540 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1541 + "AuthenticationRequired", 1542 + "jwt required", 1543 + )); 1544 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1545 + "BadJwt", 1546 + "invalid bearer", 1547 + )); 1548 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1549 + "BadJwtAudience", 1550 + "aud mismatch", 1551 + )); 1552 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1553 + "BadJwtLexiconMethod", 1554 + "lxm mismatch", 1555 + )); 1556 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1557 + "JwtExpired", 1558 + "expired", 1559 + )); 1560 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 1561 + "InvalidRequest", 1562 + "unadvertised reasonType", 1563 + )); 1564 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1565 + 1566 + let mut opts = default_opts(); 1567 + opts.self_mint_signer = Some(&signer); 1568 + opts.commit_report = true; 1569 + opts.pds_credentials = None; 1570 + let run_id = "test-run-1234567890".to_string(); 1571 + opts.run_id = &run_id; 1572 + 1573 + let results = run_report_stage(&facts, &tee, opts).await; 1574 + let rendered = render_results_to_string_with_facts(results, &facts).await; 1575 + insta::assert_snapshot!("report_all_pass_local_labeler_snapshot", rendered); 1576 + } 1577 + 1578 + #[tokio::test] 1579 + async fn report_all_pass_full_suite_snapshot() { 1580 + // E2E test: local labeler, self-mint signer, commit=true, WITH PDS credentials. 1581 + // Expected: all 10 Pass. 1582 + let facts = local_identity_facts(); 1583 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 1584 + let tee = FakeCreateReportTee::new(); 1585 + let pds_client = common::FakePdsXrpcClient::new(); 1586 + 1587 + // Queue labeler responses: no-JWT negatives (2), self-mint negatives (4), self-mint positive (1), PDS service-auth (1). 1588 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1589 + "AuthenticationRequired", 1590 + "jwt required", 1591 + )); 1592 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1593 + "BadJwt", 1594 + "invalid bearer", 1595 + )); 1596 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1597 + "BadJwtAudience", 1598 + "aud mismatch", 1599 + )); 1600 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1601 + "BadJwtLexiconMethod", 1602 + "lxm mismatch", 1603 + )); 1604 + tee.enqueue(common::FakeCreateReportResponse::unauthorized( 1605 + "JwtExpired", 1606 + "expired", 1607 + )); 1608 + tee.enqueue(common::FakeCreateReportResponse::bad_request( 1609 + "InvalidRequest", 1610 + "unadvertised reasonType", 1611 + )); 1612 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1613 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1614 + 1615 + // Queue PDS responses. 1616 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1617 + status: 200, 1618 + body: serde_json::json!({ 1619 + "accessJwt": "test-jwt-token", 1620 + "did": "did:plc:test-user-did" 1621 + }) 1622 + .to_string() 1623 + .into_bytes(), 1624 + }); 1625 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1626 + status: 200, 1627 + body: serde_json::json!({ 1628 + "token": "service-auth-jwt" 1629 + }) 1630 + .to_string() 1631 + .into_bytes(), 1632 + }); 1633 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1634 + status: 200, 1635 + body: serde_json::json!({ "id": "report-id-123" }) 1636 + .to_string() 1637 + .into_bytes(), 1638 + }); 1639 + 1640 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1641 + handle: "alice.bsky.social".to_string(), 1642 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1643 + }; 1644 + let mut opts = default_opts(); 1645 + opts.self_mint_signer = Some(&signer); 1646 + opts.pds_credentials = Some(&pds_creds); 1647 + opts.pds_xrpc_client = Some(&pds_client); 1648 + opts.commit_report = true; 1649 + let run_id = "test-run-1234567890".to_string(); 1650 + opts.run_id = &run_id; 1651 + 1652 + let results = run_report_stage(&facts, &tee, opts).await; 1653 + let rendered = render_results_to_string_with_facts(results, &facts).await; 1654 + insta::assert_snapshot!("report_all_pass_full_suite_snapshot", rendered); 1655 + } 1656 + 1657 + #[tokio::test] 1658 + async fn report_all_fail_misconfigured_labeler_snapshot() { 1659 + // E2E test: labeler accepts everything (200 for every POST). 1660 + // Expected: contract_published Pass, all JWT checks SpecViolation, 1661 + // rejected_shape Advisory (200 when 400 expected), self_mint Pass, 1662 + // pds_service_auth Pass, pds_proxied Pass. 1663 + let facts = local_identity_facts(); 1664 + let signer = SelfMintSigner::spawn(SelfMintCurve::Es256k).await.unwrap(); 1665 + let tee = FakeCreateReportTee::new(); 1666 + let pds_client = common::FakePdsXrpcClient::new(); 1667 + 1668 + // Queue labeler responses: all 8 ok_empty (misconfigured to accept everything). 1669 + for _ in 0..8 { 1670 + tee.enqueue(common::FakeCreateReportResponse::ok_empty()); 1671 + } 1672 + 1673 + // Queue PDS responses. 1674 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1675 + status: 200, 1676 + body: serde_json::json!({ 1677 + "accessJwt": "test-jwt-token", 1678 + "did": "did:plc:test-user-did" 1679 + }) 1680 + .to_string() 1681 + .into_bytes(), 1682 + }); 1683 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1684 + status: 200, 1685 + body: serde_json::json!({ 1686 + "token": "service-auth-jwt" 1687 + }) 1688 + .to_string() 1689 + .into_bytes(), 1690 + }); 1691 + pds_client.enqueue(common::FakePdsXrpcResponse::Response { 1692 + status: 200, 1693 + body: serde_json::json!({ "id": "report-id-123" }) 1694 + .to_string() 1695 + .into_bytes(), 1696 + }); 1697 + 1698 + let pds_creds = atproto_devtool::commands::test::labeler::pipeline::PdsCredentials { 1699 + handle: "alice.bsky.social".to_string(), 1700 + app_password: "xxxx-xxxx-xxxx-xxxx".to_string(), 1701 + }; 1702 + let mut opts = default_opts(); 1703 + opts.self_mint_signer = Some(&signer); 1704 + opts.pds_credentials = Some(&pds_creds); 1705 + opts.pds_xrpc_client = Some(&pds_client); 1706 + opts.commit_report = true; 1707 + let run_id = "test-run-1234567890".to_string(); 1708 + opts.run_id = &run_id; 1709 + 1710 + let results = run_report_stage(&facts, &tee, opts).await; 1711 + let rendered = render_results_to_string_with_facts(results, &facts).await; 1712 + insta::assert_snapshot!("report_all_fail_misconfigured_labeler_snapshot", rendered); 1713 + }
+90 -1
tests/labeler_subscription.rs
··· 7 7 8 8 mod common; 9 9 10 + use atproto_devtool::commands::test::labeler::create_report::self_mint::SelfMintCurve; 10 11 use atproto_devtool::commands::test::labeler::pipeline::{ 11 - HttpTee, LabelerOptions, parse_target, run_pipeline, 12 + CreateReportTeeKind, HttpTee, LabelerOptions, parse_target, run_pipeline, 12 13 }; 13 14 use atproto_devtool::commands::test::labeler::subscription::{FrameHeader, SubscribeLabelsPayload}; 14 15 use atproto_devtool::common::identity::{DnsResolver, HttpClient, IdentityError}; ··· 244 245 "tests/fixtures/labeler/subscription/backfill_complete/frames.bin", 245 246 ); 246 247 let fake_ws = common::FakeWebSocketClient::new(); 248 + let fake_report_tee = common::FakeCreateReportTee::new(); 247 249 248 250 // Add first connection (backfill): 3 frames with 10ms delay, then 600ms idle gap. 249 251 fake_ws.add_script(common::FakeScript { ··· 266 268 ws_client: Some(&fake_ws), 267 269 subscribe_timeout: Duration::from_secs(2), 268 270 verbose: false, 271 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 272 + commit_report: false, 273 + force_self_mint: false, 274 + self_mint_curve: SelfMintCurve::Es256k, 275 + report_subject_override: None, 276 + self_mint_signer: None, 277 + pds_credentials: None, 278 + pds_xrpc_client: None, 279 + pds_xrpc_client_override: None, 280 + run_id: "test-run-id", 269 281 }; 270 282 271 283 let report = run_pipeline(target, opts).await; ··· 280 292 "tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin", 281 293 ); 282 294 let fake_ws = common::FakeWebSocketClient::new(); 295 + let fake_report_tee = common::FakeCreateReportTee::new(); 283 296 284 297 // First connection (backfill): 20 frames @ 150ms delay = 3s total (exceeds 2s budget). 285 298 fake_ws.add_script(common::FakeScript { ··· 314 327 ws_client: Some(&fake_ws), 315 328 subscribe_timeout: Duration::from_secs(2), 316 329 verbose: false, 330 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 331 + commit_report: false, 332 + force_self_mint: false, 333 + self_mint_curve: SelfMintCurve::Es256k, 334 + report_subject_override: None, 335 + self_mint_signer: None, 336 + pds_credentials: None, 337 + pds_xrpc_client: None, 338 + pds_xrpc_client_override: None, 339 + run_id: "test-run-id", 317 340 }; 318 341 319 342 let report = run_pipeline(target, opts).await; ··· 325 348 #[tokio::test(flavor = "current_thread", start_paused = true)] 326 349 async fn empty_stream_advisories() { 327 350 let fake_ws = common::FakeWebSocketClient::new(); 351 + let fake_report_tee = common::FakeCreateReportTee::new(); 328 352 329 353 // Single connection (backfill): empty stream returns no frames immediately, 330 354 // so backfill outcome is NoFramesWithinBudget and live-tail is skipped. ··· 348 372 ws_client: Some(&fake_ws), 349 373 subscribe_timeout: Duration::from_secs(2), 350 374 verbose: false, 375 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 376 + commit_report: false, 377 + force_self_mint: false, 378 + self_mint_curve: SelfMintCurve::Es256k, 379 + report_subject_override: None, 380 + self_mint_signer: None, 381 + pds_credentials: None, 382 + pds_xrpc_client: None, 383 + pds_xrpc_client_override: None, 384 + run_id: "test-run-id", 351 385 }; 352 386 353 387 let report = run_pipeline(target, opts).await; ··· 361 395 let frames = 362 396 load_frames_from_fixture("tests/fixtures/labeler/subscription/malformed_frame/frames.bin"); 363 397 let fake_ws = common::FakeWebSocketClient::new(); 398 + let fake_report_tee = common::FakeCreateReportTee::new(); 364 399 365 400 // First connection (backfill): frames then stream close. 366 401 fake_ws.add_script(common::FakeScript { ··· 393 428 ws_client: Some(&fake_ws), 394 429 subscribe_timeout: Duration::from_secs(2), 395 430 verbose: false, 431 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 432 + commit_report: false, 433 + force_self_mint: false, 434 + self_mint_curve: SelfMintCurve::Es256k, 435 + report_subject_override: None, 436 + self_mint_signer: None, 437 + pds_credentials: None, 438 + pds_xrpc_client: None, 439 + pds_xrpc_client_override: None, 440 + run_id: "test-run-id", 396 441 }; 397 442 398 443 let report = run_pipeline(target, opts).await; ··· 407 452 "tests/fixtures/labeler/subscription/error_frame_malformed/frames.bin", 408 453 ); 409 454 let fake_ws = common::FakeWebSocketClient::new(); 455 + let fake_report_tee = common::FakeCreateReportTee::new(); 410 456 411 457 // First connection (backfill): frames then stream close. 412 458 fake_ws.add_script(common::FakeScript { ··· 439 485 ws_client: Some(&fake_ws), 440 486 subscribe_timeout: Duration::from_secs(2), 441 487 verbose: false, 488 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 489 + commit_report: false, 490 + force_self_mint: false, 491 + self_mint_curve: SelfMintCurve::Es256k, 492 + report_subject_override: None, 493 + self_mint_signer: None, 494 + pds_credentials: None, 495 + pds_xrpc_client: None, 496 + pds_xrpc_client_override: None, 497 + run_id: "test-run-id", 442 498 }; 443 499 444 500 let report = run_pipeline(target, opts).await; ··· 450 506 #[tokio::test(flavor = "current_thread", start_paused = true)] 451 507 async fn unreachable_endpoint_network_error() { 452 508 let fake_ws = common::FakeWebSocketClient::new(); 509 + let fake_report_tee = common::FakeCreateReportTee::new(); 453 510 454 511 // First connection returns transport error. 455 512 fake_ws.add_script(common::FakeScript { ··· 472 529 ws_client: Some(&fake_ws), 473 530 subscribe_timeout: Duration::from_secs(2), 474 531 verbose: false, 532 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 533 + commit_report: false, 534 + force_self_mint: false, 535 + self_mint_curve: SelfMintCurve::Es256k, 536 + report_subject_override: None, 537 + self_mint_signer: None, 538 + pds_credentials: None, 539 + pds_xrpc_client: None, 540 + pds_xrpc_client_override: None, 541 + run_id: "test-run-id", 475 542 }; 476 543 477 544 let report = run_pipeline(target, opts).await; ··· 486 553 "tests/fixtures/labeler/subscription/backfill_exceeds_budget/frames.bin", 487 554 ); 488 555 let fake_ws = common::FakeWebSocketClient::new(); 556 + let fake_report_tee = common::FakeCreateReportTee::new(); 489 557 490 558 // First connection (backfill): 20 frames @ 150ms delay = 3s total (exceeds 2s budget). 491 559 fake_ws.add_script(common::FakeScript { ··· 517 585 ws_client: Some(&fake_ws), 518 586 subscribe_timeout: Duration::from_secs(2), 519 587 verbose: false, 588 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 589 + commit_report: false, 590 + force_self_mint: false, 591 + self_mint_curve: SelfMintCurve::Es256k, 592 + report_subject_override: None, 593 + self_mint_signer: None, 594 + pds_credentials: None, 595 + pds_xrpc_client: None, 596 + pds_xrpc_client_override: None, 597 + run_id: "test-run-id", 520 598 }; 521 599 522 600 let report = run_pipeline(target, opts).await; ··· 549 627 } 550 628 551 629 let fake_ws = common::FakeWebSocketClient::new(); 630 + let fake_report_tee = common::FakeCreateReportTee::new(); 552 631 553 632 // Single connection (backfill): 2 frames (10ms apart), then transport error, then final_wait 554 633 // to exhaust idle-gap timer from last frame. The idle-gap should fire from the second frame, ··· 573 652 ws_client: Some(&fake_ws), 574 653 subscribe_timeout: Duration::from_secs(2), 575 654 verbose: false, 655 + create_report_tee: CreateReportTeeKind::Test(&fake_report_tee), 656 + commit_report: false, 657 + force_self_mint: false, 658 + self_mint_curve: SelfMintCurve::Es256k, 659 + report_subject_override: None, 660 + self_mint_signer: None, 661 + pds_credentials: None, 662 + pds_xrpc_client: None, 663 + pds_xrpc_client_override: None, 664 + run_id: "test-run-id", 576 665 }; 577 666 578 667 let report = run_pipeline(target, opts).await;
+13 -2
tests/snapshots/labeler_endtoend__all_pass_exits_zero_and_renders_all_ok.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 28 28 [SKIP] Subscription live-tail skipped — labeler has no published labels 29 29 == Crypto == 30 30 [OK] 1 labels verified against current key 31 + == Report == 32 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 33 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 34 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 35 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 37 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 38 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 39 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 40 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 31 42 32 - Summary: 13 passed, 0 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 0 43 + Summary: 13 passed, 0 failed (spec), 0 network errors, 1 advisories, 12 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_endtoend__canonicalization_error_distinct_diagnostic_code.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 40 40 labeler::crypto::label_verification_failed_no_history 41 41 42 42 × label at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1 failed verification against current key "#atproto_label" and PLC history could not be consulted 43 + == Report == 44 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 45 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 46 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 47 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 48 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 49 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 50 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 51 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 52 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 53 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 43 54 44 - Summary: 12 passed, 2 failed (spec), 1 network errors, 1 advisories, 2 skipped. Exit code: 1 55 + Summary: 12 passed, 2 failed (spec), 1 network errors, 1 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_endtoend__current_key_fail_history_also_fail_exits_one.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 31 31 labeler::crypto::multi_key_verification_failed 32 32 33 33 × some labels could not be verified against any of the 2 tried key id(s): ["zQ3shS9i8ufXsDMmNUWAzJDryVeJeQjh2cQNVA6Sc3r9W8wnv", "zQ3shRUke9kEV715U9eWXBSYLw8TGmg2qm87uQidBGexviaFA"] 34 + == Report == 35 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 36 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 37 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 38 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 39 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 40 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 41 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 42 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 43 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 44 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 34 45 35 - Summary: 12 passed, 1 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1 46 + Summary: 12 passed, 1 failed (spec), 0 network errors, 1 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_endtoend__current_key_fail_history_pass_exits_zero_with_advisory.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 29 29 == Crypto == 30 30 [WARN] 1 label(s) signed by a rotated-out key (1 distinct key id(s)) 31 31 [OK] All labels verified with current or historic keys 32 + == Report == 33 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 34 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 35 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 37 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 38 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 39 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 40 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 42 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 32 43 33 - Summary: 13 passed, 0 failed (spec), 0 network errors, 2 advisories, 2 skipped. Exit code: 0 44 + Summary: 13 passed, 0 failed (spec), 0 network errors, 2 advisories, 12 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_endtoend__did_web_current_key_fail_exits_one.snap
··· 6 6 Resolved DID: did:web:web-labeler.example 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://web-labeler.example/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 31 31 labeler::crypto::did_web_no_rotation_history 32 32 33 33 × labels failed verification against current key "#atproto_label" and did:web provides no rotation history 34 + == Report == 35 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 36 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 37 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 38 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 39 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 40 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 41 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 42 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 43 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 44 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 34 45 35 - Summary: 12 passed, 1 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1 46 + Summary: 12 passed, 1 failed (spec), 0 network errors, 1 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_endtoend__empty_labeler_skipped_crypto_only_advisory_exits_zero.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 29 29 [SKIP] Subscription live-tail skipped — labeler has no published labels 30 30 == Crypto == 31 31 [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 32 + == Report == 33 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 34 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 35 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 37 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 38 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 39 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 40 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 42 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 32 43 33 - Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 3 skipped. Exit code: 0 44 + Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 13 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_endtoend__exit_code_summary_for_network_only_run_is_two.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 26 26 [SKIP] Subscription live-tail skipped — labeler has no published labels 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — neither HTTP nor subscription stage produced labels to verify 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 31 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 32 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 33 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 34 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 35 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 36 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 37 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 38 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 39 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 29 40 30 - Summary: 9 passed, 0 failed (spec), 1 network errors, 1 advisories, 3 skipped. Exit code: 2 41 + Summary: 9 passed, 0 failed (spec), 1 network errors, 1 advisories, 13 skipped. Exit code: 2
+13 -2
tests/snapshots/labeler_endtoend__http_decode_failure_exits_one.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 35 35 [SKIP] Subscription live-tail skipped — labeler has no published labels 36 36 == Crypto == 37 37 [SKIP] Crypto stage (not run) — neither HTTP nor subscription stage produced labels to verify 38 + == Report == 39 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 40 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 41 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 42 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 43 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 44 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 45 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 46 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 47 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 48 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 38 49 39 - Summary: 10 passed, 1 failed (spec), 0 network errors, 1 advisories, 3 skipped. Exit code: 1 50 + Summary: 10 passed, 1 failed (spec), 0 network errors, 1 advisories, 13 skipped. Exit code: 1
+14 -2
tests/snapshots/labeler_endtoend__identity_only_failure_exits_one_with_severity_breakdown.snap
··· 1 1 --- 2 2 source: tests/labeler_endtoend.rs 3 + assertion_line: 364 3 4 expression: normalized 4 5 --- 5 6 Target: did:plc:test123456789abcdefghijklmnop 6 - elapsed: Xms 7 + elapsed: XXms 7 8 8 9 == Identity == 9 10 [OK] target resolution ··· 35 36 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 36 37 == Crypto == 37 38 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 39 + == Report == 40 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 41 + [SKIP] Unauthenticated report rejected — blocked by identity stage 42 + [SKIP] Malformed bearer rejected — blocked by identity stage 43 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 44 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 45 + [SKIP] Expired JWT rejected — blocked by identity stage 46 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 47 + [SKIP] Self-mint report accepted — blocked by identity stage 48 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 49 + [SKIP] PDS-proxied report accepted — blocked by identity stage 38 50 39 - Summary: 6 passed, 1 failed (spec), 1 network errors, 0 advisories, 5 skipped. Exit code: 1 51 + Summary: 6 passed, 1 failed (spec), 1 network errors, 0 advisories, 15 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_endtoend__plc_directory_unreachable_network_error_no_false_fail.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 35 35 labeler::crypto::label_verification_failed_no_history 36 36 37 37 × label at://did:plc:test123456789abcdefghijklmnop/app.bsky.feed.post/abc1 failed verification against current key "#atproto_label" and PLC history could not be consulted 38 + == Report == 39 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 40 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 41 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 42 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 43 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 44 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 45 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 46 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 47 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 48 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 38 49 39 - Summary: 12 passed, 1 failed (spec), 1 network errors, 1 advisories, 2 skipped. Exit code: 1 50 + Summary: 12 passed, 1 failed (spec), 1 network errors, 1 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_endtoend__skipped_reasons_rendered.snap
··· 3 3 expression: normalized 4 4 --- 5 5 Target: https://labeler.example.com/ 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [SKIP] Subscription live-tail skipped — labeler has no published labels 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 12 skipped. Exit code: 0 41 + Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 22 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_endtoend__subscription_transport_error_exits_two.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 28 28 [NET] Subscription endpoint reachability 29 29 == Crypto == 30 30 [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 31 + == Report == 32 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 33 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 34 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 35 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 37 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 38 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 39 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 40 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 31 42 32 - Summary: 12 passed, 0 failed (spec), 1 network errors, 1 advisories, 2 skipped. Exit code: 2 43 + Summary: 12 passed, 0 failed (spec), 1 network errors, 1 advisories, 12 skipped. Exit code: 2
+13 -3
tests/snapshots/labeler_identity__did_plc_direct_happy_path.snap
··· 1 1 --- 2 2 source: tests/labeler_identity.rs 3 - assertion_line: 214 4 3 expression: rendered 5 4 --- 6 5 Target: did:plc:test123456789abcdefghijklmnop 7 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 8 7 PDS endpoint: https://pds.example.com/ 9 8 Labeler endpoint: https://labeler.example.com/ 10 - elapsed: Xms 9 + elapsed: XXms 11 10 12 11 == Identity == 13 12 [OK] target resolution ··· 38 37 39 38 × some labels could not be verified against any of the 3 tried key id(s): ["zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y", 40 39 │ "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"] 40 + == Report == 41 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 42 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 43 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 44 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 45 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 46 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 47 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 48 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 49 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 50 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 41 51 42 - Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1 52 + Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__did_web_direct_happy_path.snap
··· 6 6 Resolved DID: did:web:web-labeler.example 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://web-labeler.example/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 29 29 [SKIP] Subscription live-tail skipped — labeler has no published labels 30 30 == Crypto == 31 31 [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 32 + == Report == 33 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 34 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 35 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 37 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 38 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 39 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 40 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 42 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 32 43 33 - Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 3 skipped. Exit code: 0 44 + Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 13 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_identity__empty_policies_renders_spec_violation_with_span.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: did:plc:empty_policies_test_123456789ab 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 32 32 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 33 33 == Crypto == 34 34 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 35 + == Report == 36 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 37 + [SKIP] Unauthenticated report rejected — blocked by identity stage 38 + [SKIP] Malformed bearer rejected — blocked by identity stage 39 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 40 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 41 + [SKIP] Expired JWT rejected — blocked by identity stage 42 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 43 + [SKIP] Self-mint report accepted — blocked by identity stage 44 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 45 + [SKIP] PDS-proxied report accepted — blocked by identity stage 35 46 36 - Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 4 skipped. Exit code: 1 47 + Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 14 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__endpoint_mismatch_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://other-labeler.example/ (with explicit DID) 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 36 36 [SKIP] Subscription live-tail skipped — labeler has no published labels 37 37 == Crypto == 38 38 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 39 + == Report == 40 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 41 + [SKIP] Unauthenticated report rejected — blocked by identity stage 42 + [SKIP] Malformed bearer rejected — blocked by identity stage 43 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 44 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 45 + [SKIP] Expired JWT rejected — blocked by identity stage 46 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 47 + [SKIP] Self-mint report accepted — blocked by identity stage 48 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 49 + [SKIP] PDS-proxied report accepted — blocked by identity stage 39 50 40 - Summary: 12 passed, 1 failed (spec), 0 network errors, 2 advisories, 2 skipped. Exit code: 1 51 + Summary: 12 passed, 1 failed (spec), 0 network errors, 2 advisories, 12 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__endpoint_only_no_did_skips_identity.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [SKIP] Subscription live-tail skipped — labeler has no published labels 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 12 skipped. Exit code: 0 41 + Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 22 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_identity__handle_resolution_happy_path.snap
··· 6 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 7 7 PDS endpoint: https://pds.example.com/ 8 8 Labeler endpoint: https://labeler.example.com/ 9 - elapsed: Xms 9 + elapsed: XXms 10 10 11 11 == Identity == 12 12 [OK] target resolution ··· 29 29 [SKIP] Subscription live-tail skipped — labeler has no published labels 30 30 == Crypto == 31 31 [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 32 + == Report == 33 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 34 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 35 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 36 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 37 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 38 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 39 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 40 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 41 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 42 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 32 43 33 - Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 3 skipped. Exit code: 0 44 + Summary: 12 passed, 0 failed (spec), 0 network errors, 2 advisories, 13 skipped. Exit code: 0
+13 -3
tests/snapshots/labeler_identity__healthy_plc_renders_all_ok.snap
··· 1 1 --- 2 2 source: tests/labeler_identity.rs 3 - assertion_line: 89 4 3 expression: rendered 5 4 --- 6 5 Target: did:plc:test123456789abcdefghijklmnop 7 6 Resolved DID: did:plc:test123456789abcdefghijklmnop 8 7 PDS endpoint: https://pds.example.com/ 9 8 Labeler endpoint: https://labeler.example.com/ 10 - elapsed: Xms 9 + elapsed: XXms 11 10 12 11 == Identity == 13 12 [OK] target resolution ··· 38 37 39 38 × some labels could not be verified against any of the 3 tried key id(s): ["zQ3shVc2UkAfJCdc1TR8E66J85h48P43r93q8jGPkPpjF9Ef9", "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7y", 40 39 │ "z3u1HhU9Dn1R1TDe8kSmEMCrJ5B8t9K7c7N2L3xX7z"] 40 + == Report == 41 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 42 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 43 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 44 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 45 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 46 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 47 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 48 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 49 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 50 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 41 51 42 - Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 2 skipped. Exit code: 1 52 + Summary: 12 passed, 2 failed (spec), 0 network errors, 1 advisories, 12 skipped. Exit code: 1
+54
tests/snapshots/labeler_identity__local_http_override_mismatch_is_advisory.snap
··· 1 + --- 2 + source: tests/labeler_identity.rs 3 + expression: rendered 4 + --- 5 + Target: http://localhost:8080/ (with explicit DID) 6 + Resolved DID: did:plc:endpoint_mismatch_test_123456789 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: http://localhost:8080/ 9 + elapsed: XXms 10 + 11 + == Identity == 12 + [OK] target resolution 13 + [OK] DID document fetch 14 + [OK] labeler service entry 15 + [OK] labeler endpoint URL 16 + [OK] labeler endpoint scheme 17 + [WARN] resolved DID matches --did flag 18 + labeler::identity::resolved_did_matches_flag 19 + 20 + × DID document endpoint (https://original-endpoint.example.com/) does not match local override (http://localhost:8080/); using the local URL for the remaining stages 21 + ╭─[https://plc.directory/did:plc:endpoint_mismatch_test_123456789:13:26] 22 + 12 │ "id": "#atproto_labeler", 23 + 13 │ "serviceEndpoint": "https://original-endpoint.example.com", 24 + · ───────────────────┬─────────────────── 25 + · ╰── endpoint value 26 + 14 │ "type": "AtprotoLabeler" 27 + ╰──── 28 + [OK] signing key entry 29 + [OK] PDS endpoint entry 30 + [OK] labeler record fetch 31 + [OK] labeler record policy list 32 + == HTTP == 33 + [OK] Labeler endpoint reachability 34 + [OK] First page schema 35 + [WARN] Labeler has no published labels 36 + [OK] First page was complete; pagination not exercised 37 + == Subscription == 38 + [WARN] Subscription backfill had no frames — labeler has no published labels 39 + [SKIP] Subscription live-tail skipped — labeler has no published labels 40 + == Crypto == 41 + [SKIP] Crypto stage (no labels to verify) — labeler published no labels; nothing to verify 42 + == Report == 43 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 44 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 45 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 46 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 47 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 48 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 49 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 50 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 51 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 52 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 53 + 54 + Summary: 12 passed, 0 failed (spec), 0 network errors, 3 advisories, 12 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_identity__missing_labeler_record_renders_404_distinct_from_transport.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: did:plc:test123456789abcdefghijklmnop 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 25 25 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 26 26 == Crypto == 27 27 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 28 + == Report == 29 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 30 + [SKIP] Unauthenticated report rejected — blocked by identity stage 31 + [SKIP] Malformed bearer rejected — blocked by identity stage 32 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 34 + [SKIP] Expired JWT rejected — blocked by identity stage 35 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 36 + [SKIP] Self-mint report accepted — blocked by identity stage 37 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 38 + [SKIP] PDS-proxied report accepted — blocked by identity stage 28 39 29 - Summary: 7 passed, 1 failed (spec), 0 network errors, 0 advisories, 5 skipped. Exit code: 1 40 + Summary: 7 passed, 1 failed (spec), 0 network errors, 0 advisories, 15 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__missing_service_renders_spec_violation_with_span.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: did:plc:missing_service_test_123456789 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 32 32 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 33 33 == Crypto == 34 34 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 35 + == Report == 36 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 37 + [SKIP] Unauthenticated report rejected — blocked by identity stage 38 + [SKIP] Malformed bearer rejected — blocked by identity stage 39 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 40 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 41 + [SKIP] Expired JWT rejected — blocked by identity stage 42 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 43 + [SKIP] Self-mint report accepted — blocked by identity stage 44 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 45 + [SKIP] PDS-proxied report accepted — blocked by identity stage 35 46 36 - Summary: 6 passed, 1 failed (spec), 0 network errors, 0 advisories, 6 skipped. Exit code: 1 47 + Summary: 6 passed, 1 failed (spec), 0 network errors, 0 advisories, 16 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__missing_signing_key_renders_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: did:plc:missing_signing_key_test_12345 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 32 32 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 33 33 == Crypto == 34 34 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 35 + == Report == 36 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 37 + [SKIP] Unauthenticated report rejected — blocked by identity stage 38 + [SKIP] Malformed bearer rejected — blocked by identity stage 39 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 40 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 41 + [SKIP] Expired JWT rejected — blocked by identity stage 42 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 43 + [SKIP] Self-mint report accepted — blocked by identity stage 44 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 45 + [SKIP] PDS-proxied report accepted — blocked by identity stage 35 46 36 - Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 4 skipped. Exit code: 1 47 + Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 14 skipped. Exit code: 1
+14 -3
tests/snapshots/labeler_identity__non_https_endpoint_renders_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: did:plc:non_https_endpoint_test_123456 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [OK] target resolution ··· 13 13 [FAIL] labeler endpoint scheme 14 14 labeler::identity::labeler_endpoint_is_https 15 15 16 - × Labeler endpoint must use HTTPS, got: http://labeler.example.com 16 + × Labeler endpoint must use HTTPS (or HTTP with a local hostname), got: http://labeler.example.com 17 17 ╭─[https://plc.directory/did:plc:non_https_endpoint_test_123456:13:26] 18 18 12 │ "id": "#atproto_labeler", 19 19 13 │ "serviceEndpoint": "http://labeler.example.com", ··· 32 32 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 33 33 == Crypto == 34 34 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 35 + == Report == 36 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 37 + [SKIP] Unauthenticated report rejected — blocked by identity stage 38 + [SKIP] Malformed bearer rejected — blocked by identity stage 39 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 40 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 41 + [SKIP] Expired JWT rejected — blocked by identity stage 42 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 43 + [SKIP] Self-mint report accepted — blocked by identity stage 44 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 45 + [SKIP] PDS-proxied report accepted — blocked by identity stage 35 46 36 - Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 4 skipped. Exit code: 1 47 + Summary: 8 passed, 1 failed (spec), 0 network errors, 0 advisories, 14 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_identity__plc_directory_unreachable_renders_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: alice.test 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [NET] target resolution ··· 22 22 [SKIP] Subscription stage (not run) — blocked by identity::target_resolved 23 23 == Crypto == 24 24 [SKIP] Crypto stage (not run) — blocked by identity::target_resolved 25 + == Report == 26 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 27 + [SKIP] Unauthenticated report rejected — blocked by identity stage 28 + [SKIP] Malformed bearer rejected — blocked by identity stage 29 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 30 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 31 + [SKIP] Expired JWT rejected — blocked by identity stage 32 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 33 + [SKIP] Self-mint report accepted — blocked by identity stage 34 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 35 + [SKIP] PDS-proxied report accepted — blocked by identity stage 25 36 26 - Summary: 0 passed, 0 failed (spec), 1 network errors, 0 advisories, 12 skipped. Exit code: 2 37 + Summary: 0 passed, 0 failed (spec), 1 network errors, 0 advisories, 22 skipped. Exit code: 2
+125
tests/snapshots/labeler_report__report_all_fail_misconfigured_labeler_snapshot.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + expression: rendered 4 + --- 5 + Target: test-labeler 6 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: http://localhost:8080/ 9 + elapsed: XXms 10 + 11 + == Report == 12 + [OK] Labeler advertises reportable shape 13 + [FAIL] Unauthenticated report accepted (should have been rejected) 14 + labeler::report::unauthenticated_accepted 15 + 16 + × Labeler accepted unauthenticated createReport (status 200) 17 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 18 + 1 │ ╭─▶ { 19 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 20 + 3 │ │ "id": 1, 21 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 22 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 23 + 6 │ │ "subject": { 24 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 25 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 26 + 9 │ │ } 27 + 10 │ ├─▶ } 28 + · ╰──── accepted here 29 + ╰──── 30 + help: A labeler must reject createReport with 401 when no Authorization header is supplied. 31 + [FAIL] Malformed bearer accepted (should have been rejected) 32 + labeler::report::malformed_bearer_accepted 33 + 34 + × Labeler accepted malformed Bearer token (status 200) 35 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 36 + 1 │ ╭─▶ { 37 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 38 + 3 │ │ "id": 1, 39 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 40 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 41 + 6 │ │ "subject": { 42 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 43 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 44 + 9 │ │ } 45 + 10 │ ├─▶ } 46 + · ╰──── accepted here 47 + ╰──── 48 + help: A labeler must reject createReport with 401 when the Authorization header carries a non-JWT string. 49 + [FAIL] JWT with wrong `aud` accepted 50 + labeler::report::wrong_aud_accepted 51 + 52 + × Labeler accepted JWT with wrong `aud` (status 200) 53 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 54 + 1 │ ╭─▶ { 55 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 56 + 3 │ │ "id": 1, 57 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 58 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 59 + 6 │ │ "subject": { 60 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 61 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 62 + 9 │ │ } 63 + 10 │ ├─▶ } 64 + · ╰──── accepted here 65 + ╰──── 66 + help: A labeler must reject JWTs whose `aud` claim does not match its own DID. 67 + [FAIL] JWT with wrong `lxm` accepted 68 + labeler::report::wrong_lxm_accepted 69 + 70 + × Labeler accepted JWT with wrong `lxm` (status 200) 71 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 72 + 1 │ ╭─▶ { 73 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 74 + 3 │ │ "id": 1, 75 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 76 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 77 + 6 │ │ "subject": { 78 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 79 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 80 + 9 │ │ } 81 + 10 │ ├─▶ } 82 + · ╰──── accepted here 83 + ╰──── 84 + help: A labeler must reject JWTs whose `lxm` claim does not match the invoked Lexicon method. 85 + [FAIL] Expired JWT accepted 86 + labeler::report::expired_accepted 87 + 88 + × Labeler accepted expired JWT (status 200) 89 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 90 + 1 │ ╭─▶ { 91 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 92 + 3 │ │ "id": 1, 93 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 94 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 95 + 6 │ │ "subject": { 96 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 97 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 98 + 9 │ │ } 99 + 10 │ ├─▶ } 100 + · ╰──── accepted here 101 + ╰──── 102 + help: A labeler must reject JWTs whose `exp` claim is in the past. 103 + [WARN] Rejection status was not 400 InvalidRequest 104 + labeler::report::shape_not_400 105 + 106 + × Unadvertised `reasonType` was rejected with status 200, expected 400 InvalidRequest 107 + ╭─[https://labeler.test/xrpc/com.atproto.moderation.createReport:1:1] 108 + 1 │ ╭─▶ { 109 + 2 │ │ "createdAt": "2026-04-17T00:00:00.000Z", 110 + 3 │ │ "id": 1, 111 + 4 │ │ "reasonType": "com.atproto.moderation.defs#reasonOther", 112 + 5 │ │ "reportedBy": "did:web:127.0.0.1%3A0", 113 + 6 │ │ "subject": { 114 + 7 │ │ "$type": "com.atproto.admin.defs#repoRef", 115 + 8 │ │ "did": "did:plc:aaa22222222222222222bbbbbb" 116 + 9 │ │ } 117 + 10 │ ├─▶ } 118 + · ╰──── rejected with wrong status here 119 + ╰──── 120 + help: A labeler should return 400 InvalidRequest (not 401 or 500) for a `reasonType` not listed in its published LabelerPolicies.reasonTypes. 121 + [OK] Self-mint report accepted 122 + [OK] PDS-minted JWT accepted 123 + [OK] PDS-proxied report accepted 124 + 125 + Summary: 4 passed, 5 failed (spec), 0 network errors, 1 advisories, 0 skipped. Exit code: 1
+23
tests/snapshots/labeler_report__report_all_pass_full_suite_snapshot.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + expression: rendered 4 + --- 5 + Target: test-labeler 6 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: http://localhost:8080/ 9 + elapsed: XXms 10 + 11 + == Report == 12 + [OK] Labeler advertises reportable shape 13 + [OK] Unauthenticated report rejected 14 + [OK] Malformed bearer rejected 15 + [OK] JWT with wrong `aud` rejected 16 + [OK] JWT with wrong `lxm` rejected 17 + [OK] Expired JWT rejected 18 + [OK] Invalid shape returns 400 InvalidRequest 19 + [OK] Self-mint report accepted 20 + [OK] PDS-minted JWT accepted 21 + [OK] PDS-proxied report accepted 22 + 23 + Summary: 10 passed, 0 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 0
+23
tests/snapshots/labeler_report__report_all_pass_local_labeler_snapshot.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + expression: rendered 4 + --- 5 + Target: test-labeler 6 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: http://localhost:8080/ 9 + elapsed: XXms 10 + 11 + == Report == 12 + [OK] Labeler advertises reportable shape 13 + [OK] Unauthenticated report rejected 14 + [OK] Malformed bearer rejected 15 + [OK] JWT with wrong `aud` rejected 16 + [OK] JWT with wrong `lxm` rejected 17 + [OK] Expired JWT rejected 18 + [OK] Invalid shape returns 400 InvalidRequest 19 + [OK] Self-mint report accepted 20 + [SKIP] PDS-minted JWT accepted — requires --handle, --app-password, and --commit-report 21 + [SKIP] PDS-proxied report accepted — requires --handle, --app-password, and --commit-report 22 + 23 + Summary: 8 passed, 0 failed (spec), 0 network errors, 0 advisories, 2 skipped. Exit code: 0
+24
tests/snapshots/labeler_report__report_contract_missing_no_commit.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + assertion_line: 320 4 + expression: render_results_to_string(results).await 5 + --- 6 + Target: test-labeler 7 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 8 + PDS endpoint: https://pds.example.com/ 9 + Labeler endpoint: https://labeler.example.com/ 10 + elapsed: XXms 11 + 12 + == Report == 13 + [SKIP] Labeler advertises reportable shape — labeler does not advertise report acceptance 14 + [SKIP] Unauthenticated report rejected — labeler does not advertise report acceptance 15 + [SKIP] Malformed bearer rejected — labeler does not advertise report acceptance 16 + [SKIP] JWT with wrong `aud` rejected — labeler does not advertise report acceptance 17 + [SKIP] JWT with wrong `lxm` rejected — labeler does not advertise report acceptance 18 + [SKIP] Expired JWT rejected — labeler does not advertise report acceptance 19 + [SKIP] Invalid shape returns 400 InvalidRequest — labeler does not advertise report acceptance 20 + [SKIP] Self-mint report accepted — labeler does not advertise report acceptance 21 + [SKIP] PDS-minted JWT accepted — labeler does not advertise report acceptance 22 + [SKIP] PDS-proxied report accepted — labeler does not advertise report acceptance 23 + 24 + Summary: 0 passed, 0 failed (spec), 0 network errors, 0 advisories, 10 skipped. Exit code: 0
+28
tests/snapshots/labeler_report__report_contract_missing_with_commit.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + assertion_line: 339 4 + expression: render_results_to_string(results).await 5 + --- 6 + Target: test-labeler 7 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 8 + PDS endpoint: https://pds.example.com/ 9 + Labeler endpoint: https://labeler.example.com/ 10 + elapsed: XXms 11 + 12 + == Report == 13 + [FAIL] Labeler does not advertise a reportable shape 14 + labeler::report::contract_missing 15 + 16 + × Labeler does not advertise a reportable `LabelerPolicies` shape 17 + help: `reasonTypes` and `subjectTypes` must both be present and non-empty on the labeler's published policies; the tool cannot verify reporting conformance without them. 18 + [SKIP] Unauthenticated report rejected — blocked by `report::contract_published` 19 + [SKIP] Malformed bearer rejected — blocked by `report::contract_published` 20 + [SKIP] JWT with wrong `aud` rejected — blocked by `report::contract_published` 21 + [SKIP] JWT with wrong `lxm` rejected — blocked by `report::contract_published` 22 + [SKIP] Expired JWT rejected — blocked by `report::contract_published` 23 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by `report::contract_published` 24 + [SKIP] Self-mint report accepted — blocked by `report::contract_published` 25 + [SKIP] PDS-minted JWT accepted — blocked by `report::contract_published` 26 + [SKIP] PDS-proxied report accepted — blocked by `report::contract_published` 27 + 28 + Summary: 0 passed, 1 failed (spec), 0 network errors, 0 advisories, 9 skipped. Exit code: 1
+23
tests/snapshots/labeler_report__report_contract_present_no_commit.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + expression: render_results_to_string(results).await 4 + --- 5 + Target: test-labeler 6 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: https://labeler.example.com/ 9 + elapsed: XXms 10 + 11 + == Report == 12 + [OK] Labeler advertises reportable shape 13 + [OK] Unauthenticated report rejected 14 + [OK] Malformed bearer rejected 15 + [SKIP] JWT with wrong `aud` rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 16 + [SKIP] JWT with wrong `lxm` rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 17 + [SKIP] Expired JWT rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 18 + [SKIP] Invalid shape returns 400 InvalidRequest — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 19 + [SKIP] Self-mint report accepted — commit gated behind --commit-report 20 + [SKIP] PDS-minted JWT accepted — requires --handle, --app-password, and --commit-report 21 + [SKIP] PDS-proxied report accepted — requires --handle, --app-password, and --commit-report 22 + 23 + Summary: 3 passed, 0 failed (spec), 0 network errors, 0 advisories, 7 skipped. Exit code: 0
+23
tests/snapshots/labeler_report__report_contract_present_with_commit.snap
··· 1 + --- 2 + source: tests/labeler_report.rs 3 + expression: render_results_to_string(results).await 4 + --- 5 + Target: test-labeler 6 + Resolved DID: did:plc:aaa22222222222222222bbbbbb 7 + PDS endpoint: https://pds.example.com/ 8 + Labeler endpoint: https://labeler.example.com/ 9 + elapsed: XXms 10 + 11 + == Report == 12 + [OK] Labeler advertises reportable shape 13 + [OK] Unauthenticated report rejected 14 + [OK] Malformed bearer rejected 15 + [SKIP] JWT with wrong `aud` rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 16 + [SKIP] JWT with wrong `lxm` rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 17 + [SKIP] Expired JWT rejected — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 18 + [SKIP] Invalid shape returns 400 InvalidRequest — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 19 + [SKIP] Self-mint report accepted — self-mint required; labeler endpoint appears non-local (override with --force-self-mint) 20 + [SKIP] PDS-minted JWT accepted — requires --handle, --app-password, and --commit-report 21 + [SKIP] PDS-proxied report accepted — requires --handle, --app-password, and --commit-report 22 + 23 + Summary: 3 passed, 0 failed (spec), 0 network errors, 0 advisories, 7 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_subscription__backfill_completes_within_budget_passes.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [OK] Subscription live-tail observed after backfill 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 5 passed, 0 failed (spec), 0 network errors, 1 advisories, 11 skipped. Exit code: 0 41 + Summary: 5 passed, 0 failed (spec), 0 network errors, 1 advisories, 21 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_subscription__backfill_exceeds_budget_triggers_live_tail.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [OK] Subscription live-tail connection held 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 4 passed, 0 failed (spec), 0 network errors, 2 advisories, 11 skipped. Exit code: 0 41 + Summary: 4 passed, 0 failed (spec), 0 network errors, 2 advisories, 21 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_subscription__empty_stream_advisories.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [SKIP] Subscription live-tail skipped — labeler has no published labels 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 12 skipped. Exit code: 0 41 + Summary: 3 passed, 0 failed (spec), 0 network errors, 2 advisories, 22 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_subscription__error_frame_malformed_payload_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 35 35 ╰──── 36 36 == Crypto == 37 37 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 38 + == Report == 39 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 40 + [SKIP] Unauthenticated report rejected — blocked by identity stage 41 + [SKIP] Malformed bearer rejected — blocked by identity stage 42 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 43 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 44 + [SKIP] Expired JWT rejected — blocked by identity stage 45 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 46 + [SKIP] Self-mint report accepted — blocked by identity stage 47 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 48 + [SKIP] PDS-proxied report accepted — blocked by identity stage 38 49 39 - Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 11 skipped. Exit code: 1 50 + Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 21 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_subscription__live_tail_connect_failure_emits_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [WARN] Subscription backfill exceeded budget 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 3 passed, 0 failed (spec), 1 network errors, 2 advisories, 11 skipped. Exit code: 2 41 + Summary: 3 passed, 0 failed (spec), 1 network errors, 2 advisories, 21 skipped. Exit code: 2
+13 -2
tests/snapshots/labeler_subscription__malformed_frame_emits_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 35 35 ╰──── 36 36 == Crypto == 37 37 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 38 + == Report == 39 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 40 + [SKIP] Unauthenticated report rejected — blocked by identity stage 41 + [SKIP] Malformed bearer rejected — blocked by identity stage 42 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 43 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 44 + [SKIP] Expired JWT rejected — blocked by identity stage 45 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 46 + [SKIP] Self-mint report accepted — blocked by identity stage 47 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 48 + [SKIP] PDS-proxied report accepted — blocked by identity stage 38 49 39 - Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 11 skipped. Exit code: 1 50 + Summary: 4 passed, 1 failed (spec), 0 network errors, 2 advisories, 21 skipped. Exit code: 1
+13 -2
tests/snapshots/labeler_subscription__mid_stream_transport_error_does_not_reset_idle_gap.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 26 26 [OK] Subscription live-tail observed after backfill 27 27 == Crypto == 28 28 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 29 + == Report == 30 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 31 + [SKIP] Unauthenticated report rejected — blocked by identity stage 32 + [SKIP] Malformed bearer rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 34 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 35 + [SKIP] Expired JWT rejected — blocked by identity stage 36 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 37 + [SKIP] Self-mint report accepted — blocked by identity stage 38 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 39 + [SKIP] PDS-proxied report accepted — blocked by identity stage 29 40 30 - Summary: 5 passed, 0 failed (spec), 0 network errors, 1 advisories, 11 skipped. Exit code: 0 41 + Summary: 5 passed, 0 failed (spec), 0 network errors, 1 advisories, 21 skipped. Exit code: 0
+13 -2
tests/snapshots/labeler_subscription__unreachable_endpoint_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://example.com/labeler 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Identity == 9 9 [SKIP] target resolution — no DID supplied; run with a handle, a DID, or --did <did> ··· 25 25 [NET] Subscription endpoint reachability 26 26 == Crypto == 27 27 [SKIP] Crypto stage (not run) — identity stage produced no labeler endpoint 28 + == Report == 29 + [SKIP] Labeler advertises reportable shape — blocked by identity stage 30 + [SKIP] Unauthenticated report rejected — blocked by identity stage 31 + [SKIP] Malformed bearer rejected — blocked by identity stage 32 + [SKIP] JWT with wrong `aud` rejected — blocked by identity stage 33 + [SKIP] JWT with wrong `lxm` rejected — blocked by identity stage 34 + [SKIP] Expired JWT rejected — blocked by identity stage 35 + [SKIP] Invalid shape returns 400 InvalidRequest — blocked by identity stage 36 + [SKIP] Self-mint report accepted — blocked by identity stage 37 + [SKIP] PDS-minted JWT accepted — blocked by identity stage 38 + [SKIP] PDS-proxied report accepted — blocked by identity stage 28 39 29 - Summary: 3 passed, 0 failed (spec), 1 network errors, 1 advisories, 11 skipped. Exit code: 2 40 + Summary: 3 passed, 0 failed (spec), 1 network errors, 1 advisories, 21 skipped. Exit code: 2
+1 -1
tests/snapshots/oauth_client_discovery__https_404_produces_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/missing.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__https_confidential_happy_discovery.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__https_not_json_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__https_not_json_with_content_type_produces_spec_violation_with_ct.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__https_unreachable_produces_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__loopback_127_0_0_1.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: http://127.0.0.1:3000/ 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__loopback_root_produces_skip_rows.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: http://localhost/ 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_discovery__loopback_with_port_produces_same_skip_rows.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: http://localhost:8080/client.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_endtoend__full_pipeline_all_pass.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__discovery_failure_blocks_jwks.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__duplicate_kids_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__inline_es256_happy_jwks_passes.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__loopback_skips_all_jwks.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: http://localhost/ 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__missing_alg_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__public_client_skips_all_jwks.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__uri_es256_happy_jwks_passes.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__uri_invalid_json_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__uri_returns_404_produces_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__uri_unreachable_produces_network_error.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__weak_alg_rs1_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_jwks__wrong_use_produces_spec_violation.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__confidential_happy.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__confidential_missing_jwks.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__discovery_failure_blocks_metadata.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__dpop_bound_false.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__loopback_skips_all_metadata_checks.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: http://localhost/ 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__native_happy.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://app.example.com/oauth-client-metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__native_redirect_scheme_mismatch.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://app.example.com/oauth-client-metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__public_happy.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__public_with_token_endpoint_auth.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_metadata__scope_grammar_invalid.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: https://client.example.com/metadata.json 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Discovery == 9 9 [OK] Client ID well-formed
+1 -1
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_default_run_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: dpop_edges sub-stage 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [OK] DPoP nonce rotation on use_dpop_nonce response
+1 -1
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_with_jti_reuse_violation_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: dpop_edges jti-reuse 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [FAIL] DPoP nonce rotation on use_dpop_nonce response
+1 -1
tests/snapshots/oauth_client_substage_snapshots__dpop_edges_with_refresh_token_reuse_violation_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: dpop_edges refresh-token-reuse 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [FAIL] DPoP nonce rotation on use_dpop_nonce response
+1 -1
tests/snapshots/oauth_client_substage_snapshots__interactive_stage_blocked_by_static_failures_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: interactive stage blocked-by snapshot 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [SKIP] Fake AS server bound and identity advertised — interactive stage blocked by failed static prerequisites
+1 -1
tests/snapshots/oauth_client_substage_snapshots__scope_variations_default_run_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: scope_variations sub-stage 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [OK] Full grant approval flow
+1 -1
tests/snapshots/oauth_client_substage_snapshots__scope_variations_with_pkce_violation_snapshot.snap
··· 3 3 expression: rendered 4 4 --- 5 5 Target: scope_variations pkce-violation 6 - elapsed: Xms 6 + elapsed: XXms 7 7 8 8 == Interactive == 9 9 [OK] Full grant approval flow