Something went wrong. Try again.
A lexicon-driven AppView for ATProto. forked from gamesgamesgamesgames.games/happyview
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873//! End-to-end tests for the admin import/export bundle endpoint.//!//! These exercise the full route stack — auth, multipart parsing, zip//! read/write, validation, transactional apply, and post-commit cache//! invalidation — using the same `TestApp` harness as the other e2e//! tests. Like the other suites, they're `#[ignore]`d by default and//! require `TEST_DATABASE_URL` to be set; run with//! `cargo test --test e2e_import_export -- --ignored`.
mod common;
use std::io::{Cursor, Read, Write};
use axum::body::Body;use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode};use http_body_util::BodyExt;use serde_json::{Value, json};use serial_test::serial;use tower::ServiceExt;use zip::ZipArchive;use zip::ZipWriter;use zip::write::SimpleFileOptions;
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_or(Value::Null)}
async fn raw_body(resp: axum::response::Response) -> Vec<u8> { resp.into_body().collect().await.unwrap().to_bytes().to_vec()}
fn admin_get(uri: &str, cookie: (HeaderName, HeaderValue)) -> Request<Body> { Request::builder() .uri(uri) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap()}
fn admin_put_json(uri: &str, cookie: (HeaderName, HeaderValue), body: &Value) -> Request<Body> { Request::builder() .method(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_post_json(uri: &str, cookie: (HeaderName, HeaderValue), body: &Value) -> Request<Body> { Request::builder() .method(Method::POST) .uri(uri) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap()}
/// Build a multipart/form-data body for `POST /admin/import`. We hand-roll/// the encoding rather than pull a multipart-build crate in dev-deps.fn build_import_request( cookie: (HeaderName, HeaderValue), bundle_bytes: &[u8], mode: &str, sections: Option<&str>,) -> Request<Body> { let boundary = "----happyview-test-boundary"; let mut body: Vec<u8> = Vec::new();
// bundle file body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice( b"Content-Disposition: form-data; name=\"bundle\"; filename=\"bundle.zip\"\r\n", ); body.extend_from_slice(b"Content-Type: application/zip\r\n\r\n"); body.extend_from_slice(bundle_bytes); body.extend_from_slice(b"\r\n");
// mode body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice(b"Content-Disposition: form-data; name=\"mode\"\r\n\r\n"); body.extend_from_slice(mode.as_bytes()); body.extend_from_slice(b"\r\n");
// sections (optional) if let Some(s) = sections { body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice(b"Content-Disposition: form-data; name=\"sections\"\r\n\r\n"); body.extend_from_slice(s.as_bytes()); body.extend_from_slice(b"\r\n"); }
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
Request::builder() .method(Method::POST) .uri("/admin/import") .header(cookie.0, cookie.1) .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .body(Body::from(body)) .unwrap()}
/// Read `name` out of a zip blob as bytes. Panics if absent — every caller/// uses this to assert presence.fn read_zip_entry_bytes(bundle: &[u8], name: &str) -> Vec<u8> { let mut archive = ZipArchive::new(Cursor::new(bundle.to_vec())).unwrap(); let mut entry = archive .by_name(name) .unwrap_or_else(|_| panic!("zip entry '{name}' missing")); let mut buf = Vec::new(); entry.read_to_end(&mut buf).unwrap(); buf}
fn read_zip_entry_json(bundle: &[u8], name: &str) -> Value { let bytes = read_zip_entry_bytes(bundle, name); serde_json::from_slice(&bytes).unwrap()}
fn read_zip_entry_text(bundle: &[u8], name: &str) -> String { String::from_utf8(read_zip_entry_bytes(bundle, name)).unwrap()}
fn zip_has_entry(bundle: &[u8], name: &str) -> bool { let mut archive = ZipArchive::new(Cursor::new(bundle.to_vec())).unwrap(); archive.by_name(name).is_ok()}
/// Simple builder for a small valid bundle.fn build_bundle(entries: &[(&str, &[u8])], sections: &[&str], contains_secrets: bool) -> Vec<u8> { let mut zip = ZipWriter::new(Cursor::new(Vec::new())); let opts = SimpleFileOptions::default();
// Manifest first. let manifest = json!({ "version": 1, "exported_at": "2026-04-29T00:00:00Z", "happyview_version": "test", "sections": sections, "contains_secrets": contains_secrets, }); zip.start_file("manifest.json", opts).unwrap(); zip.write_all(&serde_json::to_vec_pretty(&manifest).unwrap()) .unwrap();
for (name, bytes) in entries { zip.start_file(*name, opts).unwrap(); zip.write_all(bytes).unwrap(); } zip.finish().unwrap().into_inner()}
// ---------------------------------------------------------------------------// Export tests// ---------------------------------------------------------------------------
#[tokio::test]#[serial]#[ignore]async fn export_empty_returns_valid_zip_with_manifest() { let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get("/admin/export", app.admin_cookie())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("content-type").unwrap(), "application/zip" ); let bundle = raw_body(resp).await;
// manifest.json is always present let manifest: Value = read_zip_entry_json(&bundle, "manifest.json"); assert_eq!(manifest["version"], 1); assert!(manifest["sections"].is_array()); // Default export excludes script_variables. assert_eq!(manifest["contains_secrets"], false); let sections = manifest["sections"].as_array().unwrap(); assert!(!sections.iter().any(|s| s == "script_variables")); assert!(sections.iter().any(|s| s == "settings"));}
#[tokio::test]#[serial]#[ignore]async fn export_with_explicit_include_filters_sections() { let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get( "/admin/export?include=settings", app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let bundle = raw_body(resp).await; let manifest: Value = read_zip_entry_json(&bundle, "manifest.json"); let names: Vec<String> = manifest["sections"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap().to_string()) .collect(); assert_eq!(names, vec!["settings".to_string()]); assert!(zip_has_entry(&bundle, "settings/settings.json")); assert!(!zip_has_entry(&bundle, "service_hooks/manifest.json"));}
#[tokio::test]#[serial]#[ignore]async fn export_unknown_section_is_400() { let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get( "/admin/export?include=nonsense", app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]#[ignore]async fn export_script_variables_requires_include_secrets() { let app = TestApp::new().await;
let resp = app .router .clone() .oneshot(admin_get( "/admin/export?include=script_variables", app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]#[ignore]async fn export_includes_seeded_settings_and_service_hooks() { let app = TestApp::new().await;
// Seed a setting. let resp = app .router .clone() .oneshot(admin_put_json( "/admin/settings/app_name", app.admin_cookie(), &json!({ "value": "Test App" }), )) .await .unwrap(); assert!(resp.status().is_success());
// Seed a service hook (event-keyed). let lua = "function handle(event) return {} end\n"; let resp = app .router .clone() .oneshot(admin_post_json( "/admin/service-hooks", app.admin_cookie(), &json!({ "event": "label.emitted", "script": lua, "enabled": true, "description": "demo", }), )) .await .unwrap(); assert!( resp.status().is_success(), "service hook upsert failed: {:?}", resp.status() );
// Export. let resp = app .router .clone() .oneshot(admin_get("/admin/export", app.admin_cookie())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let bundle = raw_body(resp).await;
// settings.json contains the new setting. let settings: Value = read_zip_entry_json(&bundle, "settings/settings.json"); let settings = settings.as_array().unwrap(); assert!( settings.iter().any(|e| e["key"] == "app_name" && e["value"] == "Test App"), "settings.json missing seeded entry: {settings:?}" );
// service_hooks/manifest.json + scripts/<event>.lua are present. let hooks: Value = read_zip_entry_json(&bundle, "service_hooks/manifest.json"); let hooks = hooks.as_array().unwrap(); assert!(hooks.iter().any(|h| h["event"] == "label.emitted")); let script = read_zip_entry_text(&bundle, "service_hooks/scripts/label.emitted.lua"); assert_eq!(script, lua);}
// ---------------------------------------------------------------------------// Import tests// ---------------------------------------------------------------------------
#[tokio::test]#[serial]#[ignore]async fn import_missing_bundle_is_400() { let app = TestApp::new().await;
// Multipart with no `bundle` field. let boundary = "----happyview-test-boundary"; let body = format!("--{boundary}--\r\n"); let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/admin/import") .header(cookie.0, cookie.1) .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]#[ignore]async fn import_missing_manifest_is_400() { let app = TestApp::new().await;
// Build a zip with no manifest. let mut zip = ZipWriter::new(Cursor::new(Vec::new())); zip.start_file("settings/settings.json", SimpleFileOptions::default()) .unwrap(); zip.write_all(b"[]").unwrap(); let bundle = zip.finish().unwrap().into_inner();
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]#[ignore]async fn import_settings_merge_writes_through() { let app = TestApp::new().await;
let payload = json!([ { "key": "app_name", "value": "Imported" }, { "key": "client_uri", "value": "https://example.test" }, ]); let bundle = build_bundle( &[( "settings/settings.json", serde_json::to_vec_pretty(&payload).unwrap().as_slice(), )], &["settings"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["mode"], "merge"); assert_eq!(body["applied"]["settings"]["inserted"], 2); assert_eq!(body["applied"]["settings"]["deleted"], 0);
// GET /admin/settings should now show the imported rows. let resp = app .router .clone() .oneshot(admin_get("/admin/settings", app.admin_cookie())) .await .unwrap(); let settings = json_body(resp).await; let settings = settings.as_array().unwrap(); let app_name = settings .iter() .find(|s| s["key"] == "app_name") .expect("app_name not found"); assert_eq!(app_name["value"], "Imported"); assert_eq!(app_name["source"], "database");}
#[tokio::test]#[serial]#[ignore]async fn import_dry_run_does_not_write() { let app = TestApp::new().await;
let payload = json!([{ "key": "app_name", "value": "DryRun" }]); let bundle = build_bundle( &[( "settings/settings.json", serde_json::to_vec_pretty(&payload).unwrap().as_slice(), )], &["settings"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "dry_run", None, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["mode"], "dry_run"); assert_eq!(body["applied"]["settings"]["would_insert"], 1);
// Verify nothing was actually written. let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instance_settings WHERE key = 'app_name'") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(count.0, 0);}
#[tokio::test]#[serial]#[ignore]async fn import_replace_deletes_rows_not_in_bundle() { let app = TestApp::new().await;
// Seed two settings. let _ = app .router .clone() .oneshot(admin_put_json( "/admin/settings/keep_me", app.admin_cookie(), &json!({ "value": "yes" }), )) .await .unwrap(); let _ = app .router .clone() .oneshot(admin_put_json( "/admin/settings/drop_me", app.admin_cookie(), &json!({ "value": "removed" }), )) .await .unwrap();
// Import a bundle that only has keep_me with a new value. let payload = json!([{ "key": "keep_me", "value": "updated" }]); let bundle = build_bundle( &[( "settings/settings.json", serde_json::to_vec_pretty(&payload).unwrap().as_slice(), )], &["settings"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "replace", None, )) .await .unwrap(); let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::OK, "import failed: {body:?}"); assert_eq!(body["applied"]["settings"]["updated"], 1); assert!( body["applied"]["settings"]["deleted"] .as_u64() .unwrap_or(0) >= 1, "expected at least one deletion, got {body:?}" );
let resp = app .router .clone() .oneshot(admin_get("/admin/settings", app.admin_cookie())) .await .unwrap(); let settings = json_body(resp).await; let settings = settings.as_array().unwrap(); assert!(settings.iter().any(|s| s["key"] == "keep_me" && s["value"] == "updated")); assert!(!settings.iter().any(|s| s["key"] == "drop_me"));}
#[tokio::test]#[serial]#[ignore]async fn import_bad_lua_rejects_whole_bundle() { let app = TestApp::new().await;
// Bundle with both a valid setting and a service hook with broken Lua. let settings_json = json!([{ "key": "app_name", "value": "ShouldNotApply" }]); let hooks_manifest = json!([{ "event": "label.emitted", "enabled": true }]); let bad_lua = b"function handle(event return {} end"; // missing closing paren
let bundle = build_bundle( &[ ( "settings/settings.json", serde_json::to_vec_pretty(&settings_json).unwrap().as_slice(), ), ( "service_hooks/manifest.json", serde_json::to_vec_pretty(&hooks_manifest).unwrap().as_slice(), ), ("service_hooks/scripts/label.emitted.lua", bad_lua), ], &["settings", "service_hooks"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
// Settings must NOT have been written even though the hook came after. let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instance_settings WHERE key = 'app_name'") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!( count.0, 0, "validation failure must not have written any rows" );}
#[tokio::test]#[serial]#[ignore]async fn import_round_trip_preserves_service_hook_script() { let app = TestApp::new().await;
// Seed a service hook. let lua = "function handle(event)\n return {}\nend\n"; let resp = app .router .clone() .oneshot(admin_post_json( "/admin/service-hooks", app.admin_cookie(), &json!({ "event": "report.created", "script": lua, "enabled": true, }), )) .await .unwrap(); assert!(resp.status().is_success());
// Export, then truncate, then import. let resp = app .router .clone() .oneshot(admin_get( "/admin/export?include=service_hooks", app.admin_cookie(), )) .await .unwrap(); let bundle = raw_body(resp).await;
sqlx::query("DELETE FROM service_hooks") .execute(&app.state.db) .await .unwrap();
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::OK, "import failed: {body:?}");
// Verify the script came back identical. let row: (String,) = sqlx::query_as("SELECT script FROM service_hooks WHERE event = ?") .bind("report.created") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(row.0, lua);}
#[tokio::test]#[serial]#[ignore]async fn import_section_filter_only_touches_listed_sections() { let app = TestApp::new().await;
// Seed an existing setting we want to leave alone. let _ = app .router .clone() .oneshot(admin_put_json( "/admin/settings/keep_me", app.admin_cookie(), &json!({ "value": "yes" }), )) .await .unwrap();
// Bundle covers both settings and service_hooks, but caller asks for service_hooks only. let settings_json = json!([{ "key": "should_not_apply", "value": "x" }]); let hooks_manifest = json!([{ "event": "label.emitted", "enabled": true }]); let lua = b"function handle(event) return {} end\n";
let bundle = build_bundle( &[ ( "settings/settings.json", serde_json::to_vec_pretty(&settings_json).unwrap().as_slice(), ), ( "service_hooks/manifest.json", serde_json::to_vec_pretty(&hooks_manifest).unwrap().as_slice(), ), ("service_hooks/scripts/label.emitted.lua", lua), ], &["settings", "service_hooks"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", Some("service_hooks"), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert!(body["applied"].get("settings").is_none());
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM instance_settings WHERE key = 'should_not_apply'") .fetch_one(&app.state.db) .await .unwrap(); assert_eq!(count.0, 0);}
#[tokio::test]#[serial]#[ignore]async fn import_zip_slip_path_rejected() { let app = TestApp::new().await;
// Build a bundle with a manifest that points to a malicious entry. // The attack target is the manifest reference triggering a read of a // path containing `..`. Because we run the manifest through Section // names (not arbitrary paths), the most direct way to trip the // safe-path check is to put a `..` entry in the archive at all and // ensure we reject reads of it. let mut zip = ZipWriter::new(Cursor::new(Vec::new())); zip.start_file("manifest.json", SimpleFileOptions::default()) .unwrap(); zip.write_all( &serde_json::to_vec_pretty(&json!({ "version": 1, "exported_at": "2026-04-29T00:00:00Z", "happyview_version": "test", "sections": ["settings"], })) .unwrap(), ) .unwrap(); // settings.json is missing — settings/.. would be a shenanigans path // but our code only ever reads `settings/settings.json`. Confirm import // simply succeeds with a no-op (settings absent → empty plan). let bundle = zip.finish().unwrap().into_inner();
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); // No settings.json present → SettingsPlan is empty → no-op import. assert_eq!(resp.status(), StatusCode::OK); let body = json_body(resp).await; assert_eq!(body["applied"]["settings"]["inserted"], 0);}
#[tokio::test]#[serial]#[ignore]async fn import_invalid_event_name_rejected() { let app = TestApp::new().await;
let hooks_manifest = json!([{ "event": "totally.fake.event", "enabled": true }]); let lua = b"function handle(event) return {} end\n"; let bundle = build_bundle( &[ ( "service_hooks/manifest.json", serde_json::to_vec_pretty(&hooks_manifest).unwrap().as_slice(), ), ("service_hooks/scripts/totally.fake.event.lua", lua), ], &["service_hooks"], false, );
let resp = app .router .clone() .oneshot(build_import_request( app.admin_cookie(), &bundle, "merge", None, )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);}
#[tokio::test]#[serial]#[ignore]async fn import_unauthenticated_returns_401() { let app = TestApp::new().await;
let bundle = build_bundle(&[], &[], false); let boundary = "----happyview-test-boundary"; let mut body: Vec<u8> = Vec::new(); body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); body.extend_from_slice(b"Content-Disposition: form-data; name=\"bundle\"; filename=\"bundle.zip\"\r\n"); body.extend_from_slice(b"Content-Type: application/zip\r\n\r\n"); body.extend_from_slice(&bundle); body.extend_from_slice(b"\r\n"); body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
let resp = app .router .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/admin/import") .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}
#[tokio::test]#[serial]#[ignore]async fn export_unauthenticated_returns_401() { let app = TestApp::new().await;
let resp = app .router .clone() .oneshot( Request::builder() .uri("/admin/export") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);}