Something went wrong. Try again.
A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924mod 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()}
// ---------------------------------------------------------------------------// Service entry CRUD via admin endpoints// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn create_list_update_delete_service_entry() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
// CREATE let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries") .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "fragment_id": "#chess", "service_type": "ChessAppView" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); let created = json_body(resp).await; let entry_id = created["id"].as_i64().unwrap(); assert_eq!(created["fragment_id"], "#chess"); assert_eq!(created["service_type"], "ChessAppView"); assert_eq!(created["access_mode"], "all");
// LIST let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/service-entries") .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let list = json_body(resp).await; let entries = list.as_array().unwrap(); assert!(entries.iter().any(|e| e["id"].as_i64() == Some(entry_id)));
// UPDATE let resp = app .router .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/admin/service-entries/{}", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "access_mode": "specific" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// DELETE let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/admin/service-entries/{}", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Verify deletion let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/service-entries") .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
let list = json_body(resp).await; let entries = list.as_array().unwrap(); assert!(!entries.iter().any(|e| e["id"].as_i64() == Some(entry_id)));}
#[tokio::test]#[serial]async fn delete_nonexistent_entry_returns_404() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri("/admin/service-entries/99999") .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
#[tokio::test]#[serial]async fn update_entry_with_invalid_access_mode_returns_400() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let entry_id = app .create_service_entry("#chess", "ChessAppView", "all") .await;
let resp = app .router .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/admin/service-entries/{}", entry_id)) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "access_mode": "invalid_mode" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "invalid access_mode should return 400" );}
// ---------------------------------------------------------------------------// XRPC junction table via admin endpoints// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn add_list_remove_entry_xrpcs() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let entry_id = app .create_service_entry("#chess", "ChessAppView", "specific") .await;
// ADD xrpcs let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "lexicon_ids": ["games.example.listGames", "games.example.getGame"] })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// LIST xrpcs let resp = app .router .clone() .oneshot( Request::builder() .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let xrpcs = json_body(resp).await; let list = xrpcs.as_array().unwrap(); assert_eq!(list.len(), 2); assert!(list.contains(&json!("games.example.getGame"))); assert!(list.contains(&json!("games.example.listGames")));
// REMOVE one xrpc let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "lexicon_ids": ["games.example.getGame"] })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Verify removal let resp = app .router .clone() .oneshot( Request::builder() .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
let xrpcs = json_body(resp).await; let list = xrpcs.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0], "games.example.listGames");}
// ---------------------------------------------------------------------------// Reverse lookup: services_for_lexicon// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn lexicon_services_reverse_lookup() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let id_all = app .create_service_entry("#chess", "ChessAppView", "all") .await; let id_specific = app .create_service_entry("#checkers", "CheckersAppView", "specific") .await; app.add_entry_xrpcs(id_specific, &["games.example.listGames"]) .await;
// Both should appear for games.example.listGames (one via access_mode=all, one via junction) let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/lexicons/games.example.listGames/services") .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let services = json_body(resp).await; let list = services.as_array().unwrap(); assert_eq!(list.len(), 2); let ids: Vec<i64> = list.iter().filter_map(|e| e["id"].as_i64()).collect(); assert!(ids.contains(&id_all)); assert!(ids.contains(&id_specific));
// Only #chess (access_mode=all) should appear for a random XRPC let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/lexicons/games.example.unrelated/services") .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let services = json_body(resp).await; let list = services.as_array().unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0]["id"].as_i64().unwrap(), id_all);}
// ---------------------------------------------------------------------------// Update entry with empty body — short-circuit path// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn update_entry_with_empty_body() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let entry_id = app .create_service_entry("#empty", "EmptyUpdate", "all") .await;
// Send an update with no fields — should succeed (no-op update) let resp = app .router .clone() .oneshot( Request::builder() .method("PUT") .uri(format!("/admin/service-entries/{}", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&json!({})).unwrap())) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::NO_CONTENT, "empty update body should succeed" );
// Verify entry is unchanged let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/service-entries") .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
let list = json_body(resp).await; let entries = list.as_array().unwrap(); let entry = entries .iter() .find(|e| e["id"].as_i64() == Some(entry_id)) .unwrap(); assert_eq!(entry["service_type"], "EmptyUpdate"); assert_eq!(entry["access_mode"], "all");}
// ---------------------------------------------------------------------------// Non-admin permission checks// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn unauthenticated_list_entries_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/service-entries") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated request to admin endpoint should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_create_entry_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "fragment_id": "#noauth", "service_type": "NoAuth" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated POST to admin endpoint should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_delete_entry_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri("/admin/service-entries/1") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated DELETE to admin endpoint should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_sync_plc_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries/sync-plc") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated POST to sync-plc should be rejected, got {}", resp.status() );}
// ---------------------------------------------------------------------------// Update nonexistent entry returns 404// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn update_nonexistent_entry_returns_404() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let resp = app .router .clone() .oneshot( Request::builder() .method("PUT") .uri("/admin/service-entries/99999") .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ "access_mode": "specific" })) .unwrap(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::NOT_FOUND, "updating a nonexistent entry should return 404" );}
// ---------------------------------------------------------------------------// XRPC idempotency — adding the same NSID twice// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn add_entry_xrpcs_idempotent() { common::require_db!(); let app = TestApp::new().await; let cookie = app.admin_cookie();
let entry_id = app .create_service_entry("#chess", "ChessAppView", "specific") .await;
let add_body = serde_json::to_vec(&json!({ "lexicon_ids": ["games.example.listGames"] })) .unwrap();
// Add once let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from(add_body.clone())) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// Add the same NSID again — should succeed (ON CONFLICT DO NOTHING) let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from(add_body)) .unwrap(), ) .await .unwrap(); assert_eq!( resp.status(), StatusCode::NO_CONTENT, "adding the same xrpc twice should succeed idempotently" );
// Verify only one entry exists let resp = app .router .clone() .oneshot( Request::builder() .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
let xrpcs = json_body(resp).await; assert_eq!( xrpcs.as_array().unwrap().len(), 1, "should have exactly one entry after duplicate add" );}
// ---------------------------------------------------------------------------// Unauthenticated access to remaining endpoints// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn unauthenticated_update_entry_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("PUT") .uri("/admin/service-entries/1") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"access_mode": "all"})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated PUT should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_list_xrpcs_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/service-entries/1/xrpcs") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated GET xrpcs should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_add_xrpcs_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries/1/xrpcs") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated POST xrpcs should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_remove_xrpcs_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri("/admin/service-entries/1/xrpcs") .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), )) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated DELETE xrpcs should be rejected, got {}", resp.status() );}
#[tokio::test]#[serial]async fn unauthenticated_lexicon_services_rejected() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/lexicons/test.foo.bar/services") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert!( resp.status().is_client_error(), "unauthenticated lexicon services lookup should be rejected, got {}", resp.status() );}
// ---------------------------------------------------------------------------// PLC sync — did_plc mode// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn sync_plc_updates_did_document() { common::require_db!(); let mut app = TestApp::new().await; let plc_store = plc::setup_mock_plc(&app.mock_server).await; let did = app.setup_did_plc().await;
// Generate and store a rotation key (setup_did_plc only stores a signing key) let encryption_key = [0x42u8; 32];
let mut rotation_key_bytes = [0u8; 32]; rand::Rng::fill_bytes(&mut rand::rng(), &mut rotation_key_bytes); let _rotation_key = p256::ecdsa::SigningKey::from_slice(&rotation_key_bytes[..]).unwrap(); let encrypted = happyview::plugin::encryption::encrypt(&encryption_key, &rotation_key_bytes) .expect("encryption failed"); let rotation_key_enc = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encrypted);
let sql = happyview::db::adapt_sql( "UPDATE happyview_service_identity SET rotation_key_enc = ? WHERE id = 1", app.state.db_backend, ); happyview::db::query(&sql) .bind(&rotation_key_enc) .execute(&app.state.db) .await .expect("failed to store rotation key");
// Build a genesis-like PLC document for the mock let rotation_did_key = happyview::plc::private_key_to_did_key(&rotation_key_bytes).unwrap();
// Compute the signing key's did:key from the stored identity let identity = happyview::service_identity::get_identity(&app.state.db, app.state.db_backend) .await .unwrap() .unwrap(); let signing_key_bytes = happyview::plc::decrypt_key(identity.signing_key_enc.as_ref().unwrap(), &encryption_key) .unwrap(); let signing_did_key = happyview::plc::private_key_to_did_key(&signing_key_bytes).unwrap();
let genesis_doc = json!({ "type": "plc_operation", "rotationKeys": [&rotation_did_key], "verificationMethods": { "atproto": &signing_did_key, }, "alsoKnownAs": [], "services": {}, "prev": null, "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", });
plc_store.write().await.insert(did.clone(), genesis_doc);
// Create a service entry app.create_service_entry("#chess", "ChessAppView", "all") .await;
// POST sync-plc let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries/sync-plc") .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::NO_CONTENT, "sync-plc should return 204" );
// Verify the PLC mock received the updated document let store = plc_store.read().await; let updated = store.get(&did).expect("PLC store should have the DID"); let services = updated["services"] .as_object() .expect("services should exist"); assert!( services.contains_key("chess"), "services should contain the chess entry" ); assert_eq!(updated["services"]["chess"]["type"], "ChessAppView");}
#[tokio::test]#[serial]async fn sync_plc_rejects_non_plc_mode() { common::require_db!(); let mut app = TestApp::new().await; app.setup_did_web().await;
let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/service-entries/sync-plc") .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "sync-plc should reject non-plc mode" );}