Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

plugin/host/tests/router_cannot_tell_them_apart.rs

//! **The bar: a real `.wasm`, loaded from a plugin directory, answering a real
//! request through holger's normal router.**
//!
//! Not a unit test of the loader — the module is registered by scanning a
//! directory, put into the same `FastRoutes` table `server/lib` builds after
//! wiring, looked up by repository name, and asked for bytes and for an HTTP
//! response through `RepositoryBackendTrait`. Nothing here reaches around the
//! router.
//!
//! The claim under test is the design's whole point: **`fast_routes.rs` cannot
//! tell a linked handler from a loaded one.** So the same route table holds one
//! of each, and every assertion is made against both without the caller ever
//! naming which is which.

// 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::sync::Arc;

use holger_plugin_host::registry::{scan, FileStemMounts, ScanError};
use holger_plugin_host::{mount_linked_with, DirStore};
use holger_server_lib::exposed::fast_routes::FastRoutes;
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait};

mod common;
use common::{plugin_dir_with_modules, scratch, BUILD_CMD};

/// Registration reads a DIRECTORY. Nothing in `server/lib/Cargo.toml` mentions
/// these handlers, and the route they answer on comes from the module file's
/// name — so a new handler needs no rebuild of holger.
#[test]
fn a_module_dropped_in_a_directory_serves_a_real_request_through_the_router() {
    let plugins = plugin_dir_with_modules(&["holger_handler_skidbladnir_wasm.wasm"]);
    let data_root = scratch("data");

    let reg = scan(
        &plugins,
        &FileStemMounts {
            data_root: data_root.clone(),
        },
    )
    .unwrap_or_else(|e| panic!("scanning {}: {e}", plugins.display()));
    assert!(
        reg.failures.is_empty(),
        "modules failed to load: {:?}",
        reg.failures
    );
    assert_eq!(
        reg.backends.len(),
        1,
        "expected exactly one handler from {}",
        plugins.display()
    );

    let repo = reg.backends[0].name().to_string();
    assert_eq!(
        repo, "holger_handler_skidbladnir_wasm",
        "the route name did not come from the module file stem"
    );
    assert_eq!(reg.backends[0].handler_name(), "skidbladnir");

    // Seed a bundle where that backend's store points, then build the SAME route
    // table `server/lib` builds after wiring.
    let store_dir = data_root.join(&repo);
    std::fs::create_dir_all(&store_dir).unwrap();
    let body = b"\x00sealed,bundle:bytes\xff".to_vec();
    std::fs::write(store_dir.join("tillsynia-20260706.znippy"), &body).unwrap();

    let routes = FastRoutes::new(
        reg.backends
            .into_iter()
            .map(|b| (b.name().to_string(), b.into_backend()))
            .collect(),
    );

    // Everything below goes through the router, exactly as a request does.
    let backend = routes
        .lookup(&repo)
        .expect("the router did not resolve the plugin's repo");
    assert_eq!(backend.format(), ArtifactFormat::Znippy);

    let id = ArtifactId {
        namespace: None,
        name: "tillsynia".into(),
        version: "20260706".into(),
    };
    assert_eq!(
        backend
            .fetch(&id)
            .expect("fetch through the router errored"),
        Some(traits::Bytes::from(body.clone())),
        "the router resolved the backend but it served the wrong bytes"
    );

    let (status, headers, served) = backend
        .handle_http2_request("GET", &format!("/{repo}/tillsynia/20260706"), b"")
        .expect("http through the router errored");
    assert_eq!(status, 200);
    assert_eq!(
        served, body,
        "the HTTP door served different bytes than fetch"
    );
    assert!(
        headers
            .iter()
            .any(|(k, v)| k == "content-type" && v == "application/vnd.znippy.bundle"),
        "content-type missing from a wasm-served response: {headers:?}"
    );

    // And the shared classifier, reached over the ABI, through the router.
    let (status, _, body) = backend
        .handle_http2_request(
            "GET",
            &format!("/{repo}/tillsynia/20260706/classify/secrets/secrets.age"),
            b"",
        )
        .expect("classify through the router errored");
    assert_eq!(status, 200);
    assert_eq!(
        String::from_utf8_lossy(&body),
        "kind\tsecret\ncomponent\tsecrets\nencrypted\ttrue"
    );

    // A name the table does not hold must miss, or `lookup` proves nothing.
    assert!(routes.lookup("no-such-repo").is_none());
}

