Something went wrong. Try again.
A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966//! `POST /admin/scripts/{id}/codemod`, the `needs_migration` field on//! `GET /admin/scripts`, and the save paths' refusal of an unmigrated body,//! through `TestApp` and guarded by//! `common::require_db!()` so a normal `cargo test` run exercises them//! whenever `TEST_DATABASE_URL` is set.
mod 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::echo_interpreter;
// ---------------------------------------------------------------------------// 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()}
/// A POST with no body and no `Content-Type` at all, distinct from/// `admin_post(..., &json!({}))`'s two-byte `{}` with an explicit/// `application/json` header. Axum's `Option<Json<T>>` resolves to `None`/// only when `Content-Type` is absent entirely, so this is the case that/// actually exercises the preview default.fn admin_post_no_body( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue),) -> Request<Body> { Request::builder() .method("POST") .uri(uri) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap()}
fn bearer_post(uri: &str, key: &str, body: &Value) -> Request<Body> { Request::builder() .method("POST") .uri(uri) .header("authorization", format!("Bearer {key}")) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap()}
fn admin_patch( uri: &str, cookie: (axum::http::HeaderName, axum::http::HeaderValue), body: &Value,) -> Request<Body> { Request::builder() .method("PATCH") .uri(uri) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap()}
fn script_uri(id: &str) -> String { format!("/admin/scripts/{}", urlencoding::encode(id))}
async fn get_script(app: &TestApp, id: &str) -> axum::response::Response { app.router .clone() .oneshot(admin_get(&script_uri(id), app.admin_cookie())) .await .unwrap()}
/// Seed a Lua row directly: the only way to get an unmigrated body into the/// table, since `POST /admin/scripts` refuses one.async fn seed_stored_script(app: &TestApp, id: &str, body: &str) -> Value { seed_script_with_type(app, id, "lua", body).await; json_body(get_script(app, id).await).await}
/// Seed a script row with an arbitrary `script_type`, bypassing/// `POST /admin/scripts` — the only way to get a non-Lua row into the table,/// since that endpoint refuses a language no installed interpreter claims and/// this harness installs none.async fn seed_script_with_type(app: &TestApp, id: &str, script_type: &str, body: &str) { let now = happyview::db::now_rfc3339(); let sql = happyview::db::adapt_sql( "INSERT INTO happyview_scripts (id, script_type, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", app.state.db_backend, ); happyview::db::query(&sql) .bind(id) .bind(script_type) .bind(body) .bind(&now) .bind(&now) .execute(&app.state.db) .await .expect("seed_script_with_type: insert failed");}
/// Mints a scoped API key via `POST /admin/api-keys`, the same mechanism/// `super_user_api_key_is_bounded_by_its_permissions` (tests/e2e_admin.rs)/// uses — it is the only way to exercise a specific permission set without/// a second logged-in user.async fn scoped_api_key(app: &TestApp, name: &str, permissions: &[&str]) -> String { let resp = app .router .clone() .oneshot(admin_post( "/admin/api-keys", app.admin_cookie(), &json!({ "name": name, "permissions": permissions }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); json_body(resp).await["key"] .as_str() .expect("api key returned") .to_string()}
// ---------------------------------------------------------------------------// Preview / apply// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn codemod_preview_does_not_store() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let original = "function handle()\n return params.q\nend\n"; seed_stored_script(&app, id, original).await;
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), &json!({}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], true); assert!( result["source"].as_str().unwrap().contains("input.q"), "expected the preview to rewrite params.q to input.q: {result:?}" );
// The stored row must be untouched by a preview. let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; assert_eq!(row["body"], original);}
/// A genuinely empty POST (no body, no `Content-Type`) must resolve to a/// preview, not a 415/422 — the endpoint is usable from a plain `curl -X/// POST` with nothing else.#[tokio::test]#[serial]async fn codemod_preview_with_empty_body_and_no_content_type() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let original = "function handle()\n return params.q\nend\n"; seed_stored_script(&app, id, original).await;
let resp = app .router .clone() .oneshot(admin_post_no_body( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], true);
let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; assert_eq!(row["body"], original, "an empty body must not apply");}
#[tokio::test]#[serial]async fn codemod_apply_stores_and_next_get_shows_no_needs_migration() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, "function handle()\n return params.q\nend\n").await;
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), &json!({ "apply": true }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], true); let applied_source = result["source"].as_str().unwrap().to_string();
let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; assert_eq!(row["body"], applied_source);
let list = app .router .clone() .oneshot(admin_get("/admin/scripts", app.admin_cookie())) .await .unwrap(); assert_eq!(list.status(), StatusCode::OK); let list = json_body(list).await; let entry = list .as_array() .unwrap() .iter() .find(|s| s["id"] == id) .expect("script present in list"); assert_eq!( entry["needs_migration"], json!([]), "a migrated script must report no remaining migration work: {entry:?}" );}
#[tokio::test]#[serial]async fn codemod_rejects_non_lua_script() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_script_with_type(&app, id, "javascript", "function handle() {}").await;
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), &json!({}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; let msg = err["error"].as_str().unwrap_or(""); assert!( msg.contains("codemod applies to Lua scripts"), "expected the Lua-only error, got: {msg}" );}
// ---------------------------------------------------------------------------// Marker guard: apply refuses a rewrite that still has markers unless// `allow_markers` is set.// ---------------------------------------------------------------------------
/// A TID conversion, which no rename can carry over because the two sides/// differ in precision — so this rewrites and still leaves a marker.const MARKED_SOURCE: &str = "function handle()\n return TID.toNumber(params.tid)\nend\n";
#[tokio::test]#[serial]async fn codemod_apply_refuses_when_markers_remain() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.procedure:com.example.create"; seed_stored_script(&app, id, MARKED_SOURCE).await;
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), &json!({ "apply": true }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); let err = json_body(resp).await; let msg = err["error"].as_str().unwrap_or(""); assert!( msg.contains('1'), "expected the marker count in the message, got: {msg}" );
let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; assert_eq!(row["body"], MARKED_SOURCE, "a refused apply must not store");}
#[tokio::test]#[serial]async fn codemod_apply_with_allow_markers_stores_despite_markers() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.procedure:com.example.create"; seed_stored_script(&app, id, MARKED_SOURCE).await;
let resp = app .router .clone() .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), app.admin_cookie(), &json!({ "apply": true, "allow_markers": true }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], true); assert!(!result["notes"].as_array().unwrap().is_empty());
let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; let stored = row["body"].as_str().unwrap(); assert!( stored.contains("-- codemod:"), "expected the marker comment to be stored: {row:?}" );
// The same body through the save path is refused, so apply is the one // write that stores a marked construct. let resp = app .router .clone() .oneshot(admin_patch( &script_uri(id), app.admin_cookie(), &json!({ "body": stored }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert_eq!( err["error"], "script references removed globals: TID -- run the codemod first" );}
/// Applying a rewrite that has already landed writes nothing, so the marker/// guard has nothing to protect and the call succeeds. Refusing it would/// report a settled script as a new conflict on every retry.#[tokio::test]#[serial]async fn codemod_apply_a_second_time_is_a_no_op_rather_than_a_conflict() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.procedure:com.example.create"; seed_stored_script(&app, id, MARKED_SOURCE).await;
let apply = |body: Value| { let router = app.router.clone(); let cookie = app.admin_cookie(); async move { router .oneshot(admin_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), cookie, &body, )) .await .unwrap() } };
let first = apply(json!({ "apply": true, "allow_markers": true })).await; assert_eq!(first.status(), StatusCode::OK); let first = json_body(first).await; assert_eq!(first["changed"], true);
let second = apply(json!({ "apply": true })).await; assert_eq!(second.status(), StatusCode::OK); let second = json_body(second).await; assert_eq!(second["changed"], false); assert!( !second["notes"].as_array().unwrap().is_empty(), "the marker is still reported: {second:?}" ); assert_eq!(second["source"], first["source"]);}
// ---------------------------------------------------------------------------// Draft preview: `source` rewrites text the table does not hold.// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn codemod_previews_a_supplied_source_without_a_stored_row() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list";
let resp = app .router .clone() .oneshot(admin_post( &format!("{}/codemod", script_uri(id)), app.admin_cookie(), &json!({ "source": "function handle()\n return params.q\nend\n" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], true); assert!( result["source"].as_str().unwrap().contains("input.q"), "the id's kind must drive the rewrite: {result:?}" );
assert_eq!(get_script(&app, id).await.status(), StatusCode::NOT_FOUND);}
/// The stored body and the supplied one differ, and the answer must describe/// the supplied one.#[tokio::test]#[serial]async fn codemod_preview_of_a_supplied_source_ignores_the_stored_body() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let stored = "function handle()\n return params.q\nend\n"; seed_stored_script(&app, id, stored).await;
let draft = "function handle(input, ctx)\n return input.q\nend\n"; let resp = app .router .clone() .oneshot(admin_post( &format!("{}/codemod", script_uri(id)), app.admin_cookie(), &json!({ "source": draft }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let result = json_body(resp).await; assert_eq!(result["changed"], false); assert_eq!(result["source"], draft);
let row = json_body(get_script(&app, id).await).await; assert_eq!(row["body"], stored);}
#[tokio::test]#[serial]async fn codemod_refuses_apply_with_a_supplied_source() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let stored = "function handle()\n return params.q\nend\n"; seed_stored_script(&app, id, stored).await;
let resp = app .router .clone() .oneshot(admin_post( &format!("{}/codemod", script_uri(id)), app.admin_cookie(), &json!({ "apply": true, "source": "function handle()\n return params.r\nend\n" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let row = json_body(get_script(&app, id).await).await; assert_eq!(row["body"], stored, "a refused apply must not store");}
// ---------------------------------------------------------------------------// Refusal at save: create and patch turn away a body that still reaches a// removed global.// ---------------------------------------------------------------------------
const CLEAN_SOURCE: &str = "function handle(input, ctx)\n return input.q\nend\n";
/// A `lua` interpreter, for the cases that get past the scanner and so need/// something to check the body. The fixture rather than the real plugin: the/// scanner's refusal is the subject, and the one case that needs a body/// judged says so through the fixture's directive. `false` when the fixture/// is not built.async fn with_an_interpreter(app: &TestApp) -> bool { if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return false; } app.state .plugin_registry .register(echo_interpreter::plugin("lua")) .await; true}
#[tokio::test]#[serial]async fn create_refuses_an_unmigrated_body_naming_its_globals_in_order() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list";
let resp = app .router .clone() .oneshot(admin_post( "/admin/scripts", app.admin_cookie(), &json!({ "id": id, "body": "function handle()\n return db.get(params.uri)\nend\n", }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert_eq!( err["error"], "script references removed globals: db, params -- run the codemod first" ); assert_eq!(err["removed_globals"], json!(["db", "params"]));
assert_eq!(get_script(&app, id).await.status(), StatusCode::NOT_FOUND);}
/// A read at file scope runs when the compile check loads the chunk, where the/// removed-name guard raises. The refusal has to answer first, because it is/// the error a client can act on.#[tokio::test]#[serial]async fn a_file_scope_read_is_refused_as_unmigrated_rather_than_as_a_compile_failure() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let unmigrated = "local rows = db.query{ collection = \"com.example.thing\" }\n\ function handle(input, ctx)\n return rows\nend\n"; let expected = "script references removed globals: db -- run the codemod first";
let resp = app .router .clone() .oneshot(admin_post( "/admin/scripts", app.admin_cookie(), &json!({ "id": id, "body": unmigrated }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert_eq!(err["error"], expected); assert_eq!(err["removed_globals"], json!(["db"]));
seed_stored_script(&app, id, CLEAN_SOURCE).await; let resp = app .router .clone() .oneshot(admin_patch( &script_uri(id), app.admin_cookie(), &json!({ "body": unmigrated }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert_eq!(err["error"], expected); assert_eq!(err["removed_globals"], json!(["db"]));}
/// A body the scanner reads as unparseable names no global, so what reaches/// the client is the interpreter's own refusal — and that refusal carries no/// `removed_globals`, which is the field the dashboard keys its Migrate offer/// on.#[tokio::test]#[serial]async fn an_interpreters_refusal_carries_no_globals_field() { common::require_db!(); let app = TestApp::new().await; if !with_an_interpreter(&app).await { return; }
let resp = app .router .clone() .oneshot(admin_post( "/admin/scripts", app.admin_cookie(), &json!({ "id": "xrpc.query:com.example.list", "body": "invalid, says the body" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert!( err["error"] .as_str() .unwrap() .starts_with("script compilation failed"), "{err:?}" ); assert!(err.get("removed_globals").is_none(), "{err:?}");}
#[tokio::test]#[serial]async fn create_stores_a_migrated_body() { common::require_db!(); let app = TestApp::new().await; if !with_an_interpreter(&app).await { return; } let id = "xrpc.query:com.example.list";
let resp = app .router .clone() .oneshot(admin_post( "/admin/scripts", app.admin_cookie(), &json!({ "id": id, "body": CLEAN_SOURCE }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let row = json_body(resp).await; assert_eq!(row["body"], CLEAN_SOURCE); assert_eq!(row["needs_migration"], json!([]));}
#[tokio::test]#[serial]async fn create_refuses_to_replace_a_stored_script_with_an_unmigrated_body() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, CLEAN_SOURCE).await;
let resp = app .router .clone() .oneshot(admin_post( "/admin/scripts", app.admin_cookie(), &json!({ "id": id, "body": "function handle()\n return params.q\nend\n" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let row = json_body(get_script(&app, id).await).await; assert_eq!(row["body"], CLEAN_SOURCE);}
#[tokio::test]#[serial]async fn patch_refuses_an_unmigrated_body_and_stores_a_migrated_one() { common::require_db!(); let app = TestApp::new().await; if !with_an_interpreter(&app).await { return; } let id = "record.index:com.example.thing"; seed_stored_script(&app, id, CLEAN_SOURCE).await;
let resp = app .router .clone() .oneshot(admin_patch( &script_uri(id), app.admin_cookie(), &json!({ "body": "function handle()\n log(uri)\n return record\nend\n" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); let err = json_body(resp).await; assert_eq!( err["error"], "script references removed globals: log, record, uri -- run the codemod first" ); let row = json_body(get_script(&app, id).await).await; assert_eq!(row["body"], CLEAN_SOURCE, "a refused patch must not store");
let migrated = "function handle(input, ctx)\n return input.record\nend\n"; let resp = app .router .clone() .oneshot(admin_patch( &script_uri(id), app.admin_cookie(), &json!({ "body": migrated }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(json_body(resp).await["body"], migrated);}
/// The check is on the body a request submits. A stored unmigrated script/// keeps an editable description while it waits for the codemod.#[tokio::test]#[serial]async fn patch_without_a_body_is_not_refused_for_the_stored_one() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; let stored = "function handle()\n return params.q\nend\n"; seed_stored_script(&app, id, stored).await;
let resp = app .router .clone() .oneshot(admin_patch( &script_uri(id), app.admin_cookie(), &json!({ "description": "lists things" }), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let row = json_body(resp).await; assert_eq!(row["description"], "lists things"); assert_eq!(row["body"], stored);}
// ---------------------------------------------------------------------------// needs_migration on GET /admin/scripts// ---------------------------------------------------------------------------
/// A script whose `needs_migration` list is empty must show that from/// `GET /admin/scripts` even without ever calling the codemod endpoint on it/// — a freshly-authored v3 script never needs the trip.#[tokio::test]#[serial]async fn list_reports_needs_migration_for_an_unmigrated_script() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, "function handle()\n return params.q\nend\n").await;
let list = app .router .clone() .oneshot(admin_get("/admin/scripts", app.admin_cookie())) .await .unwrap(); let list = json_body(list).await; let entry = list .as_array() .unwrap() .iter() .find(|s| s["id"] == id) .expect("script present in list"); let needs_migration = entry["needs_migration"].as_array().unwrap(); assert!( needs_migration.iter().any(|v| v.as_str() == Some("params")), "expected 'params' in needs_migration, got: {entry:?}" );}
#[tokio::test]#[serial]async fn list_reports_empty_needs_migration_for_a_non_lua_script() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_script_with_type( &app, id, "javascript", "function handle() { return params.q; }", ) .await;
let list = app .router .clone() .oneshot(admin_get("/admin/scripts", app.admin_cookie())) .await .unwrap(); let list = json_body(list).await; let entry = list .as_array() .unwrap() .iter() .find(|s| s["id"] == id) .expect("script present in list"); assert_eq!(entry["needs_migration"], json!([]));}
// ---------------------------------------------------------------------------// Permission gating// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn codemod_preview_needs_only_scripts_read() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, "function handle()\n return params.q\nend\n").await; let key = scoped_api_key(&app, "scripts-reader", &["scripts:read"]).await;
let resp = app .router .clone() .oneshot(bearer_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), &key, &json!({}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK);}
#[tokio::test]#[serial]async fn codemod_apply_requires_scripts_manage_even_with_scripts_read() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, "function handle()\n return params.q\nend\n").await; let key = scoped_api_key(&app, "scripts-reader", &["scripts:read"]).await;
let resp = app .router .clone() .oneshot(bearer_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), &key, &json!({ "apply": true }), )) .await .unwrap(); assert_eq!( resp.status(), StatusCode::FORBIDDEN, "apply must require scripts:manage, not just scripts:read" );
let row = app .router .clone() .oneshot(admin_get( &format!("/admin/scripts/{}", urlencoding::encode(id)), app.admin_cookie(), )) .await .unwrap(); let row = json_body(row).await; assert_eq!(row["body"], "function handle()\n return params.q\nend\n");}
#[tokio::test]#[serial]async fn codemod_without_scripts_read_returns_403() { common::require_db!(); let app = TestApp::new().await; let id = "xrpc.query:com.example.list"; seed_stored_script(&app, id, "function handle()\n return params.q\nend\n").await; let key = scoped_api_key(&app, "unrelated", &["stats:read"]).await;
let resp = app .router .clone() .oneshot(bearer_post( &format!("/admin/scripts/{}/codemod", urlencoding::encode(id)), &key, &json!({}), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN);}