Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113mod common;
use axum::body::Body;use axum::http::{Request, StatusCode};use happyview::db::{adapt_sql, now_rfc3339};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<Body> { 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<Body> { 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<Body> { 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<Body> { 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]async fn create_api_client_returns_201() { common::require_db!(); 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]async fn create_api_client_duplicate_client_id_url_fails() { common::require_db!(); 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]async fn create_api_client_registers_in_oauth_registry() { common::require_db!(); 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]async fn list_api_clients_empty() { common::require_db!(); 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]async fn list_api_clients_returns_created_clients() { common::require_db!(); 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]async fn get_api_client_returns_details() { common::require_db!(); 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]async fn get_api_client_not_found() { common::require_db!(); 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]async fn update_api_client_changes_fields() { common::require_db!(); 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]async fn update_api_client_not_found() { common::require_db!(); 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]async fn update_api_client_deactivate_removes_from_registry() { common::require_db!(); 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]async fn delete_api_client_returns_204() { common::require_db!(); 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]async fn delete_api_client_removes_from_oauth_registry() { common::require_db!(); 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]async fn delete_api_client_not_found() { common::require_db!(); 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]async fn api_clients_no_auth_returns_401() { common::require_db!(); 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]async fn api_clients_non_admin_returns_403() { common::require_db!(); 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]async fn oauth_registry_get_or_default_returns_default_for_unknown() { common::require_db!(); 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]async fn oauth_registry_get_or_default_returns_default_for_none() { common::require_db!(); 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]async fn create_api_client_with_rate_limit_overrides() { common::require_db!(); 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 client creation tests removed — the /oauth/api-clients route// no longer exists. The XRPC equivalent (dev.happyview.createApiClient) is// tested in tests/dev_happyview.rs.
// ---------------------------------------------------------------------------// Cascade: deactivate / delete parent cascades to children// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------// Cascade: deactivate / delete parent cascades to children// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn test_deactivate_parent_cascades_to_children() { common::require_db!(); let app = TestApp::new().await;
// Create parent via admin API let parent_body = json!({ "name": "Cascade Parent", "client_id_url": "https://cascade-deactivate-parent.example.com/oauth-client-metadata.json", "client_uri": "https://cascade-deactivate-parent.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &parent_body, )) .await .unwrap(); assert_eq!(create_resp.status(), StatusCode::CREATED); let created = json_body(create_resp).await; let parent_id = created["id"].as_str().unwrap().to_string();
// Insert child directly in DB let child_id = uuid::Uuid::new_v4().to_string(); let child_key = format!("hvc_{}", hex::encode([0xCCu8; 16])); let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_cc".as_bytes())); 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_hash) .bind("Cascade Child") .bind("https://cascade-deactivate-child.example.com/metadata.json") .bind("https://cascade-deactivate-child.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");
// Deactivate the parent let deactivate_resp = app .router .clone() .oneshot(admin_put( &format!("/admin/api-clients/{parent_id}"), app.admin_cookie(), &json!({"is_active": false}), )) .await .unwrap(); assert_eq!(deactivate_resp.status(), StatusCode::NO_CONTENT);
// Verify parent is deactivated let parent_get = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{parent_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(parent_get.status(), StatusCode::OK); let parent_json = json_body(parent_get).await; assert_eq!( parent_json["is_active"], false, "parent should be deactivated" );
// Verify child is also deactivated let child_get = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{child_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(child_get.status(), StatusCode::OK); let child_json = json_body(child_get).await; assert_eq!( child_json["is_active"], false, "child should be deactivated by cascade" );}
#[tokio::test]#[serial]async fn test_delete_parent_cascades_to_children() { common::require_db!(); let app = TestApp::new().await;
// Create parent via admin API let parent_body = json!({ "name": "Delete Parent", "client_id_url": "https://cascade-delete-parent.example.com/oauth-client-metadata.json", "client_uri": "https://cascade-delete-parent.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &parent_body, )) .await .unwrap(); assert_eq!(create_resp.status(), StatusCode::CREATED); let created = json_body(create_resp).await; let parent_id = created["id"].as_str().unwrap().to_string();
// Insert child directly in DB let child_id = uuid::Uuid::new_v4().to_string(); let child_key = format!("hvc_{}", hex::encode([0xDDu8; 16])); let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_dd".as_bytes())); 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_hash) .bind("Delete Child") .bind("https://cascade-delete-child.example.com/metadata.json") .bind("https://cascade-delete-child.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");
// Delete the parent let delete_resp = app .router .clone() .oneshot(admin_delete( &format!("/admin/api-clients/{parent_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
// Verify parent is gone let parent_get = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{parent_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(parent_get.status(), StatusCode::NOT_FOUND);
// Verify child is also gone (ON DELETE CASCADE) let child_get = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients/{child_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(child_get.status(), StatusCode::NOT_FOUND);}
// ---------------------------------------------------------------------------// List filtering: parent_id query param and response fields// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn test_list_api_clients_filter_by_parent() { common::require_db!(); let app = TestApp::new().await;
// Create two parents via admin API let parent1_body = json!({ "name": "Filter Parent 1", "client_id_url": "https://filter-parent-1.example.com/oauth-client-metadata.json", "client_uri": "https://filter-parent-1.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); let parent2_body = json!({ "name": "Filter Parent 2", "client_id_url": "https://filter-parent-2.example.com/oauth-client-metadata.json", "client_uri": "https://filter-parent-2.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" });
let p1_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &parent1_body, )) .await .unwrap(); assert_eq!(p1_resp.status(), StatusCode::CREATED); let p1 = json_body(p1_resp).await; let parent1_id = p1["id"].as_str().unwrap().to_string();
let p2_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &parent2_body, )) .await .unwrap(); assert_eq!(p2_resp.status(), StatusCode::CREATED); let p2 = json_body(p2_resp).await; let parent2_id = p2["id"].as_str().unwrap().to_string();
// Insert a child under parent 1 let child1_id = uuid::Uuid::new_v4().to_string(); let child1_key = format!("hvc_{}", hex::encode([0xE1u8; 16])); let child1_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e1".as_bytes())); 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(&child1_id) .bind(&child1_key) .bind(&child1_hash) .bind("Child of Parent 1") .bind("https://filter-child-1.example.com/metadata.json") .bind("https://filter-child-1.example.com") .bind("[]") .bind("atproto") .bind("confidential") .bind("did:plc:testadmin") .bind(&now) .bind(&now) .bind(&parent1_id) .bind("did:plc:testadmin") .execute(&app.state.db) .await .expect("failed to insert child1");
// Insert a child under parent 2 let child2_id = uuid::Uuid::new_v4().to_string(); let child2_key = format!("hvc_{}", hex::encode([0xE2u8; 16])); let child2_hash = hex::encode(sha2::Sha256::digest("hvs_fake_e2".as_bytes()));
let sql2 = 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(&sql2) .bind(&child2_id) .bind(&child2_key) .bind(&child2_hash) .bind("Child of Parent 2") .bind("https://filter-child-2.example.com/metadata.json") .bind("https://filter-child-2.example.com") .bind("[]") .bind("atproto") .bind("confidential") .bind("did:plc:testadmin") .bind(&now) .bind(&now) .bind(&parent2_id) .bind("did:plc:testadmin") .execute(&app.state.db) .await .expect("failed to insert child2");
// Filter by parent1_id — should return only child1 let resp = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients?parent_id={parent1_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let arr = json_body(resp).await; let items = arr.as_array().expect("response should be an array"); assert_eq!( items.len(), 1, "should return exactly one child for parent 1" ); assert_eq!(items[0]["id"], child1_id); assert_eq!(items[0]["name"], "Child of Parent 1");
// Filter by parent2_id — should return only child2 let resp2 = app .router .clone() .oneshot(admin_get( &format!("/admin/api-clients?parent_id={parent2_id}"), app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp2.status(), StatusCode::OK); let arr2 = json_body(resp2).await; let items2 = arr2.as_array().expect("response should be an array"); assert_eq!( items2.len(), 1, "should return exactly one child for parent 2" ); assert_eq!(items2[0]["id"], child2_id); assert_eq!(items2[0]["name"], "Child of Parent 2");}
#[tokio::test]#[serial]async fn test_list_api_clients_includes_parent_and_owner_fields() { common::require_db!(); let app = TestApp::new().await;
// Create a top-level parent via admin API let parent_body = json!({ "name": "Fields Parent", "client_id_url": "https://fields-parent.example.com/oauth-client-metadata.json", "client_uri": "https://fields-parent.example.com", "redirect_uris": ["https://happyview.example.com/auth/callback"], "scopes": "atproto" }); let create_resp = app .router .clone() .oneshot(admin_post( "/admin/api-clients", app.admin_cookie(), &parent_body, )) .await .unwrap(); assert_eq!(create_resp.status(), StatusCode::CREATED); let created = json_body(create_resp).await; let parent_id = created["id"].as_str().unwrap().to_string();
// Insert a child with owner_did set let child_id = uuid::Uuid::new_v4().to_string(); let child_key = format!("hvc_{}", hex::encode([0xFFu8; 16])); let child_hash = hex::encode(sha2::Sha256::digest("hvs_fake_ff".as_bytes())); let now = now_rfc3339(); let owner_did = "did:plc:childowner";
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_hash) .bind("Fields Child") .bind("https://fields-child.example.com/metadata.json") .bind("https://fields-child.example.com") .bind("[]") .bind("atproto") .bind("confidential") .bind("did:plc:testadmin") .bind(&now) .bind(&now) .bind(&parent_id) .bind(owner_did) .execute(&app.state.db) .await .expect("failed to insert child");
// List all clients let resp = app .router .clone() .oneshot(admin_get("/admin/api-clients", app.admin_cookie())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let arr = json_body(resp).await; let items = arr.as_array().expect("response should be an array"); assert_eq!(items.len(), 2, "should have parent + child");
// Find the parent in the list let parent_item = items .iter() .find(|c| c["id"].as_str() == Some(&parent_id)) .expect("parent should be in list"); // Top-level client has null parent_client_id and null owner_did assert!( parent_item["parent_client_id"].is_null(), "top-level client should have null parent_client_id" ); assert!( parent_item["owner_did"].is_null(), "admin-created client should have null owner_did" );
// Find the child in the list let child_item = items .iter() .find(|c| c["id"].as_str() == Some(&child_id)) .expect("child should be in list"); assert_eq!( child_item["parent_client_id"].as_str(), Some(parent_id.as_str()), "child should reference its parent" ); assert_eq!( child_item["owner_did"].as_str(), Some(owner_did), "child should have owner_did set" );}