A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235mod 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 tower::ServiceExt;
use common::app::TestApp;use common::fixtures;
// ---------------------------------------------------------------------------// 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_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()}
async fn response_json(resp: axum::http::Response<Body>) -> Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&body).unwrap_or(json!(null))}
fn bearer_get(uri: &str, key: &str) -> Request<Body> { Request::builder() .uri(uri) .header("authorization", format!("Bearer {key}")) .body(Body::empty()) .unwrap()}
/// A super admin's scoped API key must be limited to its stored permissions —/// it must NOT inherit the owner's super privileges (H5).#[tokio::test]#[serial]async fn super_user_api_key_is_bounded_by_its_permissions() { common::require_db!(); let app = TestApp::new().await;
// The TestApp admin is a super user. Create a key scoped to stats:read only. let create = app .router .clone() .oneshot(admin_post( "/admin/api-keys", app.admin_cookie(), &json!({ "name": "ci-monitor", "permissions": ["stats:read"] }), )) .await .unwrap(); assert_eq!(create.status(), StatusCode::CREATED); let key = response_json(create).await["key"] .as_str() .expect("api key returned") .to_string();
// It can reach the permission it was granted. let allowed = app .router .clone() .oneshot(bearer_get("/admin/stats", &key)) .await .unwrap(); assert_eq!(allowed.status(), StatusCode::OK);
// It must NOT reach a permission it wasn't granted, even though its owner // is super. Before the fix this returned 200 (full super via the key). let denied = app .router .clone() .oneshot(bearer_get("/admin/lexicons", &key)) .await .unwrap(); assert_eq!( denied.status(), StatusCode::FORBIDDEN, "a super user's scoped key must not grant permissions outside its list" );
// And it must not reach super-only operations (user management). let users = app .router .clone() .oneshot(bearer_get("/admin/users", &key)) .await .unwrap(); assert_ne!(users.status(), StatusCode::OK);}
// ---------------------------------------------------------------------------// Auth tests// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn admin_no_auth_returns_401() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/lexicons") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}
#[tokio::test]#[serial]async fn admin_wrong_token_returns_401() { common::require_db!(); let app = TestApp::new().await;
// No valid session cookie — the request will be rejected. let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/lexicons") .header("cookie", "session=invalid-cookie-value") .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}
#[tokio::test]#[serial]async fn admin_valid_token_returns_200() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get("/admin/lexicons", app.admin_cookie())) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK);}
#[tokio::test]#[serial]async fn admin_non_admin_did_returns_403() { common::require_db!(); let app = TestApp::new().await;
// Use a DID that is NOT in the admins table. let resp = app .router .clone() .oneshot(admin_get( "/admin/lexicons", common::auth::admin_cookie_header("did:plc:notadmin", &app.state.cookie_key), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);}
#[tokio::test]#[serial]async fn admin_auto_bootstrap_first_user() { common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend;
// Clear the seeded user so the table is empty. happyview::db::query("DELETE FROM happyview_users") .execute(&app.state.db) .await .unwrap();
// Use a new DID via cookie auth. let bootstrap_did = "did:plc:bootstrap";
let resp = app .router .clone() .oneshot(admin_get( "/admin/lexicons", common::auth::admin_cookie_header(bootstrap_did, &app.state.cookie_key), )) .await .unwrap();
// The first user should be auto-bootstrapped as admin. assert_eq!(resp.status(), StatusCode::OK);
// Verify the DID was inserted. let sql = adapt_sql( "SELECT COUNT(*) FROM happyview_users WHERE did = ?", backend, ); let count: (i64,) = happyview::db::query_as(&sql) .bind(bootstrap_did) .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(count.0, 1);}
// ---------------------------------------------------------------------------// Lexicon CRUD// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn lexicon_create_returns_201() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); let json = json_body(resp).await; assert_eq!(json["id"], "games.gamesgamesgamesgames.game"); assert_eq!(json["revision"], 1);}
#[tokio::test]#[serial]async fn lexicon_upsert_returns_200_with_incremented_revision() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true });
// First create let resp = app .router .clone() .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED);
// Upsert let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["revision"], 2);}
#[tokio::test]#[serial]async fn lexicon_invalid_version_returns_400() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": { "lexicon": 99, "id": "test.bad" }, });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]async fn lexicon_missing_id_returns_400() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": { "lexicon": 1 }, });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
// ---------------------------------------------------------------------------// Lexicon-triggered backfill// ---------------------------------------------------------------------------
/// Count backfill jobs for a collection.async fn backfill_job_count(app: &TestApp, collection: &str) -> i64 { let sql = adapt_sql( "SELECT COUNT(*) FROM happyview_backfill_jobs WHERE collection = ?", app.state.db_backend, ); let row: (i64,) = happyview::db::query_as(&sql) .bind(collection) .fetch_one(&app.state.db) .await .expect("count backfill jobs"); row.0}
#[tokio::test]#[serial]async fn record_lexicon_with_backfill_starts_a_backfill_job() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED);
let json = json_body(resp).await; let job_id = json["backfill_job_id"] .as_str() .expect("backfill_job_id in upload response");
let sql = adapt_sql( "SELECT collection FROM happyview_backfill_jobs WHERE id = ?", app.state.db_backend, ); let row: Option<(Option<String>,)> = happyview::db::query_as(&sql) .bind(job_id) .fetch_optional(&app.state.db) .await .expect("query backfill job"); assert_eq!( row.expect("backfill job row exists").0.as_deref(), Some("games.gamesgamesgamesgames.game"), "the job must target the uploaded lexicon's collection" );}
#[tokio::test]#[serial]async fn re_uploading_a_lexicon_does_not_start_a_second_backfill_job() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true });
for _ in 0..2 { let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert!(resp.status().is_success()); }
assert_eq!( backfill_job_count(&app, "games.gamesgamesgamesgames.game").await, 1, "only the first upload (revision 1) may start a backfill" );}
#[tokio::test]#[serial]async fn record_lexicon_without_backfill_starts_no_job() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": false });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); assert!(json_body(resp).await["backfill_job_id"].is_null());
assert_eq!( backfill_job_count(&app, "games.gamesgamesgamesgames.game").await, 0 );}
#[tokio::test]#[serial]async fn query_lexicon_with_backfill_starts_no_job() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "lexicon_json": fixtures::list_games_query_lexicon(), "backfill": true, "target_collection": "games.gamesgamesgamesgames.game" });
let resp = app .router .clone() .oneshot(admin_post("/admin/lexicons", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); assert!( json_body(resp).await["backfill_job_id"].is_null(), "there is nothing to backfill for a non-record lexicon" );}
/// An uploader holding `lexicons:create` but not `backfill:create` gets the/// lexicon, not a 403 — the backfill is skipped and reported as skipped.#[tokio::test]#[serial]async fn lexicon_upload_without_backfill_permission_skips_the_job() { common::require_db!(); let app = TestApp::new().await;
let create = app .router .clone() .oneshot(admin_post( "/admin/api-keys", app.admin_cookie(), &json!({ "name": "lexicon-only", "permissions": ["lexicons:create"] }), )) .await .unwrap(); assert_eq!(create.status(), StatusCode::CREATED); let key = response_json(create).await["key"] .as_str() .expect("api key returned") .to_string();
let resp = app .router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/lexicons") .header("authorization", format!("Bearer {key}")) .header("content-type", "application/json") .body(Body::from( json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true }) .to_string(), )) .unwrap(), ) .await .unwrap();
assert_eq!( resp.status(), StatusCode::CREATED, "a missing backfill permission must not fail the upload" ); assert!(json_body(resp).await["backfill_job_id"].is_null());
assert_eq!( backfill_job_count(&app, "games.gamesgamesgamesgames.game").await, 0 );}
#[tokio::test]#[serial]async fn lexicon_list_all() { common::require_db!(); let app = TestApp::new().await;
// Seed a lexicon app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_get("/admin/lexicons", 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(), 1); assert_eq!(arr[0]["id"], "games.gamesgamesgamesgames.game");}
#[tokio::test]#[serial]async fn lexicon_get_by_id() { common::require_db!(); let app = TestApp::new().await;
app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_get( "/admin/lexicons/games.gamesgamesgamesgames.game", app.admin_cookie(), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["id"], "games.gamesgamesgamesgames.game");}
#[tokio::test]#[serial]async fn lexicon_get_not_found() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get( "/admin/lexicons/nonexistent.lexicon", app.admin_cookie(), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
#[tokio::test]#[serial]async fn lexicon_delete() { common::require_db!(); let app = TestApp::new().await;
app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &json!({ "lexicon_json": fixtures::game_record_lexicon() }), )) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_delete( "/admin/lexicons/games.gamesgamesgamesgames.game", app.admin_cookie(), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);}
#[tokio::test]#[serial]async fn lexicon_delete_not_found() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_delete( "/admin/lexicons/nonexistent.lexicon", app.admin_cookie(), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
// ---------------------------------------------------------------------------// Stats// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn stats_empty_db() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get("/admin/stats", app.admin_cookie())) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["total_records"], 0); assert!(json["collections"].as_array().unwrap().is_empty());}
#[tokio::test]#[serial]async fn stats_with_seeded_records() { common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend;
// Seed a lexicon so the stats query can join against it let lexicon_json_val = serde_json::json!({ "lexicon": 1, "id": "test.collection", "defs": { "main": { "type": "record", "key": "tid", "record": { "type": "object", "properties": {} } } } }); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO happyview_lexicons (id, lexicon_json, created_at) VALUES (?, ?, ?)", backend, ); happyview::db::query(&sql) .bind("test.collection") .bind(serde_json::to_string(&lexicon_json_val).unwrap_or_default()) .bind(&now) .execute(&app.state.db) .await .unwrap();
// Seed records directly let record_val = serde_json::json!({"title": "test"}); let sql = adapt_sql( "INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", backend, ); happyview::db::query(&sql) .bind("at://did:plc:test/test.collection/1") .bind("did:plc:test") .bind("test.collection") .bind("1") .bind(serde_json::to_string(&record_val).unwrap_or_default()) .bind("bafytest") .bind(&now) .execute(&app.state.db) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_get("/admin/stats", app.admin_cookie())) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["total_records"], 1); assert_eq!(json["collections"][0]["collection"], "test.collection"); assert_eq!(json["collections"][0]["count"], 1);}
// ---------------------------------------------------------------------------// Backfill// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn backfill_create_job() { common::require_db!(); let app = TestApp::new().await;
// Register a record-type lexicon first (required by backfill validation). let lexicon_body = json!({ "lexicon_json": fixtures::game_record_lexicon(), "backfill": true }); app.router .clone() .clone() .oneshot(admin_post( "/admin/lexicons", app.admin_cookie(), &lexicon_body, )) .await .unwrap();
let body = json!({ "collection": "games.gamesgamesgamesgames.game" });
let resp = app .router .clone() .oneshot(admin_post("/admin/backfill", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); let json = json_body(resp).await; assert_eq!(json["status"], "running"); assert!(json.get("id").is_some());}
#[tokio::test]#[serial]async fn backfill_list_jobs() { common::require_db!(); let app = TestApp::new().await;
// Create a job first app.router .clone() .clone() .oneshot(admin_post( "/admin/backfill", app.admin_cookie(), &json!({}), )) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_get("/admin/backfill/status", app.admin_cookie())) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json.as_array().unwrap().len(), 1);}
#[tokio::test]#[serial]async fn backfill_cancel_running_job() { common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend;
// Insert a running job directly so we don't need a real relay. let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'running', 'discovering_repos', ?, ?)", backend, ); happyview::db::query(&sql) .bind(&job_id) .bind(&now) .bind(&now) .execute(&app.state.db) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/backfill/{job_id}/cancel"), app.admin_cookie(), &json!({}), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["id"], job_id); assert_eq!(json["status"], "cancelling");}
#[tokio::test]#[serial]async fn backfill_cancel_already_cancelling_is_idempotent() { common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend;
let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO happyview_backfill_jobs (id, status, stage, started_at, created_at) VALUES (?, 'cancelling', 'fetching_records', ?, ?)", backend, ); happyview::db::query(&sql) .bind(&job_id) .bind(&now) .bind(&now) .execute(&app.state.db) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/backfill/{job_id}/cancel"), app.admin_cookie(), &json!({}), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; assert_eq!(json["status"], "cancelling");}
#[tokio::test]#[serial]async fn backfill_cancel_completed_returns_400() { common::require_db!(); let app = TestApp::new().await; let backend = app.state.db_backend;
let job_id = uuid::Uuid::new_v4().to_string(); let now = now_rfc3339(); let sql = adapt_sql( "INSERT INTO happyview_backfill_jobs (id, status, stage, completed_at, created_at) VALUES (?, 'completed', 'completed', ?, ?)", backend, ); happyview::db::query(&sql) .bind(&job_id) .bind(&now) .bind(&now) .execute(&app.state.db) .await .unwrap();
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/backfill/{job_id}/cancel"), app.admin_cookie(), &json!({}), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]async fn backfill_cancel_not_found_returns_404() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_post( "/admin/backfill/nonexistent-id/cancel", app.admin_cookie(), &json!({}), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
// ---------------------------------------------------------------------------// Admin management// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn admin_create_returns_did() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "did:plc:newadmin" });
let resp = app .router .clone() .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); let json = json_body(resp).await; assert_eq!(json["did"], "did:plc:newadmin"); assert!(json.get("id").is_some());}
#[tokio::test]#[serial]async fn admin_created_did_authenticates() { common::require_db!(); let app = TestApp::new().await;
let new_did = "did:plc:newadmin2"; let body = json!({ "did": new_did });
// Create admin via the existing admin app.router .clone() .clone() .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap();
// Use cookie auth with the new DID let resp = app .router .clone() .oneshot(admin_get( "/admin/lexicons", common::auth::admin_cookie_header(new_did, &app.state.cookie_key), )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK);}
#[tokio::test]#[serial]async fn admin_list_returns_dids() { common::require_db!(); let app = TestApp::new().await;
let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot(admin_get("/admin/users", cookie)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::OK); let json = json_body(resp).await; let admins = json.as_array().unwrap(); assert!(!admins.is_empty()); for admin in admins { assert!(admin.get("did").is_some()); assert!(admin.get("api_key").is_none()); assert!(admin.get("api_key_hash").is_none()); }}
#[tokio::test]#[serial]async fn admin_delete_returns_204() { common::require_db!(); let app = TestApp::new().await;
// Create an admin to delete let resp = app .router .clone() .clone() .oneshot(admin_post( "/admin/users", app.admin_cookie(), &json!({ "did": "did:plc:disposable" }), )) .await .unwrap(); let json = json_body(resp).await; let id = json["id"].as_str().unwrap();
let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot(admin_delete(&format!("/admin/users/{id}"), cookie)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);}
#[tokio::test]#[serial]async fn admin_delete_not_found() { common::require_db!(); let app = TestApp::new().await;
let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot(admin_delete( "/admin/users/00000000-0000-0000-0000-000000000000", cookie, )) .await .unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);}
#[tokio::test]#[serial]async fn delete_collection_enqueues_a_job() { common::require_db!(); let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .method("DELETE") .uri("/admin/records/collection?collection=app.test.post") .header(app.admin_cookie().0, app.admin_cookie().1) .body(Body::empty()) .unwrap(), ) .await .unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED); let body: serde_json::Value = { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() }; let job_id = body["job_id"].as_str().expect("job_id missing");
// These four columns are exactly what the reserved-prefix authorization // boundary depends on: the literal job type, an input built only from the // validated `collection` param (no other request-controlled key), no // inherited PDS session for a local-table delete, and `created_by` // sourced from the authenticated session rather than the request. let sql = happyview::db::adapt_sql( "SELECT job_type, input, created_by, CAST(inherit_auth AS INTEGER) FROM happyview_jobs WHERE id = ?", app.state.db_backend, ); let (job_type, input, created_by, inherit_auth): (String, String, String, i64) = happyview::db::query_as(&sql) .bind(job_id) .fetch_one(&app.state.db) .await .expect("job row missing"); assert_eq!(job_type, "happyview.delete-collection"); assert_eq!(inherit_auth, 0, "must not inherit a PDS session"); assert_eq!(created_by, app.admin_did, "must come from the auth session"); let input_json: serde_json::Value = serde_json::from_str(&input).unwrap(); assert_eq!( input_json, serde_json::json!({ "collection": "app.test.post" }), "input must carry exactly the validated collection and nothing else" );}
// ---------------------------------------------------------------------------// Identifier resolution on user creation (issue #85)// ---------------------------------------------------------------------------
/// A handle that cannot be resolved must be refused outright. Storing it/// verbatim produces a row whose `did` column holds a handle, and the login/// authorization check in `auth::routes` matches the OAuth session's DID/// exactly — so such a user can never sign in, and the failure surfaces at/// login rather than at the point of the mistake.#[tokio::test]#[serial]async fn admin_create_rejects_unresolvable_handle() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "nonexistent-handle.invalid" });
let resp = app .router .clone() .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// And no row was created. let count: (i64,) = happyview::db::query_as(&adapt_sql( "SELECT COUNT(*) FROM happyview_users WHERE did = ?", app.state.db_backend, )) .bind("nonexistent-handle.invalid") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(count.0, 0, "an unresolvable handle must not be stored");}
/// Input that is neither a DID nor a syntactically valid handle is refused/// before any network work is attempted.#[tokio::test]#[serial]async fn admin_create_rejects_malformed_identifier() { common::require_db!(); let app = TestApp::new().await;
for bad in ["not a handle", "", "@"] { let resp = app .router .clone() .oneshot(admin_post( "/admin/users", app.admin_cookie(), &json!({ "did": bad }), )) .await .unwrap();
assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "expected {bad:?} to be refused" ); }}
/// Adding the same account twice reports a conflict rather than a 500 from the/// UNIQUE constraint on `did`.#[tokio::test]#[serial]async fn admin_create_duplicate_did_conflicts() { common::require_db!(); let app = TestApp::new().await; let body = json!({ "did": "did:plc:duplicate" });
let first = app .router .clone() .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(first.status(), StatusCode::CREATED);
let second = app .router .clone() .oneshot(admin_post("/admin/users", app.admin_cookie(), &body)) .await .unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT);}