mod common; use axum::body::Body; use axum::http::{Request, StatusCode}; use happyview::db::{adapt_sql, now_rfc3339}; use happyview::oauth::pds_write::generate_dpop_proof; use http_body_util::BodyExt; use serde_json::{Value, json}; use serial_test::serial; use sha2::Digest; use tower::ServiceExt; use common::app::TestApp; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async fn json_body(resp: axum::response::Response) -> Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap() } fn admin_get( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), ) -> Request
{ Request::builder() .uri(uri) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } fn admin_post( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), body: &Value, ) -> Request { Request::builder() .method("POST") .uri(uri) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } fn admin_put( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), body: &Value, ) -> Request { Request::builder() .method("PUT") .uri(uri) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } fn admin_delete( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), ) -> Request { Request::builder() .method("DELETE") .uri(uri) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap() } fn sample_api_client_body() -> Value { json!({ "name": "Test App", "client_id_url": "https://testapp.example.com/oauth-client-metadata.json", "client_uri": "https://testapp.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }) } // --------------------------------------------------------------------------- // Create // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn create_api_client_returns_201() { let app = TestApp::new().await; let body = sample_api_client_body(); let resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let json = json_body(resp).await; assert_eq!(json["name"], "Test App"); assert_eq!( json["client_id_url"], "https://testapp.example.com/oauth-client-metadata.json" ); let key = json["client_key"].as_str().unwrap(); assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); assert_eq!(key.len(), 36); // "hvc_" (4) + 32 hex chars assert!(json["id"].as_str().is_some()); } #[tokio::test] #[serial] #[ignore] async fn create_api_client_duplicate_client_id_url_fails() { let app = TestApp::new().await; let body = sample_api_client_body(); // First create succeeds let resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // Second create with same client_id_url should fail (UNIQUE constraint) let resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] #[serial] #[ignore] async fn create_api_client_registers_in_oauth_registry() { let app = TestApp::new().await; let body = sample_api_client_body(); let resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); // The OAuth registry should now have this client let client_id_url = "https://testapp.example.com/oauth-client-metadata.json"; assert!( app.state.oauth.get(client_id_url).is_some(), "OAuth registry should contain the newly created client" ); } // --------------------------------------------------------------------------- // List // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn list_api_clients_empty() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert!(json.as_array().unwrap().is_empty()); } #[tokio::test] #[serial] #[ignore] async fn list_api_clients_returns_created_clients() { let app = TestApp::new().await; // Create two clients let body1 = json!({ "name": "App One", "client_id_url": "https://one.example.com/oauth-client-metadata.json", "client_uri": "https://one.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); let body2 = json!({ "name": "App Two", "client_id_url": "https://two.example.com/oauth-client-metadata.json", "client_uri": "https://two.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); app.router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body1)) .await .unwrap(); app.router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body2)) .await .unwrap(); let resp = app .router .clone() .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; let arr = json.as_array().unwrap(); assert_eq!(arr.len(), 2); // Verify fields are present for client in arr { assert!(client["id"].as_str().is_some()); assert!(client["client_key"].as_str().is_some()); assert!(client["name"].as_str().is_some()); assert!(client["client_id_url"].as_str().is_some()); assert!(client["is_active"].as_bool().is_some()); assert!(client["created_by"].as_str().is_some()); } } // --------------------------------------------------------------------------- // Get // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn get_api_client_returns_details() { let app = TestApp::new().await; let body = sample_api_client_body(); let create_resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); let created = json_body(create_resp).await; let id = created["id"].as_str().unwrap(); let resp = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["name"], "Test App"); assert_eq!( json["client_id_url"], "https://testapp.example.com/oauth-client-metadata.json" ); assert_eq!(json["client_uri"], "https://testapp.example.com"); assert_eq!(json["scopes"], "atproto"); assert_eq!(json["is_active"], true); assert_eq!(json["created_by"], "did:plc:testadmin"); assert!(json["redirect_uris"].as_array().unwrap().len() == 1); } #[tokio::test] #[serial] #[ignore] async fn get_api_client_not_found() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot(admin_get( "/admin/api-clients/00000000-0000-0000-0000-000000000000", app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn update_api_client_changes_fields() { let app = TestApp::new().await; // Create let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &sample_api_client_body(), )) .await .unwrap(); let created = json_body(create_resp).await; let id = created["id"].as_str().unwrap(); // Update let update_body = json!({ "name": "Updated App", "scopes": "atproto transition:generic" }); let resp = app .router .clone() .oneshot(admin_put( &format!("/admin/api-clients/{id}"), app.admin_cookie(), &update_body, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Verify let resp = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{id}"), app.admin_cookie(), )) .await .unwrap(); let json = json_body(resp).await; assert_eq!(json["name"], "Updated App"); assert_eq!(json["scopes"], "atproto transition:generic"); // Unchanged fields should remain assert_eq!(json["client_uri"], "https://testapp.example.com"); } #[tokio::test] #[serial] #[ignore] async fn update_api_client_not_found() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot(admin_put( "/admin/api-clients/00000000-0000-0000-0000-000000000000", app.admin_cookie(), &json!({"name": "Nope"}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] #[serial] #[ignore] async fn update_api_client_deactivate_removes_from_registry() { let app = TestApp::new().await; let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &sample_api_client_body(), )) .await .unwrap(); let created = json_body(create_resp).await; let id = created["id"].as_str().unwrap(); let client_id_url = "https://testapp.example.com/oauth-client-metadata.json"; assert!(app.state.oauth.get(client_id_url).is_some()); // Deactivate let resp = app .router .clone() .oneshot(admin_put( &format!("/admin/api-clients/{id}"), app.admin_cookie(), &json!({"is_active": false}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Should be removed from registry assert!( app.state.oauth.get(client_id_url).is_none(), "Deactivated client should be removed from OAuth registry" ); } // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn delete_api_client_returns_204() { let app = TestApp::new().await; let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &sample_api_client_body(), )) .await .unwrap(); let created = json_body(create_resp).await; let id = created["id"].as_str().unwrap(); let resp = app .router .clone() .oneshot(admin_delete( &format!("/admin/api-clients/{id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); // Verify gone from list let resp = app .router .clone() .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) .await .unwrap(); let json = json_body(resp).await; assert!(json.as_array().unwrap().is_empty()); } #[tokio::test] #[serial] #[ignore] async fn delete_api_client_removes_from_oauth_registry() { let app = TestApp::new().await; let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &sample_api_client_body(), )) .await .unwrap(); let created = json_body(create_resp).await; let id = created["id"].as_str().unwrap(); let client_id_url = "https://testapp.example.com/oauth-client-metadata.json"; assert!(app.state.oauth.get(client_id_url).is_some()); app.router .clone() .oneshot(admin_delete( &format!("/admin/api-clients/{id}"), app.admin_cookie(), )) .await .unwrap(); assert!( app.state.oauth.get(client_id_url).is_none(), "Deleted client should be removed from OAuth registry" ); } #[tokio::test] #[serial] #[ignore] async fn delete_api_client_not_found() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot(admin_delete( "/admin/api-clients/00000000-0000-0000-0000-000000000000", app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } // --------------------------------------------------------------------------- // Permission enforcement // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn api_clients_no_auth_returns_401() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/api-clients") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] #[serial] #[ignore] async fn api_clients_non_admin_returns_403() { let app = TestApp::new().await; let resp = app .router .clone() .oneshot(admin_get( "/admin/api-clients", common::auth::admin_cookie_header("did:plc:notadmin", &app.state.cookie_key), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } // --------------------------------------------------------------------------- // OAuth registry (unit-level via AppState) // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn oauth_registry_get_or_default_returns_default_for_unknown() { let app = TestApp::new().await; let client = app .state .oauth .get_or_default(Some("https://unknown.example.com/metadata.json")); let default = app.state.oauth.primary_client(); // Should be the same Arc (default client) assert!(std::sync::Arc::ptr_eq(&client, &default)); } #[tokio::test] #[serial] #[ignore] async fn oauth_registry_get_or_default_returns_default_for_none() { let app = TestApp::new().await; let client = app.state.oauth.get_or_default(None); let default = app.state.oauth.primary_client(); assert!(std::sync::Arc::ptr_eq(&client, &default)); } // --------------------------------------------------------------------------- // Rate limit config on API clients // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn create_api_client_with_rate_limit_overrides() { let app = TestApp::new().await; let body = json!({ "name": "Rate Limited App", "client_id_url": "https://ratelimited.example.com/oauth-client-metadata.json", "client_uri": "https://ratelimited.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto", "rate_limit_capacity": 50, "rate_limit_refill_rate": 1.5 }); let resp = app .router .clone() .oneshot(admin_post("/admin/api-clients", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let created = json_body(resp).await; let id = created["id"].as_str().unwrap(); // Verify overrides persisted let resp = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{id}"), app.admin_cookie(), )) .await .unwrap(); let json = json_body(resp).await; assert_eq!(json["rate_limit_capacity"], 50); assert_eq!(json["rate_limit_refill_rate"], 1.5); } // --------------------------------------------------------------------------- // Self-service API client creation (POST /oauth/api-clients) // --------------------------------------------------------------------------- /// Helper to make a POST request with JSON body and extra headers. fn post_json_with_headers( uri: &str, body: &serde_json::Value, headers: Vec<(&str, &str)>, ) -> Request { let mut builder = Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .header("host", "127.0.0.1:0"); for (name, value) in headers { builder = builder.header(name, value); } builder .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } /// Parse a response body as JSON, returning `null` on empty/invalid bodies. async fn response_json(resp: axum::response::Response) -> Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap_or(json!(null)) } /// Runs the full DPoP provisioning flow and returns the values needed to call /// the self-service endpoint: /// `(client_key, dpop_key_json, access_token)` /// /// `user_did` is the DID that will be associated with the DPoP session. async fn setup_dpop_session(app: &TestApp, user_did: &str) -> (String, Value, String) { let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; // 1. Provision DPoP key let key_req = post_json_with_headers( "/oauth/dpop-keys", &json!({}), vec![ ("x-client-key", &client_key), ("x-client-secret", &client_secret), ], ); let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); assert_eq!( key_resp.status(), StatusCode::CREATED, "dpop key provisioning failed" ); let key_body = response_json(key_resp).await; let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); let dpop_key = key_body["dpop_key"].clone(); // 2. Register session let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); let session_req = post_json_with_headers( "/oauth/sessions", &json!({ "provision_id": provision_id, "did": user_did, "access_token": &access_token, "scopes": "atproto", "pds_url": "https://pds.example.com", }), vec![ ("x-client-key", &client_key), ("x-client-secret", &client_secret), ], ); let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); assert_eq!( session_resp.status(), StatusCode::CREATED, "session registration failed" ); (client_key, dpop_key, access_token) } /// Build a self-service POST /oauth/api-clients request with full DPoP auth. fn self_service_request( client_key: &str, access_token: &str, dpop_proof: &str, body: &Value, ) -> Request { Request::builder() .method("POST") .uri("/oauth/api-clients") .header("host", "127.0.0.1:0") .header("content-type", "application/json") .header("x-client-key", client_key) .header("authorization", format!("DPoP {}", access_token)) .header("dpop", dpop_proof) .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap() } // --------------------------------------------------------------------------- // Happy path // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn test_self_service_create_confidential_child_client() { let app = TestApp::new_with_encryption().await; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; let request_url = "http://127.0.0.1:0/oauth/api-clients"; let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) .expect("failed to generate DPoP proof"); let body = json!({ "name": "My Confidential Child", "client_id_url": "https://child-confidential.example.com/oauth-client-metadata.json", "client_uri": "https://child-confidential.example.com", "redirect_uris": ["https://child-confidential.example.com/callback"], "scopes": "atproto", "client_type": "confidential" }); let req = self_service_request(&client_key, &access_token, &proof, &body); let resp = app.router.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let json = response_json(resp).await; // Verify all expected fields assert!(json["id"].as_str().is_some(), "response should have id"); let key = json["client_key"].as_str().unwrap(); assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); let secret = json["client_secret"].as_str().unwrap(); assert!( secret.starts_with("hvs_"), "client_secret should start with hvs_" ); assert_eq!(json["name"], "My Confidential Child"); assert_eq!( json["client_id_url"], "https://child-confidential.example.com/oauth-client-metadata.json" ); assert_eq!(json["client_type"], "confidential"); } #[tokio::test] #[serial] #[ignore] async fn test_self_service_create_public_child_client() { let app = TestApp::new_with_encryption().await; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; let request_url = "http://127.0.0.1:0/oauth/api-clients"; let proof = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None) .expect("failed to generate DPoP proof"); let body = json!({ "name": "My Public Child", "client_id_url": "https://child-public.example.com/oauth-client-metadata.json", "client_uri": "https://child-public.example.com", "redirect_uris": ["https://child-public.example.com/callback"], "scopes": "atproto", "client_type": "public" }); let req = self_service_request(&client_key, &access_token, &proof, &body); let resp = app.router.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let json = response_json(resp).await; assert!(json["id"].as_str().is_some(), "response should have id"); let key = json["client_key"].as_str().unwrap(); assert!(key.starts_with("hvc_"), "client_key should start with hvc_"); assert_eq!(json["name"], "My Public Child"); assert_eq!( json["client_id_url"], "https://child-public.example.com/oauth-client-metadata.json" ); assert_eq!(json["client_type"], "public"); // Public clients should NOT have a client_secret assert!( json["client_secret"].is_null(), "public client should not have a client_secret" ); } // --------------------------------------------------------------------------- // Error cases // --------------------------------------------------------------------------- #[tokio::test] #[serial] #[ignore] async fn test_self_service_child_cannot_create_children() { let app = TestApp::new_with_encryption().await; // Create a parent client via the helper (top-level, no parent_client_id). let (_parent_key, _parent_secret, parent_id) = app.create_api_client("confidential", None).await; // Insert a child client directly in the DB with parent_client_id set. let child_key = format!("hvc_{}", hex::encode([0xAAu8; 16])); let child_secret = format!("hvs_{}", hex::encode([0xBBu8; 32])); let child_secret_hash = hex::encode(sha2::Sha256::digest(child_secret.as_bytes())); let child_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, is_active, created_by, created_at, updated_at, parent_client_id, owner_did) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) .bind(&child_id) .bind(&child_key) .bind(&child_secret_hash) .bind("child-client") .bind("https://child-no-nest.example.com/oauth-client-metadata.json") .bind("https://child-no-nest.example.com") .bind("[]") .bind("atproto") .bind("confidential") .bind("did:plc:testadmin") .bind(&now) .bind(&now) .bind(&parent_id) .bind("did:plc:testadmin") .execute(&app.state.db) .await .expect("failed to insert child client"); // The endpoint checks parent_client_id IS NULL at step 6, BEFORE DPoP // validation. So we just need the DPoP headers to exist — they do not // need to be cryptographically valid. let body = json!({ "name": "Grandchild", "client_id_url": "https://grandchild.example.com/oauth-client-metadata.json", "client_uri": "https://grandchild.example.com", "redirect_uris": ["https://grandchild.example.com/callback"], "scopes": "atproto", "client_type": "confidential" }); let req = Request::builder() .method("POST") .uri("/oauth/api-clients") .header("host", "127.0.0.1:0") .header("content-type", "application/json") .header("x-client-key", &child_key) .header("authorization", "DPoP fake-token") .header("dpop", "fake-proof") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(); let resp = app.router.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] #[serial] #[ignore] async fn test_self_service_duplicate_client_id_url() { let app = TestApp::new_with_encryption().await; let (client_key, dpop_key, access_token) = setup_dpop_session(&app, "did:plc:testadmin").await; let shared_url = "https://dup-test.example.com/oauth-client-metadata.json"; // First creation should succeed. let request_url = "http://127.0.0.1:0/oauth/api-clients"; let proof1 = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 1"); let body = json!({ "name": "First Child", "client_id_url": shared_url, "client_uri": "https://dup-test.example.com", "redirect_uris": ["https://dup-test.example.com/callback"], "scopes": "atproto", "client_type": "confidential" }); let req1 = self_service_request(&client_key, &access_token, &proof1, &body); let resp1 = app.router.clone().oneshot(req1).await.unwrap(); assert_eq!(resp1.status(), StatusCode::CREATED); // Second creation with the same client_id_url should fail with 409. let proof2 = generate_dpop_proof(&dpop_key, "POST", request_url, &access_token, None).expect("proof 2"); let body2 = json!({ "name": "Second Child", "client_id_url": shared_url, "client_uri": "https://dup-test2.example.com", "redirect_uris": ["https://dup-test2.example.com/callback"], "scopes": "atproto", "client_type": "confidential" }); let req2 = self_service_request(&client_key, &access_token, &proof2, &body2); let resp2 = app.router.clone().oneshot(req2).await.unwrap(); assert_eq!(resp2.status(), StatusCode::CONFLICT); } #[tokio::test] #[serial] #[ignore] async fn test_self_service_missing_client_key() { let app = TestApp::new_with_encryption().await; let body = json!({ "name": "No Key Client", "client_id_url": "https://nokey.example.com/oauth-client-metadata.json", "client_uri": "https://nokey.example.com", "redirect_uris": ["https://nokey.example.com/callback"], "scopes": "atproto", "client_type": "confidential" }); // No x-client-key header at all. let req = Request::builder() .method("POST") .uri("/oauth/api-clients") .header("host", "127.0.0.1:0") .header("content-type", "application/json") .header("authorization", "DPoP fake-token") .header("dpop", "fake-proof") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(); let resp = app.router.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] #[serial] #[ignore] async fn test_self_service_invalid_client_type() { let app = TestApp::new_with_encryption().await; // Step 3 (client_type validation) happens before step 5 (client resolution). // The request just needs the required headers to exist. let body = json!({ "name": "Invalid Type Client", "client_id_url": "https://badtype.example.com/oauth-client-metadata.json", "client_uri": "https://badtype.example.com", "redirect_uris": ["https://badtype.example.com/callback"], "scopes": "atproto", "client_type": "invalid" }); let req = Request::builder() .method("POST") .uri("/oauth/api-clients") .header("host", "127.0.0.1:0") .header("content-type", "application/json") .header("x-client-key", "hvc_doesnotmatter") .header("authorization", "DPoP fake-token") .header("dpop", "fake-proof") .body(Body::from(serde_json::to_vec(&body).unwrap())) .unwrap(); let resp = app.router.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] #[serial] #[ignore] async fn test_self_service_parent_owner_not_in_users() { let app = TestApp::new_with_encryption().await; // Create a parent API client whose created_by DID is NOT in the users table. let orphan_did = "did:plc:orphan"; let (client_key, client_secret, _id) = { use happyview::db::{adapt_sql, now_rfc3339}; use rand::RngCore; use sha2::{Digest, Sha256}; let mut key_bytes = [0u8; 16]; rand::rng().fill_bytes(&mut key_bytes); let client_key = format!("hvc_{}", hex::encode(key_bytes)); let mut secret_bytes = [0u8; 32]; rand::rng().fill_bytes(&mut secret_bytes); let client_secret = format!("hvs_{}", hex::encode(secret_bytes)); let secret_hash = hex::encode(Sha256::digest(client_secret.as_bytes())); let id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO api_clients (id, client_key, client_secret_hash, name, client_id_url, client_uri, redirect_uris, scopes, client_type, allowed_origins, is_active, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)", app.state.db_backend, ); sqlx::query(&sql) .bind(&id) .bind(&client_key) .bind(&secret_hash) .bind("orphan-client") .bind(format!("https://orphan.example.com/oauth/{}", &id[..8])) .bind("https://orphan.example.com") .bind("[]") .bind("atproto") .bind("confidential") .bind(None::