/// One route table, one linked handler and one loaded module, and the caller
/// cannot tell which is which from the outside.
#[test]
fn a_linked_handler_and_a_loaded_module_are_indistinguishable_in_one_route_table() {
    let plugins = plugin_dir_with_modules(&["holger_handler_skidbladnir_wasm.wasm"]);
    let data_root = scratch("mixed");

    let reg = scan(
        &plugins,
        &FileStemMounts {
            data_root: data_root.clone(),
        },
    )
    .expect("scan");
    assert_eq!(reg.backends.len(), 1);
    let loaded_repo = reg.backends[0].name().to_string();

    // The linked half: the SAME handler the module is built from, compiled in.
    let linked_dir = data_root.join("linked");
    std::fs::create_dir_all(&linked_dir).unwrap();
    let linked = mount_linked_with(
        "linked-bundles",
        holger_handler_skidbladnir::SkidbladnirHandler::new(),
        Arc::new(DirStore::new(&linked_dir)),
    )
    .expect("linked mount");

    let body = b"\x00same,bytes:\xff".to_vec();
    std::fs::write(linked_dir.join("tillsynia-1.znippy"), &body).unwrap();
    let loaded_dir = data_root.join(&loaded_repo);
    std::fs::create_dir_all(&loaded_dir).unwrap();
    std::fs::write(loaded_dir.join("tillsynia-1.znippy"), &body).unwrap();

    let mut entries: Vec<(String, Arc<dyn RepositoryBackendTrait>)> =
        vec![("linked-bundles".to_string(), linked.into_backend())];
    for b in reg.backends {
        entries.push((b.name().to_string(), b.into_backend()));
    }
    let routes = FastRoutes::new(entries);

    let id = ArtifactId {
        namespace: None,
        name: "tillsynia".into(),
        version: "1".into(),
    };

    // The same assertions against both, with no branch on which kind it is.
    for repo in ["linked-bundles", loaded_repo.as_str()] {
        let backend = routes
            .lookup(repo)
            .unwrap_or_else(|| panic!("{repo} not routed"));
        assert_eq!(backend.name(), repo);
        assert_eq!(backend.format(), ArtifactFormat::Znippy, "{repo}");
        assert!(backend.is_writable(), "{repo}");
        assert_eq!(
            backend.fetch(&id).expect("fetch").as_deref(),
            Some(body.as_slice()),
            "{repo}"
        );
        assert_eq!(
            backend
                .coordinate_for_path(&format!("/{repo}/tillsynia/1"))
                .expect("maps"),
            Some(id.clone()),
            "{repo}"
        );

        // What the ABI deliberately does not carry, identical on both: no
        // archive, and `delete_artifact` fails CLOSED as Unsupported.
        assert_eq!(
            backend.caps(),
            traits::Caps {
                listing: true,
                path_mapping: true,
                ..traits::Caps::NONE
            },
            "{repo}"
        );
        assert!(
            traits::is_unsupported(&backend.archive().unwrap_err()),
            "{repo} claims a znippy archive it does not have"
        );
        assert!(
            traits::is_unsupported(&backend.delete_artifact(&id, "deadbeef").unwrap_err()),
            "{repo}: delete_artifact did not fail closed"
        );
        traits::backend::conformance::check(backend.as_ref()).unwrap();
    }

    // Both really are in the one table.
    assert_eq!(routes.all_repos().len(), 2);
}

/// Both modules at once — two handlers, one directory, one scan, one table.
#[test]
fn two_modules_register_side_by_side_under_their_own_routes() {
    let plugins = plugin_dir_with_modules(&[
        "holger_handler_skidbladnir_wasm.wasm",
        "holger_handler_rust_toolchain_wasm.wasm",
    ]);
    let data_root = scratch("two");

    let reg = scan(&plugins, &FileStemMounts { data_root }).expect("scan");
    assert!(reg.failures.is_empty(), "{:?}", reg.failures);
    assert_eq!(reg.backends.len(), 2, "both modules should have registered");

    let mut handlers: Vec<&str> = reg.backends.iter().map(|b| b.handler_name()).collect();
    handlers.sort();
    assert_eq!(handlers, vec!["rust-toolchain", "skidbladnir"]);

    let routes = FastRoutes::new(
        reg.backends
            .into_iter()
            .map(|b| (b.name().to_string(), b.into_backend()))
            .collect(),
    );
    assert!(routes.lookup("holger_handler_skidbladnir_wasm").is_some());
    assert!(routes
        .lookup("holger_handler_rust_toolchain_wasm")
        .is_some());
}

/// The loud-absence condition, asserted where a deployment would hit it.
#[test]
fn a_plugin_directory_that_does_not_exist_is_an_error_at_startup() {
    let missing = scratch("gone");
    std::fs::remove_dir_all(&missing).unwrap();

    let err = scan(
        &missing,
        &FileStemMounts {
            data_root: missing.clone(),
        },
    )
    .expect_err("a missing plugin directory returned an empty registry");
    assert!(matches!(err, ScanError::Missing { .. }), "{err:?}");
    let msg = err.to_string();
    assert!(msg.contains(&missing.display().to_string()), "{msg}");
    assert!(
        msg.contains(BUILD_CMD),
        "the error does not carry the build command: {msg}"
    );
}