//! `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 { 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 { 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>` 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 { 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 { 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 { 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); }