plugin/host/tests/config_names_the_reader.rs
//! **The bar: `ron_handler = "skidbladnir"` must mean the same thing whether
//! that handler is linked into holger or loaded from a `.wasm` in a directory.**
//!
//! That indistinguishability is the whole design. A repository config names the
//! *container* (`ron_repo_type` / `ron_upstream_format` → `ArtifactFormat`) and
//! now names the *reader* (`ron_handler`); the payoff is that a new reader is a
//! string in the config plus a module in the plugin directory, with **no holger
//! rebuild**. A test that only exercised the linked path would prove half of it
//! and leave the half that carries the payoff unasserted.
//!
//! So every assertion below is made against BOTH, in a loop, with no branch on
//! which kind is answering — and the two are wired from the same
//! `holger_server_lib::repository::Repository`, through
//! `backend_from_config_with`, the way boot does it.
//!
//! `server/lib/src/repository.rs` carries the linked-only half (the red/green
//! against the generic znippy backend, the typo, and the absent field); this
//! file is the module half plus the two-together claim.
// Loads real modules through the wasm host: without `wasm-plugins` this file
// did not compile under `cargo test -p holger-plugin-host` and only ran in a
// workspace build through feature unification. Its declared arm turns it on.
use std::path::{Path, PathBuf};
use holger_plugin_host::{HandlerCatalog, Origin};
use holger_server_lib::handlers::builtin_catalog;
use holger_server_lib::repository::Repository;
use traits::{ArtifactFormat, ArtifactId};
mod common;
use common::{plugin_dir_with_modules, scratch};
const SKID_MODULE: &str = "holger_handler_skidbladnir_wasm.wasm";
/// The bytes a bundle is served as. A NUL, a comma, a colon and a `0xff` —
/// nothing that survives a lossy round trip by accident.
const BUNDLE: &[u8] = b"\x00sealed,bundle:bytes\xff";
/// A repository config that names a reader for its container. Written field by
/// field rather than parsed from RON so the compiler catches a renamed field —
/// but it is the same struct `read_ron_config` deserializes into.
fn repo_named(name: &str, data_dir: &Path, handler: Option<&str>) -> Repository {
Repository {
ron_name: name.to_string(),
// This repository demands no credential of its own; the server-wide
// posture decides. The default, and what every config here means.
ron_require_auth: false,
ron_require_role: None,
ron_visibility: Default::default(),
// The CONTAINER. Unchanged, and deliberately still meaningful.
ron_repo_type: "znippy".to_string(),
ron_upstreams: vec![],
ron_in: None,
ron_out: None,
ron_members: vec![],
ron_archive_path: None,
ron_data_dir: Some(data_dir.to_string_lossy().into_owned()),
ron_base_url: None,
ron_writable_archive: None,
ron_upstream_repo: None,
ron_upstream_format: Some("znippy".to_string()),
// The READER.
ron_handler: handler.map(str::to_string),
ron_upstream_auth: None,
ron_trusted_roots: None,
ron_negative_cache_secs: None,
backend_repository: None,
// Not an upstream, so nothing publishes a digest to hold it against.
upstream_attestation: None,
wired_upstreams: vec![],
wired_members: vec![],
}
}
fn seeded_store(tag: &str) -> PathBuf {
let dir = scratch(tag);
std::fs::write(dir.join("tillsynia-20260706.znippy"), BUNDLE).unwrap();
dir
}
/// A catalog holding ONLY the loaded module — no linked handler under that name,
/// so a green here cannot be the linked one answering.
fn loaded_only() -> HandlerCatalog {
let plugins = plugin_dir_with_modules(&[SKID_MODULE]);
let mut c = HandlerCatalog::new();
let rejected = c
.load_dir(&plugins)
.unwrap_or_else(|e| panic!("loading {}: {e}", plugins.display()));
assert!(rejected.is_empty(), "modules rejected: {rejected:?}");
assert_eq!(
c.accepted(),
vec!["skidbladnir".to_string()],
"the module did not register under the name its own manifest declares"
);
assert!(
matches!(c.origin("skidbladnir"), Some(Origin::Loaded(_))),
"this catalog must hold the LOADED half only"
);
c
}
/// A catalog holding ONLY the linked handler — nothing scanned.
fn linked_only() -> HandlerCatalog {
let c = builtin_catalog().expect("builtin catalog");
assert_eq!(c.origin("skidbladnir"), Some(Origin::Linked));
c
}
/// **The claim.** One config string, two utterly different mechanisms behind
/// it, and every answer identical — asserted on served bytes and served
/// responses, never on a state round-trip.
#[test]
fn one_ron_handler_string_resolves_to_a_linked_handler_and_to_a_loaded_module_alike() {
let id = ArtifactId {
namespace: None,
name: "tillsynia".into(),
version: "20260706".into(),
};
for (kind, catalog) in [("linked", linked_only()), ("loaded", loaded_only())] {
let store = seeded_store(&format!("store-{kind}"));
let mut repo = repo_named("bundles", &store, Some("skidbladnir"));
repo.backend_from_config_with(&catalog)
.unwrap_or_else(|e| panic!("{kind}: ron_handler = skidbladnir did not wire: {e:#}"));
let backend = repo.backend_repository.as_ref().expect("backend built");
assert_eq!(backend.name(), "bundles", "{kind}");
assert_eq!(backend.format(), ArtifactFormat::Znippy, "{kind}");
assert!(backend.is_writable(), "{kind}");
// The bytes.
assert_eq!(
backend.fetch(&id).expect("fetch").as_deref(),
Some(BUNDLE),
"{kind}"
);
// The HTTP door.
let (status, headers, body) = backend
.handle_http2_request("GET", "/bundles/tillsynia/20260706", b"")
.unwrap_or_else(|e| panic!("{kind}: http: {e:#}"));
assert_eq!(status, 200, "{kind}");
assert_eq!(
body, BUNDLE,
"{kind}: the HTTP door served different bytes than fetch"
);
assert!(
headers
.iter()
.any(|(k, v)| k == "content-type" && v == "application/vnd.znippy.bundle"),
"{kind}: {headers:?}"
);
// The classifier — the route no generic znippy backend has, reached over
// the ABI in one case and by a direct call in the other.
let (status, _, body) = backend
.handle_http2_request(
"GET",
"/bundles/tillsynia/20260706/classify/secrets/secrets.age",
b"",
)
.unwrap_or_else(|e| panic!("{kind}: classify: {e:#}"));
assert_eq!(status, 200, "{kind}");
assert_eq!(
String::from_utf8_lossy(&body),
"kind\tsecret\ncomponent\tsecrets\nencrypted\ttrue",
"{kind}"
);
// A write through the config-selected backend really lands where the
// operator's data directory points — the handler does not choose.
let other = ArtifactId {
namespace: None,
name: "njord".into(),
version: "1".into(),
};
backend
.put(&other, b"pushed")
.unwrap_or_else(|e| panic!("{kind}: put: {e:#}"));
assert_eq!(
std::fs::read(store.join("njord-1.znippy")).expect("the put did not reach the store"),
b"pushed",
"{kind}"
);
}
}
/// A module in the plugin directory is a handler the config may name **without
/// holger being rebuilt** — so the error for a typo must list it too, or an
/// operator cannot discover what a deployment actually offers.
#[test]
fn the_accepted_list_names_loaded_modules_not_only_linked_handlers() {
let plugins = plugin_dir_with_modules(&["holger_handler_rust_toolchain_wasm.wasm"]);
let mut catalog = HandlerCatalog::new();
// Deliberately NOT the builtins: the only handler here arrived as a file.
catalog.load_dir(&plugins).expect("load_dir");
let store = seeded_store("accepted");
let mut repo = repo_named("bundles", &store, Some("rust_toolchain"));
let err = repo
.backend_from_config_with(&catalog)
.expect_err("an underscore typo resolved to the hyphenated handler");
let chain = format!("{err:#}");
assert!(
chain.contains("rust_toolchain"),
"the error does not name the bad value: {chain}"
);
assert!(
chain.contains("rust-toolchain"),
"the accepted list omits the handler that came from the plugin directory: {chain}"
);
assert!(
chain.contains(&plugins.display().to_string()),
"the error does not say WHERE it looked: {chain}"
);
assert!(
repo.backend_repository.is_none(),
"a rejected handler still wired"
);
}
/// A configured plugin directory that is not there is a **boot error**, not a
/// catalog that quietly holds fewer handlers than the operator wrote down.
#[test]
fn a_missing_plugin_directory_stops_the_boot() {
let gone = scratch("gone");
std::fs::remove_dir_all(&gone).unwrap();
let mut catalog = builtin_catalog().unwrap();
let err = catalog
.load_dir(&gone)
.expect_err("a missing plugin directory returned Ok");
let msg = err.to_string();
assert!(msg.contains(&gone.display().to_string()), "{msg}");
assert!(
msg.contains(common::BUILD_CMD),
"no build command in: {msg}"
);
}
/// Two handlers cannot share one config string. A module declaring a name a
/// linked handler already holds is REJECTED and reported — and the handler that
/// held the name still serves, so the collision changes nothing silently.
#[test]
fn a_module_cannot_take_a_name_a_linked_handler_already_holds() {
let plugins = plugin_dir_with_modules(&[SKID_MODULE]);
let mut catalog = builtin_catalog().unwrap();
let rejected = catalog.load_dir(&plugins).expect("load_dir");
assert_eq!(
rejected.len(),
1,
"the colliding module was accepted or silently dropped"
);
assert!(rejected[0].path.ends_with(SKID_MODULE));
assert!(
rejected[0].reason.contains("already registered"),
"the rejection does not say why: {}",
rejected[0].reason
);
assert_eq!(
catalog.origin("skidbladnir"),
Some(Origin::Linked),
"the module replaced the linked handler that held the name"
);
// And the name still serves.
let store = seeded_store("collision");
let mut repo = repo_named("bundles", &store, Some("skidbladnir"));
repo.backend_from_config_with(&catalog).expect("wire");
let id = ArtifactId {
namespace: None,
name: "tillsynia".into(),
version: "20260706".into(),
};
assert_eq!(
repo.backend_repository
.unwrap()
.fetch(&id)
.expect("fetch")
.as_deref(),
Some(BUNDLE)
);
}