use super::*; use axum::{body::Body, http::Request}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use k256::ecdsa::{Signature, signature::Signer}; use tower::ServiceExt; use wiremock::{ Mock, MockServer, ResponseTemplate, matchers::{body_json, header, method, path}, }; const ACTOR: &str = "did:plc:abcdefghijklmnopqrstuvwx"; const GATE: &str = "did:web:gate.example"; const CREATE: &str = "farm.tranquil.delegation.createAccount"; const LIST: &str = "farm.tranquil.delegation.listControlledAccounts"; const RESOLVE: &str = "sh.tangled.identity.resolveCommitters"; async fn setup() -> (App, MockServer) { let server = MockServer::start().await; let http = reqwest::Client::new(); let directory = Arc::new( JacquardResolver::new(ReqwestHttp::new(http.clone()), Default::default()).with_plc_source( PlcSource::PlcDirectory { base: Uri::parse(format!("{}/", server.uri()).as_str()) .unwrap() .to_owned(), }, ), ); let signing_key = SigningKey::from_slice(&[1; 32]).unwrap(); let gate = gate::Gate::builder( http.clone(), GATE, signing_key.clone(), server.uri(), "did:web:deliberi.example", ) .build(); let app = App { config: Arc::new(RuntimeConfig { did: GATE.into(), tranquil_url: server.uri(), signing_key, }), http, auth: ServiceAuthConfig::new(Did::new_owned(GATE).unwrap(), directory), gate: Arc::new(gate), }; let mut key = vec![0xe7, 1]; key.extend_from_slice( app.config .signing_key .verifying_key() .to_encoded_point(true) .as_bytes(), ); Mock::given(method("GET")).and(path(format!("/{ACTOR}"))).respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": ACTOR, "verificationMethod": [{"id": format!("{ACTOR}#atproto"), "type":"Multikey", "controller": ACTOR, "publicKeyMultibase": format!("z{}", bs58::encode(key).into_string())}] }))).mount(&server).await; (app, server) } fn token(app: &App, aud: &str, lxm: &str, exp: u64, key: Option<&SigningKey>) -> String { let payload = json!({"iss": ACTOR, "aud": aud, "lxm": lxm, "iat": now(), "exp": exp, "jti": format!("test-{lxm}")}); let input = format!( "{}.{}", URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#), URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()) ); let signature: Signature = key .unwrap_or(&app.config.signing_key) .sign(input.as_bytes()); format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature.to_bytes())) } async fn request(app: App, token: Option<&str>) -> Response { let mut request = Request::post("/xrpc/sh.tangled.delegation.createAccount") .header("content-type", "application/json"); if let Some(token) = token { request = request.header("authorization", format!("Bearer {token}")); } router(app) .oneshot( request .body(Body::from(json!({"handle":"org.example"}).to_string())) .unwrap(), ) .await .unwrap() } async fn email(server: &MockServer, values: Vec<&str>) { Mock::given(method("POST")) .and(path(format!("/xrpc/{RESOLVE}"))) .and(body_json(json!({"actor": ACTOR}))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"committers": values}))) .mount(server) .await; } #[tokio::test] async fn rejects_missing_wrong_audience_expired_wrong_method_and_bad_signature() { let (app, server) = setup().await; let bad_key = SigningKey::from_slice(&[2; 32]).unwrap(); let tokens = [ None, Some(token( &app, "did:web:other.example", CREATE, now() + 60, None, )), Some(token(&app, GATE, CREATE, now() - 1, None)), Some(token(&app, GATE, RESOLVE, now() + 60, None)), Some(token(&app, GATE, CREATE, now() + 60, Some(&bad_key))), ]; for token in tokens { assert!( request(app.clone(), token.as_deref()) .await .status() .is_client_error() ); } assert!( server .received_requests() .await .unwrap() .iter() .all(|r| r.method == "GET") ); } #[tokio::test] async fn requires_an_email_not_the_did_fallback_and_rejects_replay() { let (app, server) = setup().await; email(&server, vec![ACTOR]).await; let jwt = token(&app, GATE, CREATE, now() + 60, None); let response = request(app.clone(), Some(&jwt)).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); let body = axum::body::to_bytes(response.into_body(), 4096) .await .unwrap(); assert!( std::str::from_utf8(&body) .unwrap() .contains("VerifiedEmailRequired") ); assert!(request(app, Some(&jwt)).await.status().is_client_error()); assert_eq!( server .received_requests() .await .unwrap() .iter() .filter(|r| r.method == "POST") .count(), 1 ); } #[tokio::test] async fn upstream_client_errors_pass_through_and_server_errors_fail_closed() { let (app, server) = setup().await; let input = || CreateInput { handle: "org.example".into(), }; // No email mock yet, so the resolver 404s and the gate relays that status. let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::NOT_FOUND); assert_eq!(error.error, "UpstreamRejected"); email(&server, vec!["person@example.com"]).await; // Tranquil rejecting the creation is relayed with Tranquil's own status, // error code, and message, so a taken handle is distinguishable from the // per-controller cap. Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) .respond_with(ResponseTemplate::new(400).set_body_json( json!({"error": "InvalidDelegation", "message": "delegate cap reached"}), )) .up_to_n_times(1) .mount(&server) .await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::BAD_REQUEST); assert_eq!(error.error, "InvalidDelegation"); assert_eq!(error.message.as_deref(), Some("delegate cap reached")); Mock::given(path("/xrpc/_delegation.createDelegatedAccount")) .respond_with(ResponseTemplate::new(503)) .mount(&server) .await; let error = app .create(ACTOR, "create-token", input()) .await .err() .unwrap(); assert_eq!(error.status, StatusCode::BAD_GATEWAY); assert_eq!(error.error, "UpstreamUnavailable"); } #[tokio::test] async fn rejects_missing_nonce() { let (app, _) = setup().await; let claims: ServiceAuthClaims = serde_json::from_value(json!({ "iss": ACTOR, "aud": GATE, "iat": now(), "exp": now() + 60, "lxm": CREATE, "jti": null })) .unwrap(); assert!(app.verify_claims(&claims).await.is_err()); } #[tokio::test] async fn forwards_the_create_token_without_controller_overrides() { let (app, server) = setup().await; let create = token(&app, GATE, CREATE, now() + 60, None); email(&server, vec!["person@example.com"]).await; Mock::given(method("POST")) .and(path("/xrpc/_delegation.createDelegatedAccount")) .and(header("authorization", format!("Bearer {create}"))) .and(body_json( json!({"handle":"org.example", "controllerScopes":OWNER}), )) .respond_with( ResponseTemplate::new(200) .set_body_json(json!({"did":"did:plc:org", "handle":"org.example"})), ) .expect(1) .mount(&server) .await; let response = request(app, Some(&create)).await; assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), 4096) .await .unwrap(); let account: Account = serde_json::from_slice(&body).unwrap(); assert_eq!(account.controller_did, ACTOR); // The gate never enumerates delegates or opens a session of its own. assert!(server.received_requests().await.unwrap().iter().all(|r| { r.url.query().is_none() && !r.url.path().contains("createSession") && !r.url.path().contains("listControlledAccounts") })); }