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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619//! What `/admin/scripts` asks an interpreter about a body before storing it,//! and what it does with the answer.//!//! The three refusals an operator meets — a missing `handle`, a body that//! will not parse, and a file-scope read of a removed global — are asserted//! against the real Lua plugin, because the wording is the thing under test//! and a fixture would be asserting the fixture. The two probes that say what//! the save path *sends* and what it reaches use `interpreter_echo`, whose//! `source` is a directive: it reports the guard list it was handed, and it//! reports what `host_script_log` answered.
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, lua_plugin};
const LUA_ID: &str = "xrpc.query:com.example.list";const ECHO_ID: &str = "xrpc.query:com.example.echo";const VALID: &str = "function handle(input, ctx)\n return input\nend\n";
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()}
async fn post_script(app: &TestApp, body: &Value) -> axum::response::Response { let cookie = app.admin_cookie(); app.router .clone() .oneshot( Request::builder() .method("POST") .uri("/admin/scripts") .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap(), ) .await .unwrap()}
async fn patch_script(app: &TestApp, id: &str, body: &Value) -> axum::response::Response { let cookie = app.admin_cookie(); app.router .clone() .oneshot( Request::builder() .method("PATCH") .uri(format!("/admin/scripts/{}", urlencoding::encode(id))) .header(cookie.0, cookie.1) .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(body).unwrap())) .unwrap(), ) .await .unwrap()}
async fn get_script(app: &TestApp, id: &str) -> axum::response::Response { let cookie = app.admin_cookie(); app.router .clone() .oneshot( Request::builder() .uri(format!("/admin/scripts/{}", urlencoding::encode(id))) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap()}
/// The message a refusal carried, with its status checked first so a test/// that got a 201 says so rather than failing on a missing field.async fn refusal(resp: axum::response::Response) -> String { let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); body["error"].as_str().expect("a message").to_string()}
/// The real Lua plugin, registered as the interpreter for `lua`. `None` when/// the runner did not name a built plugin.async fn lua_interpreter(app: &TestApp) -> Option<()> { let dir = lua_plugin::plugin_dir()?; println!("module: {}", lua_plugin::identify(&dir)); let plugin = happyview::plugin::loader::load_from_file(&dir) .await .expect("the plugin should load through the loader"); app.state.plugin_registry.register(plugin).await; Some(())}
async fn script_log_rows(app: &TestApp) -> i64 { let sql = happyview::db::adapt_sql( "SELECT COUNT(*) FROM happyview_event_logs WHERE event_type = 'script.log'", app.state.db_backend, ); let (count,): (i64,) = happyview::db::query_as(&sql) .fetch_one(&app.state.db) .await .expect("count script.log rows"); count}
// ---------------------------------------------------------------------------// The three refusals, against the interpreter that owns their wording// ---------------------------------------------------------------------------
#[tokio::test]#[serial]async fn a_body_with_no_handle_is_refused_by_the_sentence_that_names_it() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let error = refusal( post_script( &app, &json!({ "id": LUA_ID, "body": "function other() return {} end\n" }), ) .await, ) .await; assert_eq!(error, "script must define a handle() function"); assert_eq!( get_script(&app, LUA_ID).await.status(), StatusCode::NOT_FOUND );}
#[tokio::test]#[serial]async fn a_body_that_will_not_parse_is_refused_at_its_line() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let resp = post_script(&app, &json!({ "id": LUA_ID, "body": "function handle(" })).await; let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); let error = body["error"].as_str().expect("a message"); assert!(error.starts_with("script compilation failed"), "{error}"); assert!(error.contains("line 1"), "{error}"); // The field the dashboard keys its Migrate offer on, and nothing here is // a migration. assert!(body.get("removed_globals").is_none(), "{body}"); assert_eq!( get_script(&app, LUA_ID).await.status(), StatusCode::NOT_FOUND );}
/// `params` is set for a query, so the codemod's scanner reports it and the/// refusal an operator meets is the codemod's. `input` is not, so this body/// passes the scanner and the interpreter's own guard is what refuses it —/// which is the only way to reach that guard through a save.#[tokio::test]#[serial]async fn a_file_scope_read_of_a_removed_global_the_scanner_passes_is_refused_by_name() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let error = refusal( post_script( &app, &json!({ "id": LUA_ID, "body": "local carried = input\nfunction handle() return carried end\n", }), ) .await, ) .await; assert!( error.contains("the 'input' global was removed in v3"), "{error}" ); assert_eq!( get_script(&app, LUA_ID).await.status(), StatusCode::NOT_FOUND );}
#[tokio::test]#[serial]async fn a_v3_body_saves() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let resp = post_script(&app, &json!({ "id": LUA_ID, "body": VALID })).await; assert_eq!(resp.status(), StatusCode::CREATED); assert_eq!(json_body(resp).await["runnable"], true);}
/// Each prefill the editor offers has to survive the form that offers it, so/// every template is saved under a trigger of its own kind. An unmapped stem/// fails rather than being skipped: a new template nobody saved here is a/// template nobody checked.#[tokio::test]#[serial]async fn every_editor_template_saves() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("web/src/lib/lua-templates"); let mut templates: Vec<std::path::PathBuf> = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("{}: {e}", dir.display())) .map(|entry| entry.unwrap().path()) .filter(|path| path.extension().is_some_and(|ext| ext == "lua")) .collect(); templates.sort(); assert_eq!(templates.len(), 5, "{templates:?}");
for path in templates { let stem = path.file_stem().unwrap().to_str().unwrap().to_string(); let trigger = match stem.as_str() { "job" => "job.run:test.templates".to_string(), "procedure" => "xrpc.procedure:com.example.doThing".to_string(), "query" => "xrpc.query:com.example.listThings".to_string(), "record-event" => "record.create:com.example.thing".to_string(), "trigger" => "xrpc.query:com.example.generic".to_string(), other => panic!("no trigger kind mapped for the {other} template"), }; let source = std::fs::read_to_string(&path).unwrap(); let resp = post_script(&app, &json!({ "id": trigger, "body": source })).await; let status = resp.status(); assert_eq!( status, StatusCode::CREATED, "{}: {}", path.display(), json_body(resp).await ); }}
/// The scanner names every removed global at once and offers the codemod,/// which a refusal from an interpreter cannot do, so it answers first —/// before the language is even resolved to an interpreter.#[tokio::test]#[serial]async fn the_codemod_scanner_answers_ahead_of_any_interpreter() { common::require_db!(); let app = TestApp::new().await;
let resp = post_script( &app, &json!({ "id": "xrpc.procedure:com.example.do", "body": "function handle()\n return { db, json, input }\nend\n", }), ) .await; let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); let named: Vec<&str> = body["removed_globals"] .as_array() .expect("the names") .iter() .map(|name| name.as_str().unwrap()) .collect(); assert_eq!(named, ["db", "json", "input"]);}
// ---------------------------------------------------------------------------// What the save path sends, and what it reaches// ---------------------------------------------------------------------------
/// The guard list is the host's, so a name a run refuses cannot be saved as/// valid even by an interpreter that was sent nothing.#[tokio::test]#[serial]async fn the_hosts_guard_list_reaches_validate() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await;
let error = refusal( post_script( &app, &json!({ "id": ECHO_ID, "script_type": "echo", "body": "probe-globals" }), ) .await, ) .await; for name in happyview::codemod::REMOVED_GLOBALS { assert!(error.contains(name), "{name} missing from {error}"); }}
/// Checking a body is not running it, so the imports that act on a run have/// no run to act on and nothing is attributed to one.#[tokio::test]#[serial]async fn validate_reaches_no_run_and_logs_nothing() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await; let before = script_log_rows(&app).await;
let error = refusal( post_script( &app, &json!({ "id": ECHO_ID, "script_type": "echo", "body": "probe-log" }), ) .await, ) .await; assert!(error.contains("UNSUPPORTED"), "{error}"); assert_eq!(script_log_rows(&app).await, before);}
// ---------------------------------------------------------------------------// The language, and where it comes from// ---------------------------------------------------------------------------
/// Every language is refused on the same terms, the reference language/// included: whether an interpreter claims it is the only question a save/// asks.#[tokio::test]#[serial]async fn a_save_is_refused_for_every_language_no_interpreter_claims() { common::require_db!(); let app = TestApp::new().await;
for language in ["lua", "typescript"] { let error = refusal( post_script( &app, &json!({ "id": LUA_ID, "script_type": language, "body": VALID }), ) .await, ) .await; assert!(error.contains(language), "{error}"); assert!(error.contains("plugins page"), "{error}"); assert_eq!( get_script(&app, LUA_ID).await.status(), StatusCode::NOT_FOUND ); }}
/// A body-only patch is checked by the interpreter the stored row names./// Reading the language off the default instead would hand another/// interpreter's body to Lua, which refuses it for being Lua that it is not.#[tokio::test]#[serial]async fn a_body_only_patch_is_checked_against_the_rows_own_language() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await;
let created = post_script( &app, &json!({ "id": ECHO_ID, "script_type": "echo", "body": "echo one" }), ) .await; assert_eq!(created.status(), StatusCode::CREATED);
// Valid to the echo interpreter and not Lua at all, so the two answers // differ and the test can tell which one was asked. let resp = patch_script(&app, ECHO_ID, &json!({ "body": "echo two {{{" })).await; let status = resp.status(); let body = json_body(resp).await; assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["body"], "echo two {{{"); assert_eq!(body["script_type"], "echo");}
// ---------------------------------------------------------------------------// What a save keeps// ---------------------------------------------------------------------------
/// A save prepares the body it stores, so the first run after it parses/// nothing; a refused save stores nothing to load.#[tokio::test]#[serial]async fn a_save_leaves_the_bodys_artifact_for_its_runs() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await;
let body = "returns:{\"saved\":true}"; let resp = post_script( &app, &json!({"id": ECHO_ID, "script_type": "echo", "body": body}), ) .await; assert_eq!(resp.status(), StatusCode::CREATED); let cached = app .state .plugin_registry .prepared() .lookup("interpreter_echo", body) .expect("the save prepared the body") .expect("and kept its artifact"); assert_eq!(cached.bytes, body.as_bytes());
let refused = "invalid syntax"; let resp = patch_script(&app, ECHO_ID, &json!({"body": refused})).await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!( app.state .plugin_registry .prepared() .lookup("interpreter_echo", refused), None );}
/// A dashboard script has no files beside it, so a relative import could/// only ever fail at run time. The save says so, names what was imported,/// and keeps nothing for the body. A library name is not a file and saves.#[tokio::test]#[serial]async fn a_dashboard_script_that_imports_a_file_is_refused() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await;
let body = "echo\nimport ./lib/format\nimport happyview.record\nimport ../up"; let error = refusal( post_script( &app, &json!({"id": ECHO_ID, "script_type": "echo", "body": body}), ) .await, ) .await; assert_eq!( error, "dashboard scripts can't import files, only project scripts can: \ ./lib/format (line 2), ../up (line 4)" ); assert_eq!( app.state .plugin_registry .prepared() .lookup("interpreter_echo", body), None ); assert_eq!( get_script(&app, ECHO_ID).await.status(), StatusCode::NOT_FOUND );
let library_only = "echo\nimport happyview.record"; let resp = post_script( &app, &json!({"id": ECHO_ID, "script_type": "echo", "body": library_only}), ) .await; assert_eq!(resp.status(), StatusCode::CREATED);
let error = refusal(patch_script(&app, ECHO_ID, &json!({"body": "import ./util"})).await).await; assert_eq!( error, "dashboard scripts can't import files, only project scripts can: ./util (line 1)" );}
/// The same refusal with the imports found by the real Lua plugin, so the/// specifier the operator wrote is what the sentence names.#[tokio::test]#[serial]async fn a_dashboard_lua_script_that_requires_a_file_is_refused() { common::require_db!(); let app = TestApp::new().await; if lua_interpreter(&app).await.is_none() { return; }
let body = "local x = require('./x')\nfunction handle() return x end\n"; let error = refusal(post_script(&app, &json!({ "id": LUA_ID, "body": body })).await).await; assert_eq!( error, "dashboard scripts can't import files, only project scripts can: ./x (line 1)" ); assert_eq!( get_script(&app, LUA_ID).await.status(), StatusCode::NOT_FOUND );}
async fn delete_script(app: &TestApp, id: &str) -> axum::response::Response { let cookie = app.admin_cookie(); app.router .clone() .oneshot( Request::builder() .method("DELETE") .uri(format!("/admin/scripts/{}", urlencoding::encode(id))) .header(cookie.0, cookie.1) .body(Body::empty()) .unwrap(), ) .await .unwrap()}
/// A body no row runs any more leaves the cache: replaced by a patch, or/// removed with its row. A patch refused for its description is refused/// before the body is prepared, so it leaves nothing for the body either.#[tokio::test]#[serial]async fn a_replaced_or_deleted_body_leaves_the_cache() { common::require_db!(); if !echo_interpreter::is_built() { eprintln!("skipping: {}", echo_interpreter::BUILD); return; } let app = TestApp::new().await; app.state .plugin_registry .register(echo_interpreter::plugin("echo")) .await; let cached = |body: &str| { app.state .plugin_registry .prepared() .lookup("interpreter_echo", body) };
let first = "returns:{\"n\":1}"; let resp = post_script( &app, &json!({"id": ECHO_ID, "script_type": "echo", "body": first}), ) .await; assert_eq!(resp.status(), StatusCode::CREATED); assert!(cached(first).is_some());
let unsaved = "returns:{\"n\":2}"; let resp = patch_script( &app, ECHO_ID, &json!({"body": unsaved, "description": "x".repeat(301)}), ) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_eq!(cached(unsaved), None);
// A description-only patch keeps the body, so its entry stays. let resp = patch_script(&app, ECHO_ID, &json!({"description": "kept"})).await; assert_eq!(resp.status(), StatusCode::OK); assert!(cached(first).is_some());
let second = "returns:{\"n\":3}"; let resp = patch_script(&app, ECHO_ID, &json!({"body": second})).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!(cached(first), None); assert!(cached(second).is_some());
let resp = delete_script(&app, ECHO_ID).await; assert_eq!(resp.status(), StatusCode::NO_CONTENT); assert_eq!(cached(second), None);}