2026-06-16 17:57:57 -06:00
|
|
|
//! Plugin-runtime acceptance + failure-isolation tests, plus manifest-validation
|
|
|
|
|
//! unit tests.
|
|
|
|
|
//!
|
|
|
|
|
//! The `.wasm` fixtures are built and committed by `scripts/build-plugin-hello.sh`
|
|
|
|
|
//! from `wasm/oxicloud-plugin-hello/`. Run with `cargo test --features plugins`.
|
|
|
|
|
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
use super::ExtismPluginManager;
|
|
|
|
|
use super::manifest;
|
|
|
|
|
use super::runtime::{InvokeOutcome, PluginRuntime};
|
2026-06-16 21:26:36 -06:00
|
|
|
use crate::application::ports::plugin_ports::event_export_name;
|
2026-06-16 17:57:57 -06:00
|
|
|
use crate::common::config::PluginConfig;
|
|
|
|
|
|
|
|
|
|
fn cfg() -> PluginConfig {
|
|
|
|
|
PluginConfig::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load a committed `.wasm` fixture, failing with a build hint if it's missing.
|
|
|
|
|
fn fixture(name: &str) -> Vec<u8> {
|
|
|
|
|
let path = format!(
|
|
|
|
|
"{}/tests/fixtures/plugins/{}",
|
|
|
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
|
|
|
name
|
|
|
|
|
);
|
|
|
|
|
std::fs::read(&path).unwrap_or_else(|e| {
|
|
|
|
|
panic!("missing fixture {path}: {e}\n run scripts/build-plugin-hello.sh to (re)build it")
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
fn file_uploaded_input() -> String {
|
2026-06-16 17:57:57 -06:00
|
|
|
serde_json::json!({
|
|
|
|
|
"abi": 0,
|
|
|
|
|
"event": "file.uploaded",
|
|
|
|
|
"context": {
|
|
|
|
|
"plugin_id": "com.example.hello",
|
|
|
|
|
"user_id": "u_test",
|
|
|
|
|
"invocation_id": "inv_test_0001"
|
|
|
|
|
},
|
|
|
|
|
"payload": { "path": "/photos/2026/cat.jpg", "size": 81234, "mime": "image/jpeg" }
|
|
|
|
|
})
|
|
|
|
|
.to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
fn user_login_input() -> String {
|
|
|
|
|
serde_json::json!({
|
|
|
|
|
"abi": 0,
|
|
|
|
|
"event": "user.login",
|
|
|
|
|
"context": {
|
|
|
|
|
"plugin_id": "com.example.hello",
|
|
|
|
|
"user_id": "u_test",
|
|
|
|
|
"invocation_id": "inv_login_0001"
|
|
|
|
|
},
|
|
|
|
|
"payload": {
|
|
|
|
|
"user_id": "u_test",
|
|
|
|
|
"username": "alice",
|
|
|
|
|
"email": "alice@example.com",
|
|
|
|
|
"first_login": true,
|
|
|
|
|
"is_external": false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- The M0 exit criterion: the full loop, per event ------------------------
|
2026-06-16 17:57:57 -06:00
|
|
|
|
|
|
|
|
#[test]
|
2026-06-16 21:26:36 -06:00
|
|
|
fn acceptance_file_uploaded_returns_ok_and_calls_host_log() {
|
2026-06-16 17:57:57 -06:00
|
|
|
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
2026-06-16 21:26:36 -06:00
|
|
|
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
2026-06-16 17:57:57 -06:00
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
result.outcome.is_ok(),
|
|
|
|
|
"plugin did not complete: {:?}",
|
|
|
|
|
result.outcome
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.logs.iter().any(|(level, msg)| level == "info"
|
|
|
|
|
&& msg.contains("hello plugin saw upload: /photos/2026/cat.jpg")),
|
|
|
|
|
"expected the plugin's host log line, got: {:?}",
|
|
|
|
|
result.logs
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
#[test]
|
|
|
|
|
fn acceptance_user_login_returns_ok_and_calls_host_log() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
|
|
|
|
let result = rt.invoke(&cfg(), "on_user_login", "inv", &user_login_input());
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
result.outcome.is_ok(),
|
|
|
|
|
"plugin did not complete: {:?}",
|
|
|
|
|
result.outcome
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
result.logs.iter().any(|(level, msg)| level == "info"
|
|
|
|
|
&& msg.contains("hello plugin saw login: user u_test (first_login=true)")),
|
|
|
|
|
"expected the plugin's user.login log line, got: {:?}",
|
|
|
|
|
result.logs
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 17:57:57 -06:00
|
|
|
// ---- The guarantees, not just the happy path --------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_wrong_abi() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.wrong-abi", fixture("wrong_abi.wasm"));
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(
|
2026-06-16 21:26:36 -06:00
|
|
|
rt.check_loadable(&cfg(), &[]),
|
2026-06-16 17:57:57 -06:00
|
|
|
InvokeOutcome::AbiMismatch { got: 1 }
|
|
|
|
|
),
|
|
|
|
|
"wrong-abi plugin should be rejected at load"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
#[test]
|
|
|
|
|
fn load_requires_subscribed_event_exports() {
|
|
|
|
|
let cfg = cfg();
|
|
|
|
|
let login_export = vec![event_export_name("user.login")];
|
|
|
|
|
|
|
|
|
|
// hello.wasm exports both handlers -> loadable for user.login.
|
|
|
|
|
let hello = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
hello.check_loadable(&cfg, &login_export),
|
|
|
|
|
InvokeOutcome::Ok
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// omit_login.wasm lacks on_user_login -> rejected when it claims user.login.
|
|
|
|
|
let omit = PluginRuntime::new("com.example.omit", fixture("omit_login.wasm"));
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(
|
|
|
|
|
omit.check_loadable(&cfg, &login_export),
|
|
|
|
|
InvokeOutcome::MissingExport(ref e) if e == "on_user_login"
|
|
|
|
|
),
|
|
|
|
|
"omit_login must be rejected for a user.login subscription"
|
|
|
|
|
);
|
|
|
|
|
// …but it is fine for file.uploaded, which it does export.
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
omit.check_loadable(&cfg, &[event_export_name("file.uploaded")]),
|
|
|
|
|
InvokeOutcome::Ok
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 17:57:57 -06:00
|
|
|
#[test]
|
|
|
|
|
fn contains_a_panicking_plugin() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.panic", fixture("panic.wasm"));
|
2026-06-16 21:26:36 -06:00
|
|
|
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
2026-06-16 17:57:57 -06:00
|
|
|
assert!(
|
|
|
|
|
matches!(result.outcome, InvokeOutcome::Trap(_)),
|
|
|
|
|
"expected a contained trap, got {:?}",
|
|
|
|
|
result.outcome
|
|
|
|
|
);
|
|
|
|
|
// Reaching this line at all proves the host process survived the trap.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn enforces_timeout() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.sleep", fixture("sleep.wasm"));
|
|
|
|
|
let start = Instant::now();
|
2026-06-16 21:26:36 -06:00
|
|
|
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
2026-06-16 17:57:57 -06:00
|
|
|
let elapsed = start.elapsed();
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
matches!(result.outcome, InvokeOutcome::Timeout),
|
|
|
|
|
"expected a timeout, got {:?}",
|
|
|
|
|
result.outcome
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
elapsed < Duration::from_secs(2),
|
|
|
|
|
"timeout took too long to fire: {elapsed:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-17 00:28:10 -06:00
|
|
|
#[test]
|
|
|
|
|
fn idle_eviction_drops_and_recompiles() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.hello", fixture("hello.wasm"));
|
|
|
|
|
// First invoke compiles + caches the module.
|
|
|
|
|
let r1 = rt.invoke(&cfg(), "on_file_uploaded", "inv1", &file_uploaded_input());
|
|
|
|
|
assert!(r1.outcome.is_ok(), "first invoke: {:?}", r1.outcome);
|
|
|
|
|
|
|
|
|
|
// Idle past a zero TTL -> the cached module is dropped.
|
|
|
|
|
assert!(
|
|
|
|
|
rt.evict_if_idle(Duration::ZERO),
|
|
|
|
|
"a just-idle module should be evicted"
|
|
|
|
|
);
|
|
|
|
|
// Nothing left to evict the second time.
|
|
|
|
|
assert!(
|
|
|
|
|
!rt.evict_if_idle(Duration::ZERO),
|
|
|
|
|
"second eviction is a no-op"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The next invoke recompiles transparently and still works.
|
|
|
|
|
let r2 = rt.invoke(&cfg(), "on_file_uploaded", "inv2", &file_uploaded_input());
|
|
|
|
|
assert!(
|
|
|
|
|
r2.outcome.is_ok(),
|
|
|
|
|
"recompile after eviction: {:?}",
|
|
|
|
|
r2.outcome
|
|
|
|
|
);
|
|
|
|
|
// A long TTL never evicts a freshly-used module.
|
|
|
|
|
assert!(
|
|
|
|
|
!rt.evict_if_idle(Duration::from_secs(3600)),
|
|
|
|
|
"a fresh module must not be evicted"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 17:57:57 -06:00
|
|
|
#[test]
|
|
|
|
|
fn no_network() {
|
|
|
|
|
let rt = PluginRuntime::new("com.example.net", fixture("net.wasm"));
|
2026-06-16 21:26:36 -06:00
|
|
|
let result = rt.invoke(&cfg(), "on_file_uploaded", "inv", &file_uploaded_input());
|
2026-06-16 17:57:57 -06:00
|
|
|
assert!(
|
|
|
|
|
!result.outcome.is_ok(),
|
|
|
|
|
"network access should be denied, got {:?}",
|
|
|
|
|
result.outcome
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
// ---- Manager discovery + dispatch ------------------------------------------
|
2026-06-16 17:57:57 -06:00
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
/// Write a one-plugin directory (plugin.toml + the given wasm) under a tempdir
|
|
|
|
|
/// and load a manager from it.
|
|
|
|
|
fn manager_with(wasm_name: &str, subscribe_toml: &str) -> (tempfile::TempDir, ExtismPluginManager) {
|
2026-06-16 17:57:57 -06:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-06-16 21:26:36 -06:00
|
|
|
let dir = tmp.path().join("plugin");
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
std::fs::write(dir.join("plugin.wasm"), fixture(wasm_name)).unwrap();
|
2026-06-16 17:57:57 -06:00
|
|
|
std::fs::write(
|
2026-06-16 21:26:36 -06:00
|
|
|
dir.join("plugin.toml"),
|
|
|
|
|
format!(
|
|
|
|
|
r#"
|
2026-06-16 17:57:57 -06:00
|
|
|
[plugin]
|
2026-06-16 21:26:36 -06:00
|
|
|
id = "com.example.test"
|
|
|
|
|
name = "Test"
|
2026-06-16 17:57:57 -06:00
|
|
|
version = "0.1.0"
|
|
|
|
|
abi = 0
|
2026-06-16 21:26:36 -06:00
|
|
|
entrypoint = "plugin.wasm"
|
2026-06-16 17:57:57 -06:00
|
|
|
|
|
|
|
|
[events]
|
2026-06-16 21:26:36 -06:00
|
|
|
subscribe = {subscribe_toml}
|
|
|
|
|
"#
|
|
|
|
|
),
|
2026-06-16 17:57:57 -06:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let manager = ExtismPluginManager::load_from_dir(cfg(), tmp.path());
|
2026-06-16 21:26:36 -06:00
|
|
|
(tmp, manager)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn manager_loads_and_dispatches_both_events() {
|
|
|
|
|
use crate::application::ports::plugin_ports::{
|
|
|
|
|
EVENT_FILE_UPLOADED, EVENT_USER_LOGIN, PluginDispatchPort, PluginEvent,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let (_tmp, manager) = manager_with("hello.wasm", r#"["file.uploaded", "user.login"]"#);
|
2026-06-16 17:57:57 -06:00
|
|
|
assert_eq!(manager.loaded_count(), 1, "the valid plugin should load");
|
|
|
|
|
assert!(manager.has_subscribers("file.uploaded"));
|
2026-06-16 21:26:36 -06:00
|
|
|
assert!(manager.has_subscribers("user.login"));
|
2026-06-16 17:57:57 -06:00
|
|
|
assert!(!manager.has_subscribers("file.deleted"));
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
// Both dispatches run the plugin on the blocking pool; neither may panic.
|
|
|
|
|
manager.dispatch(PluginEvent {
|
|
|
|
|
name: EVENT_FILE_UPLOADED,
|
2026-06-16 17:57:57 -06:00
|
|
|
user_id: Some("u_test".into()),
|
2026-06-16 21:26:36 -06:00
|
|
|
invocation_id: "inv_upload".into(),
|
|
|
|
|
payload: serde_json::json!({ "path": "/a.txt", "size": 3, "mime": "text/plain" }),
|
|
|
|
|
});
|
|
|
|
|
manager.dispatch(PluginEvent {
|
|
|
|
|
name: EVENT_USER_LOGIN,
|
|
|
|
|
user_id: Some("u_test".into()),
|
|
|
|
|
invocation_id: "inv_login".into(),
|
|
|
|
|
payload: serde_json::json!({ "user_id": "u_test", "first_login": false }),
|
2026-06-16 17:57:57 -06:00
|
|
|
});
|
|
|
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
#[test]
|
|
|
|
|
fn manager_rejects_plugin_missing_a_subscribed_export() {
|
|
|
|
|
// omit_login.wasm subscribes to user.login but doesn't export on_user_login.
|
|
|
|
|
let (_tmp, rejected) = manager_with("omit_login.wasm", r#"["user.login"]"#);
|
|
|
|
|
assert_eq!(rejected.loaded_count(), 0, "missing export -> not loaded");
|
|
|
|
|
|
|
|
|
|
// The same wasm is fine when it only claims an event it actually exports.
|
|
|
|
|
let (_tmp2, loaded) = manager_with("omit_login.wasm", r#"["file.uploaded"]"#);
|
|
|
|
|
assert_eq!(loaded.loaded_count(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 17:57:57 -06:00
|
|
|
// ---- Manifest validation (no wasm needed) -----------------------------------
|
|
|
|
|
|
|
|
|
|
const VALID_MANIFEST: &str = r#"
|
|
|
|
|
[plugin]
|
|
|
|
|
id = "com.example.hello"
|
|
|
|
|
name = "Hello"
|
|
|
|
|
version = "0.1.0"
|
|
|
|
|
abi = 0
|
|
|
|
|
entrypoint = "hello.wasm"
|
|
|
|
|
|
|
|
|
|
[events]
|
|
|
|
|
subscribe = ["file.uploaded"]
|
|
|
|
|
"#;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn manifest_accepts_valid() {
|
|
|
|
|
let m = manifest::parse_and_validate(VALID_MANIFEST).expect("valid manifest");
|
|
|
|
|
assert_eq!(m.plugin.id, "com.example.hello");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
#[test]
|
|
|
|
|
fn manifest_accepts_user_login_and_combined() {
|
|
|
|
|
let login = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["user.login"]"#);
|
|
|
|
|
assert!(manifest::parse_and_validate(&login).is_ok());
|
|
|
|
|
|
|
|
|
|
let both = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.uploaded", "user.login"]"#);
|
|
|
|
|
assert!(manifest::parse_and_validate(&both).is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 17:57:57 -06:00
|
|
|
#[test]
|
|
|
|
|
fn manifest_rejects_unknown_field() {
|
|
|
|
|
let toml = format!("{VALID_MANIFEST}\nbogus_top_level = true\n");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
|
|
|
|
"parse_error"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn manifest_rejects_abi_mismatch() {
|
|
|
|
|
let toml = VALID_MANIFEST.replace("abi = 0", "abi = 1");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
|
|
|
|
"abi_mismatch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn manifest_rejects_unknown_event() {
|
|
|
|
|
let toml = VALID_MANIFEST.replace(r#"["file.uploaded"]"#, r#"["file.deleted"]"#);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
|
|
|
|
"unknown_event"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn manifest_rejects_nonempty_permissions() {
|
|
|
|
|
let toml = format!("{VALID_MANIFEST}\n[permissions]\nfs = \"/tmp\"\n");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
manifest::parse_and_validate(&toml).unwrap_err().reason(),
|
|
|
|
|
"permissions_not_empty"
|
|
|
|
|
);
|
|
|
|
|
}
|