//! Plugins installed from the registry: loaded for the vaults that enabled them, commands //! `plugin..` registered, run only with the permissions the user approved. mod addons_support; mod common; use std::path::{Path, PathBuf}; use std::sync::Arc; use addons_support::{Shared, entry, index_json, manifest, serve, wasm}; use common::{TempVault, temp_config, temp_vault}; use notescraft_engine::addons::{FakeTransport, FakeVerifier, INDEX_URL}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; struct Fixture { reg: Registry, session: Session, _guards: (TempVault, TempVault), } /// A session whose transport serves the plugin `id` made of `manifest_text` and `module`. fn fixture(name: &str, id: &str, manifest_text: &str, module: &[u8], permissions: &[&str]) -> Fixture { let files: &[(&str, &[u8])] = &[("manifest.json", manifest_text.as_bytes()), ("plugin.wasm", module)]; let doc = index_json(&[entry("plugin", id, "1.0.0", files, permissions)], &[]); let net = serve(FakeTransport::new().with(INDEX_URL, &doc), "plugin", id, "1.0.0", files); let (vault, config) = (temp_vault(name), temp_config(name)); let mut session = Session::open(vault.path()).unwrap(); session.use_settings_dir(config.path()); session.set_addons_transport(Box::new(Shared(Arc::new(net)))); session.set_addons_verifier(Box::new(FakeVerifier { accept: true })); let mut reg = Registry::new(); register_core(&mut reg).unwrap(); Fixture { reg, session, _guards: (vault, config) } } impl Fixture { fn run(&mut self, id: &str, params: &Value) -> Result { self.reg.run(id, &mut self.session, params) } fn ok(&mut self, id: &str, params: &Value) -> Value { self.run(id, params).unwrap_or_else(|e| panic!("{id}: {e}")) } fn install(&mut self, id: &str) { self.ok("registry.check", &json!({})); self.ok("registry.install", &json!({ "kind": "plugin", "id": id })); } fn commands(&mut self) -> usize { self.session.reload_plugin_commands(&mut self.reg).unwrap() } } fn dice_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/dice") } #[test] fn an_enabled_installed_plugin_registers_and_runs() { let mut f = fixture("on", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]); f.install("demo"); assert_eq!(f.commands(), 0, "installed but not enabled in this vault"); assert!(f.reg.get("plugin.demo.roll").is_none()); f.ok("registry.enable", &json!({ "id": "demo" })); let n = f.commands(); assert_eq!(n, 1, "{:?}", f.session.diagnostics()); let answer = f.ok("plugin.demo.roll", &json!({})); assert_eq!(answer["command"], "plugin.demo.roll"); assert_eq!(f.session.plugin_infos().first().map(|p| p.id.as_str()), Some("demo")); } #[test] fn a_disabled_plugin_is_not_registered() { let mut f = fixture("off", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]); f.install("demo"); f.ok("registry.enable", &json!({ "id": "demo" })); assert_eq!(f.commands(), 1); f.ok("registry.disable", &json!({ "id": "demo" })); assert_eq!(f.commands(), 0); assert!(f.reg.get("plugin.demo.roll").is_none()); assert_eq!(f.session.plugin_infos().len(), 0); } #[test] fn a_plugin_removed_while_enabled_unloads() { let mut f = fixture("gone", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]); f.install("demo"); f.ok("registry.enable", &json!({ "id": "demo" })); assert_eq!(f.commands(), 1); f.ok("registry.remove", &json!({ "kind": "plugin", "id": "demo" })); assert_eq!(f.commands(), 0); assert_eq!(f.session.plugin_infos().len(), 0); assert_eq!(f.session.diagnostics().len(), 0, "{:?}", f.session.diagnostics()); } #[test] fn the_registry_owns_the_switch_of_its_plugins() { let mut f = fixture("switch", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]); f.install("demo"); f.ok("registry.enable", &json!({ "id": "demo" })); assert!(f.run("plugins.disable", &json!({ "id": "demo" })).is_err()); assert!(f.run("plugins.enable", &json!({ "id": "demo" })).is_err()); assert!(f.session.is_plugin_enabled("demo")); } #[test] fn approval_never_goes_beyond_the_manifest() { let mut f = fixture("approve", "demo", &manifest("demo", "1.0.0", &["editor"]), wasm(), &["editor"]); f.install("demo"); assert!(f.run("plugins.approve", &json!({ "id": "demo", "permissions": ["vault.write"] })).is_err()); assert!(f.run("plugins.approve", &json!({ "id": "demo", "permissions": "editor" })).is_err()); assert!(f.run("plugins.approve", &json!({ "id": "ghost" })).is_err()); assert!(f.session.plugin_settings().approved.is_empty()); let answer = f.ok("plugins.approve", &json!({ "id": "demo" })); assert_eq!(answer["approved"], json!(["editor"])); let answer = f.ok("plugins.approve", &json!({ "id": "demo", "permissions": [] })); assert_eq!(answer["approved"], json!([])); } #[test] fn dice_installs_from_a_fake_registry_and_needs_approval_to_roll() { let (Ok(module), Ok(text)) = (std::fs::read(dice_dir().join("dice.wasm")), std::fs::read_to_string(dice_dir().join("manifest.json"))) else { println!("skipped: build plugins/dice/build.sh first"); return; }; let mut f = fixture("dice", "dice", &text, &module, &["editor"]); f.install("dice"); f.ok("registry.enable", &json!({ "id": "dice" })); assert_eq!(f.commands(), 1); let refused = f.run("plugin.dice.roll", &json!({ "args": { "expr": "1d1" } })); assert!(refused.is_err(), "the editor permission was not approved: {refused:?}"); f.ok("plugins.approve", &json!({ "id": "dice" })); let answer = f.ok("plugin.dice.roll", &json!({ "args": { "expr": "2d6+3" } })); let total = answer["result"]["total"].as_i64().unwrap(); assert!((5..=15).contains(&total), "{answer}"); } /// Like `wasm()`, but `ns_run_command` traps. fn trapping_wasm() -> Vec { let text = r#"(module (memory (export "memory") 2) (global $bump (mut i32) (i32.const 1024)) (func (export "ns_alloc") (param $len i32) (result i32) (local $ptr i32) (local.set $ptr (global.get $bump)) (global.set $bump (i32.add (local.get $ptr) (local.get $len))) (local.get $ptr)) (func (export "ns_free") (param i32 i32)) (func (export "ns_abi_version") (result i32) (i32.const 1)) (func (export "ns_on_load") (result i32) (i32.const 0)) (func (export "ns_on_unload") (result i32) (i32.const 0)) (func (export "ns_on_event") (param i32 i32) (result i32) (i32.const 0)) (func (export "ns_run_command") (param i32 i32 i32 i32) (result i64) (unreachable)))"#; wat::parse_str(text).unwrap_or_default() } #[test] fn a_registry_plugin_disabled_after_failures_stays_off_after_a_restart() { let mut f = fixture("autooff", "demo", &manifest("demo", "1.0.0", &[]), &trapping_wasm(), &[]); f.install("demo"); f.ok("registry.enable", &json!({ "id": "demo" })); assert_eq!(f.commands(), 1); for _ in 0..3 { assert!(f.run("plugin.demo.roll", &json!({})).is_err()); } assert!(!f.session.is_plugin_enabled("demo")); let file = f.session.vault().root().join(".notescraft/registry.json"); let saved: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); assert_eq!(saved["disabled_after_failures"], json!(["plugin:demo"])); assert_eq!(saved["enabled"], json!(["plugin:demo"]), "the user's choice is kept"); // A restart: load again from the files. assert_eq!(f.commands(), 0, "{:?}", f.session.diagnostics()); assert!(!f.session.is_plugin_enabled("demo")); f.ok("registry.enable", &json!({ "id": "demo" })); assert_eq!(f.commands(), 1); let saved: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); assert_eq!(saved["disabled_after_failures"], json!([])); }