tests/public_api.rs
//! PUBLIC-API seam test — exercises Skíðblaðnir's service/systemd surface through
//! ONLY its public exports (an external consumer's view: holger/nornir/dwarfs).
//!
//! The in-crate `#[cfg(test)]` modules can reach `pub(crate)` internals; this file
//! deliberately cannot, so it pins the exact API those consumers link against:
//! `service::{ServiceSpec, Backend, ServiceRenderer, renderer_for, Socket, Storage,
//! HealthProbe, AgentMode, Lifecycle}`, the shared `systemd::{Scope, RestartPolicy,
//! SystemdServiceSpec, systemd_unit, systemd_unit_scoped, env_file, nornir_spec,
//! holger_spec}`, `k8s::KubernetesRenderer`, and `config::LakeSpec`. If any of these
//! stop being public (or change shape), this test fails to COMPILE — the honest
//! early warning for a downstream break.
use skidbladnir::service::{
AgentMode, Backend, HealthProbe, Lifecycle, ServiceSpec, Socket, Storage, renderer_for,
};
use skidbladnir::systemd::{
ChownRefusal, ChownRefused, RestartPolicy, Scope, SystemdServiceSpec, env_file, holger_spec,
nornir_spec, systemd_unit, systemd_unit_scoped,
};
/// One neutral `ServiceSpec`, built only with public chainers, projects onto every
/// implemented backend through the public `renderer_for`, and each output carries
/// the injected values in that backend's native shape.
#[test]
fn one_public_spec_renders_across_every_implemented_backend() {
let spec = ServiceSpec::new(
"holger",
"Holger artifact repository",
"/opt/holger/bin/holger",
)
.arg("serve")
.user("_holger")
.group("_holger")
.working_dir("/var/lib/holger")
.env("HOLGER_DATA", "/var/lib/holger")
.after("nornir-server")
.restart(RestartPolicy::OnFailure)
.health(HealthProbe::Tcp(7070))
.agent(AgentMode::Fat)
.image("registry.local/holger:v1")
.namespace("prod")
.replicas(3)
.socket(Socket::tcp(7070));
// systemd-system
let sd = renderer_for(Backend::SystemdSystem).unwrap().render(&spec).unwrap();
assert_eq!(sd.backend, Backend::SystemdSystem);
assert!(
sd.file_ending("holger.service")
.unwrap()
.contains("User=_holger")
);
// OpenRC (genuinely different init)
let rc = renderer_for(Backend::OpenRc).unwrap().render(&spec).unwrap();
assert!(
rc.file_ending("/etc/init.d/holger")
.unwrap()
.starts_with("#!/sbin/openrc-run")
);
// Kubernetes (data-only manifests)
let k = renderer_for(Backend::Kubernetes).unwrap().render(&spec).unwrap();
let dep = k.file_ending("holger-deployment.yaml").unwrap();
assert!(dep.contains("image: \"registry.local/holger:v1\""));
assert!(dep.contains("replicas: 3"));
assert!(dep.contains("namespace: \"prod\""));
// OCI (raw `podman run` argv) and Quadlet (a systemd `.container` unit) both
// render the same public spec — the container backends carry the image verbatim.
let oci = renderer_for(Backend::Oci).unwrap().render(&spec).unwrap();
assert!(
oci.start
.join(" ")
.contains("--entrypoint /opt/holger/bin/holger registry.local/holger:v1 serve")
);
let quad = renderer_for(Backend::Quadlet).unwrap().render(&spec).unwrap();
let cunit = quad.file_ending("holger.container").unwrap();
assert!(cunit.contains("Image=registry.local/holger:v1"));
assert!(cunit.contains("Exec=/opt/holger/bin/holger serve"));
assert_eq!(quad.enable, vec!["systemctl", "daemon-reload"]);
// Windows service (SCM): a WiX `.wxs` fragment (via the MSI seam) + `sc.exe` verbs.
let win = renderer_for(Backend::WindowsService).unwrap().render(&spec).unwrap();
let wxs = win.file_ending(".wxs").unwrap();
assert!(wxs.contains("<ServiceInstall"));
assert!(wxs.contains("Account=\"_holger\""));
assert_eq!(win.start, vec!["sc.exe", "start", "holger"]);
// Coverage is complete on the public path: EVERY declared backend renders.
assert!(Backend::all().iter().all(|b| renderer_for(*b).is_some()));
// The one-call systemd convenience is reachable and matches the renderer.
assert_eq!(
spec.render_systemd_unit(),
sd.file_ending("holger.service").unwrap()
);
}
/// The public `Lifecycle` exposes unit name + pure unit text and a real health
/// probe, all without root/D-Bus.
#[test]
fn public_lifecycle_unit_and_health() {
let spec = ServiceSpec::new("svc", "d", "/bin/svc").health(HealthProbe::Tcp(1));
let lc = Lifecycle::new(spec);
assert_eq!(lc.unit(), "svc.service");
assert!(lc.unit_text().contains("ExecStart=/bin/svc"));
assert!(
!lc.health().unwrap(),
"nothing listens on port 1 ⇒ unhealthy"
);
}
/// The shared systemd generators + the two example presets are public and stable:
/// `nornir_spec`/`holger_spec` build specs whose rendered units carry their
/// signature fields, and `Scope::User` drops `User=`/`Group=`.
#[test]
fn public_systemd_presets_and_scope() {
let n = nornir_spec("/usr/local/bin/nornir-server", "0.0.0.0:7878");
assert_eq!(n.unit_file_name(), "nornir.service");
assert!(env_file(&n).contains("NORNIR_SERVER_ADDR=0.0.0.0:7878"));
assert!(
systemd_unit(&n).contains("Environment=PATH=/opt/nornir/bin:/usr/local/bin:/usr/bin:/bin")
);
let h = holger_spec("/opt/holger/bin/holger", "/var/lib/holger");
let sys = systemd_unit_scoped(&h, Scope::System);
assert!(sys.contains("User=_holger"));
let usr = systemd_unit_scoped(&h, Scope::User);
assert!(!usr.contains("User="), "--user unit drops User=");
assert!(usr.contains("WantedBy=default.target"));
// The raw builder is public too.
let s =
SystemdServiceSpec::new("x", "d", "/bin/x", "x", "/tmp").with_restart(RestartPolicy::No);
assert!(systemd_unit(&s).contains("Restart=no"));
}
/// The public **service-user** API — the `useradd --system` replacement nornir
/// consumes instead of shelling `useradd` (dup-audit P1). Exercised through the
/// consumer's view only: `create_system_user_in` against a temp fixture root
/// (no live `/etc`, no root) proves the allocation + idempotence contract holds
/// on the public path.
#[cfg(unix)]
#[test]
fn public_service_user_creation_is_reachable_and_idempotent() {
use skidbladnir::systemd::create_system_user_in;
let dir = tempfile::tempdir().unwrap();
let (passwd, group, shadow) = (
dir.path().join("passwd"),
dir.path().join("group"),
dir.path().join("shadow"),
);
std::fs::write(&passwd, "root:x:0:0::/root:/bin/sh\n").unwrap();
std::fs::write(&group, "root:x:0:\n").unwrap();
std::fs::write(&shadow, "root:!::::::\n").unwrap();
let (uid, gid) =
create_system_user_in(&passwd, &group, &shadow, "nornir", "/opt/nornir").unwrap();
assert_eq!(uid, gid, "system user gets a matching group");
assert!((100..1000).contains(&uid), "allocated a system id");
assert!(
std::fs::read_to_string(&passwd)
.unwrap()
.contains("nornir:x:")
);
assert!(
std::fs::read_to_string(&passwd)
.unwrap()
.contains(":/opt/nornir:/usr/sbin/nologin")
);
// Idempotent: same ids, no duplicate row (the `!exists` guard nornir relied on).
let again = create_system_user_in(&passwd, &group, &shadow, "nornir", "/opt/nornir").unwrap();
assert_eq!(again, (uid, gid));
assert_eq!(
std::fs::read_to_string(&passwd)
.unwrap()
.matches("nornir:x:")
.count(),
1,
"no duplicate user row",
);
// `ensure_system_user` + `chown_recursive` are public symbols on the consumer path.
let _ensure: fn(&str, &str) -> anyhow::Result<(u32, u32)> =
skidbladnir::systemd::ensure_system_user;
let _chown: fn(&std::path::Path, u32, u32) -> anyhow::Result<()> =
skidbladnir::systemd::chown_recursive;
// INCIDENT 2026-07-22: the chown blast-radius guard is PUBLIC so every consumer
// (dwarves, nornir, holger, modgunn) CONSUMES the one implementation instead of
// hand-rolling a denylist twin (L5) — and it refuses `/` and `/home` through the
// public entry point, not just internally.
let _guard: fn(&std::path::Path, u32, Option<&std::path::Path>) -> Result<(), ChownRefused> =
skidbladnir::systemd::guard_chown_target;
let _under: fn(&std::path::Path, &std::path::Path, u32, u32) -> anyhow::Result<()> =
skidbladnir::systemd::chown_recursive_under;
for deny in ["/", "/home", "/usr", "/etc", "/var"] {
let refused =
skidbladnir::systemd::guard_chown_target(std::path::Path::new(deny), 110, None)
.expect_err("the public guard must refuse a system directory");
assert_eq!(refused.reason, ChownRefusal::SystemDir, "{deny}");
assert!(
skidbladnir::systemd::chown_recursive(std::path::Path::new(deny), 110, 110).is_err(),
"the public chown_recursive must refuse {deny}"
);
}
}
/// `Storage` + a stateful spec render a StatefulSet+PVC purely through public API.
#[test]
fn public_k8s_stateful_storage() {
let spec = ServiceSpec::new("db", "db", "/unused")
.image("postgres:16")
.stateful(true)
.storage(Storage::new("8Gi", "/var/lib/postgresql/data"))
.socket(Socket::tcp(5432));
let r = renderer_for(Backend::Kubernetes).unwrap().render(&spec).unwrap();
assert!(
r.file_ending("db-statefulset.yaml")
.unwrap()
.contains("kind: \"StatefulSet\"")
);
assert!(
r.file_ending("db-pvc.yaml")
.unwrap()
.contains("storage: \"8Gi\"")
);
}
/// `config::LakeSpec` (the whole-lake descriptor) is public and derives ordered
/// start waves from declared dependency edges.
#[test]
fn public_lakespec_start_waves() {
let lake = skidbladnir::config::LakeSpec {
version: "20250712".into(),
pki: skidbladnir::pki::PkiSource::SelfGenerated,
products: vec![
skidbladnir::product::ComponentSpec::new("holger", "linux-x86_64/holger", "_holger"),
skidbladnir::product::ComponentSpec::new(
"nornir-server",
"linux-x86_64/nornir-server",
"nornir",
)
.after("holger"),
],
};
let waves = lake.start_waves().unwrap();
assert_eq!(waves[0], vec!["holger"], "root first");
assert_eq!(waves[1], vec!["nornir-server"], "dependent after");
}
// ─────────────────────── THE UNINSTALL LAW, from outside ────────────────────
/// **THE UNINSTALL LAW through the PUBLIC surface only** — an external consumer
/// (nornir · holger · dwarves · korp) declares ONE data root and gets the law for
/// free: `uninstall` erases the data, keeps the ssh key + token, and reports honestly.
///
/// This file cannot reach `pub(crate)` internals, so it pins the exact API those four
/// consumers link against: `uninstall::{DataRoot, DataRootRefusal, UninstallOptions,
/// UninstallReport, StatePlan, EraseMode, EraseOwner, plan_state_erase,
/// execute_state_erase, execute_state_erase_in}` plus
/// `ServiceSpec::{data_root, legacy_data_root, state_dir}`
/// and `Lifecycle::{state_plan, uninstall_with}`. If any of these stop being public
/// (or change shape) this test fails to COMPILE — the honest early warning.
#[test]
fn public_uninstall_law_one_data_root_data_dies_keys_live() {
use skidbladnir::uninstall::{
DataRoot, EraseOwner, StatePlan, UninstallOptions, UninstallReport, execute_state_erase,
};
let d = tempfile::tempdir().unwrap();
let root = d.path().join("var/lib/dwarves");
std::fs::create_dir_all(root.join(".ssh")).unwrap();
std::fs::create_dir_all(root.join("warehouse")).unwrap();
std::fs::write(root.join(".ssh/id_ed25519"), b"DEPLOY KEY").unwrap();
std::fs::write(root.join("token"), b"TOKEN").unwrap();
std::fs::write(root.join("warehouse/facts.parquet"), b"DATA").unwrap();
// A consumer declares ONE root. It is validated where it is written.
let declared = DataRoot::new(&root).expect("a product's own directory is a valid data root");
let spec = ServiceSpec::new("dwarves", "Dwarves symbol forge", "/usr/local/bin/dwarves")
.user("dwarves")
.working_dir("/var/lib/dwarves")
.data_root(&declared)
.legacy_data_root("/home/dwarves/.dwarves");
assert_eq!(
spec.data_root.as_deref(),
Some(declared.path().to_str().unwrap())
);
// The plan the lifecycle would execute — assertable without root, without deleting.
let lc = Lifecycle::new(spec.clone());
let plan = lc.state_plan();
assert_eq!(plan.root.as_deref(), Some(declared.path()));
assert!(plan.erases_anything());
// …and the SAME plan under the other scope, for ANY owning account. The owner,
// 2026-08-03: *"Regardless of user install or system-user install (the owner calls that
// global) — we made them the same."* The WHOLE plan is compared, not just the
// root: a mode or legacy list that moved with the scope would be the same two-
// ladder defect one level down. And the account is varied across a service name, a
// human's name and none at all, because `--run-as <a-human>` is precisely the input
// that made korp resolve a different tree and report a clean box over 113 GB.
for user in [Some("dwarves"), Some("alice"), Some("root"), None] {
let mut s = spec.clone();
if let Some(u) = user {
s = s.user(u);
}
let sys = Lifecycle::new(s.clone().scope(Scope::System)).state_plan();
let usr = Lifecycle::new(s.scope(Scope::User)).state_plan();
assert_eq!(
sys, usr,
"the erase plan must be IDENTICAL in both scopes (user {user:?}) — the data \
root is a declaration, not a function of who owns the process"
);
assert_eq!(
sys.root.as_deref(),
Some(declared.path()),
"…and it must be the DECLARED root, not merely equal to itself (user {user:?})"
);
assert!(
sys.erases_anything(),
"…and it must still erase (user {user:?})"
);
}
// Execute it: DATA DIES, KEYS LIVE.
let mut report = UninstallReport::new("dwarves");
execute_state_erase(
&StatePlan::sweep(&root),
&EraseOwner::uid(skidbladnir::systemd::current_euid()),
UninstallOptions::new(),
&mut report,
);
assert!(!root.join("warehouse").exists(), "the data must be GONE");
assert_eq!(
std::fs::read(root.join(".ssh/id_ed25519")).unwrap(),
b"DEPLOY KEY"
);
assert_eq!(std::fs::read(root.join("token")).unwrap(), b"TOKEN");
assert!(!report.is_noop());
assert!(report.summary().contains("KEPT"), "{}", report.summary());
}
/// A data root that IS a real user's home is refused where it is WRITTEN, through the
/// public constructor — a consumer cannot build a spec that would sweep somebody's
/// home. And the account-removal opt-in is refused without the identity opt-in.
#[test]
fn public_data_root_refuses_a_users_home_and_the_holger_combination() {
use skidbladnir::uninstall::{DataRoot, DataRootRefusal, UninstallOptions};
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
let e = DataRoot::new(&home).expect_err("a real user's home is never a data root");
assert!(matches!(
e.reason,
DataRootRefusal::IsUserHome { .. } | DataRootRefusal::SystemDir
));
assert!(format!("{e}").contains("REFUSING"), "{e}");
DataRoot::new("/").expect_err("the filesystem root is never a data root");
DataRoot::new("relative").expect_err("a relative path is never a data root");
UninstallOptions::new()
.remove_account(true)
.validate()
.expect_err("removing the account that owns the key, without wiping it, is REFUSED");
}