//! End-to-end tests for AppView authentication. //! //! Unlike the other suites in this directory these use a **real** //! token: the test creates an account on the running PDS //! (`com.atproto.server.createAccount`, the same call the desktop //! client makes at signup) and presents the access JWT it gets back to //! the AppView. That is the whole point — it exercises the actual key //! distribution path (PDS signs → publishes its public key at //! `/.well-known/did.json` → AppView fetches and verifies), which a //! self-minted token would bypass. //! //! Fail-open like the rest of the suites: if the PDS or the AppView //! isn't running, or the AppView runs with `APPVIEW_AUTH_REQUIRED=false`, //! the test prints a notice and returns successfully. use serde_json::{json, Value}; use std::time::Duration; const PDS_URL: &str = "http://127.0.0.1:2583"; fn appview_url() -> String { std::env::var("APPVIEW_TEST_URL").unwrap_or_else(|_| "http://127.0.0.1:2584".to_string()) } /// 30 s, not the 5 s the sibling suites use. /// /// The authenticated happy path for `/api/timeline/home` runs the /// cold-start query (a brand-new account follows nobody, so the handler /// falls back to the global recent feed). On a developer machine whose /// AppView has been indexing the public firehose for a while that scan /// takes seconds — a timeout there would look like an auth failure and /// is nothing of the sort. fn client() -> reqwest::Client { reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() .unwrap() } async fn up(c: &reqwest::Client, base: &str) -> bool { for _ in 0..20 { if let Ok(r) = c.get(format!("{base}/healthz")).send().await { if r.status().is_success() { return true; } } tokio::time::sleep(Duration::from_millis(250)).await; } false } /// A freshly created PDS account. struct Account { did: String, access_jwt: String, refresh_jwt: String, } /// Guard for every test here: both services up **and** the AppView /// actually enforcing auth. Returns the client plus a new account. async fn ready() -> Option<(reqwest::Client, Account)> { let c = client(); if !up(&c, &appview_url()).await { eprintln!("appview not running, skipping"); return None; } if !up(&c, PDS_URL).await { eprintln!("pds not running, skipping"); return None; } // Probe: does this instance enforce auth? An operator running with // `APPVIEW_AUTH_REQUIRED=false` (the mode the other suites use) // would otherwise see every assertion here fail for the one reason // that isn't a bug. let probe = c .get(format!("{}/api/notifications/count", appview_url())) .query(&[("did", "did:plc:auth_probe")]) .send() .await .ok()?; if probe.status().as_u16() != 401 { eprintln!( "appview does not enforce auth (probe returned {}), skipping", probe.status() ); return None; } let acc = create_account(&c).await?; Some((c, acc)) } async fn create_account(c: &reqwest::Client) -> Option { let handle = format!("auth_{}.maarcadetweet.local", uuid::Uuid::new_v4().simple()); let r: Value = c .post(format!("{PDS_URL}/xrpc/com.atproto.server.createAccount")) .json(&json!({ "handle": handle, "password": "hunter2hunter2" })) .send() .await .ok()? .json() .await .ok()?; Some(Account { did: r["did"].as_str()?.to_string(), access_jwt: r["access_jwt"].as_str()?.to_string(), refresh_jwt: r["refresh_jwt"].as_str()?.to_string(), }) } /// `GET /api/notifications?did=…` with an optional bearer token. async fn get_notifications( c: &reqwest::Client, did: &str, token: Option<&str>, ) -> reqwest::Response { let mut rb = c .get(format!("{}/api/notifications", appview_url())) .query(&[("did", did)]); if let Some(t) = token { rb = rb.bearer_auth(t); } rb.send().await.unwrap() } /// Assert the AppView's standard error envelope: the status, and the /// `error` code the desktop client branches on. async fn assert_error(r: reqwest::Response, status: u16, code: &str) { let got = r.status().as_u16(); let body: Value = r.json().await.unwrap(); assert_eq!(got, status, "unexpected status; body = {body}"); assert_eq!(body["error"], json!(code), "unexpected error code: {body}"); assert!( body["message"].is_string(), "error body must carry a message: {body}" ); } #[tokio::test] async fn own_token_reads_own_notifications() { let Some((c, acc)) = ready().await else { return }; let r = get_notifications(&c, &acc.did, Some(&acc.access_jwt)).await; assert_eq!(r.status().as_u16(), 200); let body: Value = r.json().await.unwrap(); // A brand-new account has no notifications, but the shape must be // the normal list response, not an error. assert!(body["notifications"].is_array(), "body = {body}"); } #[tokio::test] async fn no_token_is_401_auth_missing() { let Some((c, acc)) = ready().await else { return }; let r = get_notifications(&c, &acc.did, None).await; assert_error(r, 401, "AuthMissing").await; } #[tokio::test] async fn foreign_did_is_403() { let Some((c, acc)) = ready().await else { return }; // A valid token, but asking about somebody else's inbox. This is // the case the endpoint used to answer with a 200. let r = get_notifications(&c, "did:plc:somebodyelse", Some(&acc.access_jwt)).await; assert_error(r, 403, "Forbidden").await; } #[tokio::test] async fn garbage_token_is_401_token_invalid() { let Some((c, acc)) = ready().await else { return }; let r = get_notifications(&c, &acc.did, Some("not.a.jwt")).await; assert_error(r, 401, "TokenInvalid").await; } #[tokio::test] async fn refresh_token_is_not_accepted() { let Some((c, acc)) = ready().await else { return }; // Signed by the same key and valid for 90 days — only the `scope` // claim separates it from a session credential. let r = get_notifications(&c, &acc.did, Some(&acc.refresh_jwt)).await; assert_error(r, 401, "TokenInvalid").await; } #[tokio::test] async fn seen_write_requires_matching_token() { let Some((c, acc)) = ready().await else { return }; let url = format!("{}/api/notifications/seen", appview_url()); // Somebody else's unread badge: 403, nothing written. let r = c .post(&url) .bearer_auth(&acc.access_jwt) .json(&json!({ "did": "did:plc:somebodyelse" })) .send() .await .unwrap(); assert_error(r, 403, "Forbidden").await; // No credential at all: 401. let r = c .post(&url) .json(&json!({ "did": &acc.did })) .send() .await .unwrap(); assert_error(r, 401, "AuthMissing").await; // Own inbox: allowed (zero rows updated — the account is new). let r = c .post(&url) .bearer_auth(&acc.access_jwt) .json(&json!({ "did": &acc.did })) .send() .await .unwrap(); assert_eq!(r.status().as_u16(), 200); let body: Value = r.json().await.unwrap(); assert_eq!(body["ok"], json!(true)); } #[tokio::test] async fn timeline_home_is_authenticated() { let Some((c, acc)) = ready().await else { return }; let base = appview_url(); let r = c .get(format!("{base}/api/timeline/home")) .query(&[("did", acc.did.as_str())]) .send() .await .unwrap(); assert_error(r, 401, "AuthMissing").await; let r = c .get(format!("{base}/api/timeline/home")) .query(&[("did", "did:plc:somebodyelse")]) .bearer_auth(&acc.access_jwt) .send() .await .unwrap(); assert_error(r, 403, "Forbidden").await; let r = c .get(format!("{base}/api/timeline/home")) .query(&[("did", acc.did.as_str())]) .bearer_auth(&acc.access_jwt) .send() .await .unwrap(); assert_eq!(r.status().as_u16(), 200); } #[tokio::test] async fn public_endpoints_stay_public() { let Some((c, acc)) = ready().await else { return }; let base = appview_url(); // Profiles, search, follower lists and the health probe are public // records in AT Proto. Putting them behind auth would be a // behaviour change no protocol rule asks for — assert they still // answer without a token. for (path, params) in [ ("/api/profile", vec![("did", acc.did.as_str())]), ("/api/search", vec![("q", "hello")]), ("/api/followers", vec![("did", acc.did.as_str())]), ("/api/following", vec![("did", acc.did.as_str())]), ("/healthz", vec![]), ] { let r = c .get(format!("{base}{path}")) .query(¶ms) .send() .await .unwrap(); assert_eq!( r.status().as_u16(), 200, "{path} must remain public, got {}", r.status() ); } } /// The AppView can only verify anything because the PDS publishes its /// public key. If this document ever stops carrying a usable key, every /// authenticated request degrades to `503 AuthUnavailable` — so assert /// the shape the AppView parses. #[tokio::test] async fn pds_publishes_a_usable_signing_key() { let c = client(); if !up(&c, PDS_URL).await { eprintln!("pds not running, skipping"); return; } let doc: Value = c .get(format!("{PDS_URL}/.well-known/did.json")) .send() .await .unwrap() .json() .await .unwrap(); let id = doc["id"].as_str().expect("did document needs an id"); assert!(id.starts_with("did:web:"), "id = {id}"); let vm = &doc["verificationMethod"][0]; assert_eq!(vm["type"], json!("Multikey")); assert_eq!(vm["controller"], json!(id)); let key = vm["publicKeyMultibase"] .as_str() .expect("verificationMethod needs publicKeyMultibase"); // base58-btc multibase: the `z` prefix is what the AppView's // decoder expects. assert!(key.starts_with('z'), "key = {key}"); }