//! Shared test support for the AppView integration suites. //! //! ## Why the suites need this //! //! `/api/notifications*` and `/api/timeline/home` require a //! PDS-issued access token whose `sub` equals the `did` in the request. //! The suites, however, seed synthetic DIDs (`did:plc:ntf_…`) through //! `/internal/ingest-commit` — accounts the PDS has never heard of, so //! there is no `createSession` that would hand out a token for them. //! //! The way out is that a token is just an ES256 JWT signed with the //! server key derived from `PDS_JWT_SECRET`. A test that can read that //! secret (from the process environment, or from the repo `.env` the //! dev stack itself was started with) can mint a token for any DID it //! likes — the same thing `pds-server/src/jwt_issuer.rs` does. //! //! ## Fail-open, like the rest of the suites //! //! [`TestAuth::probe`] asks the running AppView whether it enforces //! auth at all: //! //! - not enforcing (`APPVIEW_AUTH_REQUIRED=false`) → no header needed; //! - enforcing and we have the secret → mint per-DID tokens; //! - enforcing and we don't → `None`, and the caller skips, exactly as //! it already skips when the service or the database is down. #![allow(dead_code)] use at_crypto::ecdsa::P256Keypair; use at_crypto::jwt::{issue_jwt, JwtClaims}; /// Audience the PDS stamps into access tokens — and, since the /// audience check landed, the value the AppView insists on: its own /// service DID, derived from `APPVIEW_PUBLIC_URL`. A token minted with /// anything else is rejected as `TokenInvalid`, which is exactly what /// we want a wrong value here to look like. /// /// Derived the same way `AppConfig::appview_did()` does it, from the /// same environment variable, so this helper can't drift from the /// service it's impersonating the PDS for. fn appview_aud() -> String { let url = std::env::var("APPVIEW_PUBLIC_URL") .unwrap_or_else(|_| "http://127.0.0.1:2584".to_string()); at_shared::config::did_web_from_url(&url) } /// The scope the AppView insists on. A token with any other scope — /// `com.atproto.refresh`, say — is rejected with `TokenInvalid`. pub const ACCESS_SCOPE: &str = "com.atproto.access"; /// How the suite should authenticate against the AppView under test. /// /// `Clone` because a test that pages through results in a closure has /// to hand each iteration its own copy, exactly like the client and the /// base URL next to it. #[derive(Clone)] pub enum TestAuth { /// `APPVIEW_AUTH_REQUIRED=false`: send no `Authorization` header. Disabled, /// Auth is enforced; mint tokens with this hex secret. Secret(String), } impl TestAuth { /// Decide how (or whether) this suite can talk to the AppView. /// /// Returns `None` when the AppView enforces auth but no /// `PDS_JWT_SECRET` is reachable — the caller should print a notice /// and return, keeping `cargo test --workspace` green on a machine /// without the dev stack's environment. pub async fn probe(c: &reqwest::Client, base_url: &str) -> Option { // An unauthenticated probe against a private endpoint. We only // look at the status: 401 means the extractor is active. A DID // that doesn't exist is fine — the auth check runs first. let status = c .get(format!("{base_url}/api/notifications/count")) .query(&[("did", "did:plc:auth_probe")]) .send() .await .ok()? .status() .as_u16(); if status != 401 && status != 503 { return Some(TestAuth::Disabled); } match pds_jwt_secret() { Some(secret) => Some(TestAuth::Secret(secret)), None => { eprintln!( "appview enforces auth (probe returned {status}) but PDS_JWT_SECRET \ is not set and no .env was found — skipping" ); None } } } /// Attach an `Authorization: Bearer` header for `did`, if needed. pub fn apply(&self, rb: reqwest::RequestBuilder, did: &str) -> reqwest::RequestBuilder { match self { TestAuth::Disabled => rb, TestAuth::Secret(secret) => match mint_access_jwt(secret, did, ACCESS_SCOPE, 3600) { Some(token) => rb.bearer_auth(token), None => rb, }, } } /// A token for `did` — for tests that want to send a *wrong* one on /// purpose. `None` when auth is disabled, in which case the test /// that needs it should skip. pub fn token_for(&self, did: &str) -> Option { match self { TestAuth::Disabled => None, TestAuth::Secret(secret) => mint_access_jwt(secret, did, ACCESS_SCOPE, 3600), } } } /// `PDS_JWT_SECRET` from the environment, falling back to the repo /// `.env` — the same file the running dev stack loaded at startup, so /// the minted tokens verify against the key the PDS actually publishes. pub fn pds_jwt_secret() -> Option { if let Ok(v) = std::env::var("PDS_JWT_SECRET") { if !v.trim().is_empty() { return Some(v); } } // `dotenvy::dotenv` walks up from the current directory, which for // a test binary is the crate root — so this finds the workspace // `.env` two levels up. It never overrides a real env var. let _ = dotenvy::dotenv(); std::env::var("PDS_JWT_SECRET") .ok() .filter(|v| !v.trim().is_empty()) } /// Mint an access JWT for `did`, signed with the PDS's server key. /// /// Mirrors `pds-server/src/jwt_issuer.rs`: the P-256 secret scalar is /// the **first 32 bytes** of `PDS_JWT_SECRET` (the config allows a /// longer secret), i.e. the first 64 hex characters. /// /// `ttl_secs` may be negative to build a deliberately expired token. pub fn mint_access_jwt( secret_hex: &str, did: &str, scope: &str, ttl_secs: i64, ) -> Option { let hex = secret_hex.trim().trim_start_matches("0x"); if hex.len() < 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { eprintln!("PDS_JWT_SECRET is not ≥32 bytes of hex; cannot mint a test token"); return None; } let kp = P256Keypair { secret_hex: hex[..64].to_string(), // Only the signing half is used by `issue_jwt`; the verifier // fetches the public key from the PDS's DID document. public_multibase: String::new(), }; let now = chrono::Utc::now().timestamp(); issue_jwt( &kp, &JwtClaims { iss: "did:web:test".into(), sub: did.to_string(), aud: appview_aud(), iat: now - 1, exp: now + ttl_secs, jti: None, scope: Some(scope.to_string()), }, ) .ok() }