Files
Marcos Paulo cac6b0ed39
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry consumers (grammar, theme, pack), seed reader, wasm plugin host with approval, wanted scan, STIX and Lottie store lookups, plugin auto-disable persistence
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 18:35:47 -03:00

198 lines
10 KiB
Rust

//! The `registry.*` commands through the registry: list, check, install, update, remove,
//! rollback, enable, the read-only refusal and the assistant gate classes.
mod addons_support;
mod common;
use std::sync::Arc;
use addons_support::{Shared, TINY, entry, index_json, manifest, serve, wasm};
use common::{TempVault, temp_config, temp_vault};
use notescraft_engine::addons::{FakeTransport, FakeVerifier, INDEX_URL, Response};
use notescraft_engine::{AssistantGate, AssistantSettings, Decision, Error, GateSource, Registry, Session, register_core};
use serde_json::{Value, json};
const GRAMMAR: &str = "twig.sublime-syntax";
struct Fixture {
reg: Registry<Session>,
session: Session,
net: Arc<FakeTransport>,
vault_path: std::path::PathBuf,
_guards: (TempVault, TempVault),
}
fn script(version: &str, bytes: &[u8]) -> FakeTransport {
let doc = index_json(&[entry("grammar", "twig", version, &[(GRAMMAR, bytes)], &[])], &[]);
let net = FakeTransport::new().with_response(INDEX_URL, Ok(Response { status: 200, etag: Some("\"e\"".into()), bytes: doc }));
serve(net, "grammar", "twig", version, &[(GRAMMAR, bytes)])
}
fn fixture(name: &str, net: FakeTransport) -> Fixture {
let (vault, config) = (temp_vault(name), temp_config(name));
let mut session = Session::open(vault.path()).unwrap();
session.use_settings_dir(config.path());
let net = Arc::new(net);
session.set_addons_transport(Box::new(Shared(net.clone())));
session.set_addons_verifier(Box::new(FakeVerifier { accept: true }));
let mut reg = Registry::new();
register_core(&mut reg).unwrap();
Fixture { reg, session, net, vault_path: vault.path().to_path_buf(), _guards: (vault, config) }
}
impl Fixture {
fn run(&mut self, id: &str, params: impl Into<Value>) -> Result<Value, Error> {
self.reg.run(id, &mut self.session, &params.into())
}
fn ok(&mut self, id: &str, params: impl Into<Value>) -> Value {
self.run(id, params).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn code(&mut self, id: &str, params: impl Into<Value>) -> String {
match self.run(id, params) {
Err(Error::Command { source, .. }) => source.code,
other => panic!("{id}: expected a command error, got {other:?}"),
}
}
}
#[test]
fn list_is_empty_until_a_check_then_joins_the_state() {
let mut f = fixture("list", script("1.0.0", TINY.as_bytes()));
assert_eq!(f.ok("registry.list", json!({}))["cached"], false);
assert!(f.net.asked().is_empty(), "list never touches the network");
let checked = f.ok("registry.check", json!({}));
assert_eq!((checked["entries"].clone(), checked["updates"].clone()), (json!(1), json!(0)));
let rows = f.ok("registry.list", json!({}));
assert_eq!(rows["entries"][0]["state"], "available");
f.ok("registry.install", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(f.ok("registry.list", json!({ "state": "installed" }))["entries"][0]["installed_version"], "1.0.0");
assert_eq!(f.ok("registry.list", json!({ "state": "available" }))["entries"], json!([]));
assert_eq!(f.ok("registry.list", json!({ "query": "TW" }))["entries"].as_array().map(Vec::len), Some(1));
assert_eq!(f.ok("registry.info", json!({ "kind": "grammar", "id": "twig" }))["files"][0]["path"], GRAMMAR);
assert_eq!(f.ok("registry.suggest", json!({ "label": "Twig" }))["entries"][0]["id"], "twig");
assert_eq!(f.ok("registry.suggest", json!({ "label": "nope" }))["entries"], json!([]));
}
#[test]
fn install_update_rollback_remove_run_through_the_registry() {
let mut f = fixture("flow", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
let done = f.ok("registry.install", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(done["version"], "1.0.0");
let newer = format!("{TINY}# 1.1\n");
// A new index with 1.1.0 appears after another check.
let net = script("1.1.0", newer.as_bytes());
f.session.set_addons_transport(Box::new(Shared(Arc::new(net))));
assert_eq!(f.ok("registry.check", json!({}))["updates"], 1);
assert_eq!(f.ok("registry.list", json!({ "state": "updates" }))["entries"][0]["state"], "update");
let all = f.ok("registry.update", json!({}));
assert_eq!(all["updated"][0]["previous"], "1.0.0");
assert_eq!(f.ok("registry.rollback", json!({ "kind": "grammar", "id": "twig" }))["version"], "1.0.0");
f.ok("registry.remove", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(f.code("registry.remove", json!({ "kind": "grammar", "id": "twig" })), "not_found");
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "nope" })), "not_found");
assert_eq!(f.code("registry.install", json!({ "kind": "exe", "id": "twig" })), "invalid_params");
}
#[test]
fn install_without_a_trusted_key_is_a_clear_addon_error() {
let mut f = fixture("nokey", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
let config = temp_config("nokey-default");
let vault = temp_vault("nokey-default");
let mut plain = Session::open(vault.path()).unwrap();
plain.use_settings_dir(config.path());
plain.set_addons_transport(Box::new(Shared(f.net.clone())));
f.session = plain;
f.ok("registry.check", json!({}));
let error = f.run("registry.install", json!({ "kind": "grammar", "id": "twig" })).unwrap_err().to_string();
assert!(error.contains("no trusted registry key"), "{error}");
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "addon");
f.session.set_addons_verifier(Box::new(notescraft_engine::addons::Ed25519::new(vec![])));
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "registry_refused");
}
#[test]
fn offline_check_leaves_the_cache() {
let mut f = fixture("offline", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
f.session.set_addons_transport(Box::new(Shared(Arc::new(FakeTransport::new()))));
assert_eq!(f.code("registry.check", json!({})), "offline");
assert_eq!(f.ok("registry.list", json!({}))["entries"].as_array().map(Vec::len), Some(1));
}
#[test]
fn install_before_any_check_says_so() {
let mut f = fixture("nocache", script("1.0.0", TINY.as_bytes()));
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "not_found");
}
#[test]
fn enable_and_disable_write_the_vault_file() {
let text = manifest("dice", "1.0.0", &[]);
let files: &[(&str, &[u8])] = &[("manifest.json", text.as_bytes()), ("plugin.wasm", wasm())];
let doc = index_json(&[entry("plugin", "dice", "1.0.0", files, &[])], &[]);
let net = serve(FakeTransport::new().with(INDEX_URL, &doc), "plugin", "dice", "1.0.0", files);
let mut f = fixture("enable", net);
assert_eq!(f.code("registry.enable", json!({ "id": "dice" })), "not_found", "not installed yet");
f.ok("registry.check", json!({}));
f.ok("registry.install", json!({ "kind": "plugin", "id": "dice" }));
f.ok("registry.enable", json!({ "id": "dice" }));
let file = std::fs::read_to_string(f.vault_path.join(".notescraft/registry.json")).unwrap();
assert!(file.contains("plugin:dice"), "{file}");
f.ok("registry.disable", json!({ "id": "dice" }));
let file = std::fs::read_to_string(f.vault_path.join(".notescraft/registry.json")).unwrap();
assert!(!file.contains("plugin:dice"), "{file}");
}
#[test]
fn a_read_only_session_refuses_the_mutating_ids() {
let vault = temp_vault("ro");
let mut session = Session::open_read_only(vault.path()).unwrap();
let mut reg = Registry::new();
register_core(&mut reg).unwrap();
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback", "registry.enable", "registry.disable"] {
let params = json!({ "kind": "grammar", "id": "twig" });
assert!(matches!(reg.run(id, &mut session, &params), Err(Error::Disabled(_))), "{id}");
}
for id in ["registry.list", "registry.suggest"] {
assert!(!matches!(reg.run(id, &mut session, &json!({ "label": "x" })), Err(Error::Disabled(_))), "{id}");
}
}
#[test]
fn the_gate_classes_hold() {
let f = fixture("gate", FakeTransport::new());
let mut session = f.session;
let decide = |session: &Session, id: &str| AssistantGate::check(GateSource::Assistant, session, id, &Value::Null);
for id in ["registry.list", "registry.info", "registry.suggest"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
for id in ["registry.enable", "registry.disable", "registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Deny(_)), "{id} needs allow_write");
}
session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: false, review_writes: false, ..AssistantSettings::default() });
for id in ["registry.enable", "registry.disable"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Deny(_)), "{id} needs allow_delete");
}
session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: true, review_writes: false, ..AssistantSettings::default() });
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
}
#[test]
fn list_reports_a_legacy_grammar_as_installed() {
let mut f = fixture("legacy-list", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
f.ok("grammar.install_text", json!({ "id": "twig", "text": TINY }));
let row = f.ok("registry.list", json!({ "state": "installed" }))["entries"][0].clone();
assert_eq!((row["id"].clone(), row["state"].clone(), row["source"].clone()), (json!("twig"), json!("installed"), json!("legacy")));
assert_eq!(f.ok("registry.list", json!({ "state": "available" }))["entries"], json!([]));
}