A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856mod common;
use axum::body::Body;use axum::http::{Request, StatusCode};use http_body_util::BodyExt;use serde_json::{Value, json};use serial_test::serial;use tower::ServiceExt;
use common::app::TestApp;use common::plc;
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()}
// ---------------------------------------------------------------------------// Setup status defaults// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn setup_status_returns_defaults_when_no_identity() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["setup_complete"], false); assert!(body["identity_mode"].is_null());}
// ---------------------------------------------------------------------------// Setup identity sets mode// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn setup_identity_sets_mode() { common::require_db!(); let mut app = TestApp::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["identity_mode"], "did_web"); assert_eq!(body["identity_configured"], true);}
// ---------------------------------------------------------------------------// Setup identity rejects when complete// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn setup_identity_rejects_when_complete() { common::require_db!(); let mut app = TestApp::new().await; app.setup_did_web().await;
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::FORBIDDEN, "setup identity should reject when setup is complete" );}
// ---------------------------------------------------------------------------// Setup complete marks done// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn setup_complete_marks_done() { common::require_db!(); let app = TestApp::new().await;
// Set identity to not_exposed (no encryption key needed) let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Mark complete let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/complete") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Verify status let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["setup_complete"], true);}
// ---------------------------------------------------------------------------// Rotation key export// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn rotation_key_export() { common::require_db!(); let mut app = TestApp::new().await; let encryption_key = [0x42u8; 32]; app.state.config.token_encryption_key = Some(encryption_key); app.rebuild_router();
// Set identity to did_plc via the endpoint (which generates both keys) let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Do NOT mark setup complete -- rotation key export requires setup incomplete
// GET rotation key let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/rotation-key") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::OK, "rotation key export should succeed for did_plc" );
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes(); assert!( !body_bytes.is_empty(), "rotation key response should have binary content" ); // The decrypted key should be 32 bytes (P-256 private key) assert_eq!(body_bytes.len(), 32, "rotation key should be 32 bytes");}
// ---------------------------------------------------------------------------// Rotation key export rejects non-PLC// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn rotation_key_export_rejects_non_plc() { common::require_db!(); let mut app = TestApp::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
// Set identity to did_web (not complete -- so guard passes but mode check fails) let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// GET rotation key should fail for did_web let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/rotation-key") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "rotation key export should reject non-plc mode" );}
// ---------------------------------------------------------------------------// Resolve identity endpoint// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn resolve_identity_empty_query_returns_empty() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/resolve?q=") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; let results = body.as_array().unwrap(); assert!(results.is_empty(), "empty query should return empty array");}
#[tokio::test]#[serial]async fn resolve_identity_with_did_returns_result() { common::require_db!(); let app = TestApp::new().await;
// Resolving a DID that doesn't exist should still return the DID as-is (fallback path) let resp = app .router .clone() .oneshot( app.authed_request() .uri("/api/setup/resolve?q=did%3Aplc%3Atestresolver") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; let results = body.as_array().unwrap(); assert_eq!(results.len(), 1, "DID input should return one result"); assert_eq!(results[0]["did"], "did:plc:testresolver");}
// ---------------------------------------------------------------------------// PLC register endpoint// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn plc_register_creates_did() { common::require_db!(); let mut app = TestApp::new().await; let plc_store = plc::setup_mock_plc(&app.mock_server).await;
app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
// Set identity to did_plc via the setup endpoint (generates both keys) let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Register the DID via the PLC directory let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::OK, "plc_register should return 200 with the DID" ); let body = json_body(resp).await; let did = body["did"].as_str().unwrap(); assert!( did.starts_with("did:plc:"), "DID should start with did:plc:" );
// Verify the PLC store received the genesis document let store = plc_store.read().await; assert!( store.contains_key(did), "PLC store should contain the registered DID" ); let genesis = store.get(did).unwrap(); assert_eq!(genesis["type"], "plc_operation"); assert!(genesis["sig"].is_string(), "genesis should be signed");}
#[tokio::test]#[serial]async fn plc_register_rejects_non_plc_mode() { common::require_db!(); let mut app = TestApp::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
// Set identity to did_web let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "plc_register should reject non-plc mode" );}
#[tokio::test]#[serial]async fn plc_register_rejects_duplicate() { common::require_db!(); let mut app = TestApp::new().await; let _plc_store = plc::setup_mock_plc(&app.mock_server).await;
app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// First registration succeeds let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Second registration should fail (DID already set) let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::CONFLICT, "duplicate plc_register should return 409" );}
// ---------------------------------------------------------------------------// Attach auth confirm endpoint// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn attach_auth_confirm_restores_cookie() { common::require_db!(); let app = TestApp::new().await;
// Set up attach_account mode with a known attached DID let attached_did = "did:plc:attachedaccount"; happyview::service_identity::upsert_identity( &app.state.db, app.state.db_backend, &happyview::service_identity::IdentityMode::AttachAccount, None, None, None, Some(attached_did), ) .await .unwrap();
// Build a cookie as the attached account (simulating post-OAuth state) let attached_cookie = crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key);
// POST attach-auth/confirm to restore the admin's cookie let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/api/setup/attach-auth/confirm") .header(attached_cookie.0, attached_cookie.1) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "original_did": &app.admin_did })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::NO_CONTENT, "attach_auth_confirm should return 204" );
// Verify the Set-Cookie header is present (cookie was restored) assert!( resp.headers().contains_key("set-cookie"), "response should set a new cookie" );}
#[tokio::test]#[serial]async fn attach_auth_confirm_rejects_invalid_original_did() { common::require_db!(); let app = TestApp::new().await;
let attached_did = "did:plc:attachedaccount2"; happyview::service_identity::upsert_identity( &app.state.db, app.state.db_backend, &happyview::service_identity::IdentityMode::AttachAccount, None, None, None, Some(attached_did), ) .await .unwrap();
let attached_cookie = crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key);
// Try with empty original_did let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/api/setup/attach-auth/confirm") .header(attached_cookie.0, attached_cookie.1) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "original_did": "not-a-did" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "invalid original_did should be rejected" );}
#[tokio::test]#[serial]async fn attach_auth_confirm_rejects_mismatched_session() { common::require_db!(); let app = TestApp::new().await;
let attached_did = "did:plc:attachedaccount3"; happyview::service_identity::upsert_identity( &app.state.db, app.state.db_backend, &happyview::service_identity::IdentityMode::AttachAccount, None, None, None, Some(attached_did), ) .await .unwrap();
// Cookie is for the admin user, but attached_account_did is different let wrong_cookie = app.admin_cookie();
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/api/setup/attach-auth/confirm") .header(wrong_cookie.0, wrong_cookie.1) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "original_did": &app.admin_did })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::UNAUTHORIZED, "mismatched session should be rejected" );}
// ---------------------------------------------------------------------------// PLC request/submit — mode rejection// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn plc_request_rejects_non_attach_account_mode() { common::require_db!(); let mut app = TestApp::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
// Set identity to did_web let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/request") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "plc_request should reject non-attach_account mode" );}
#[tokio::test]#[serial]async fn plc_submit_rejects_non_attach_account_mode() { common::require_db!(); let mut app = TestApp::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router();
// Set identity to did_web let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/plc/submit") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"token": "fake-token"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "plc_submit should reject non-attach_account mode" );}
// ---------------------------------------------------------------------------// Setup exempts new instances from the one-time vacuum// ---------------------------------------------------------------------------
/// A database created by this version already has incremental auto-vacuum and/// nothing stranded, so completing setup must record the one-time vacuum as/// unnecessary — otherwise a brand-new instance would be prompted to rebuild a/// database that has nothing to reclaim.#[tokio::test]#[serial]async fn setup_complete_marks_vacuum_not_needed() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app .router .clone() .oneshot( app.authed_request() .method("POST") .uri("/api/setup/complete") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let status = happyview::maintenance::vacuum::read_status(&app.state.db, app.state.db_backend) .await .expect("failed to read vacuum status"); assert!( status.completed_at.is_some(), "completing setup must mark the vacuum as not needed, so a fresh \ instance is never prompted to run one" );}