holger-cli/tests/against_a_holger.rs
//! **Every command group against a REAL holger** (lane B3): the server runs in this test
//! process (`holger-server-lib`, the same `Holger::start` a box runs), with the console's
//! auth shape: the box's static bearer (Admin), a second static bearer that is a Reader,
//! and the passkey plane. Each test drives `holger-cli` exactly as a shell would
//! (`holger_cli::run_args`), and asserts what the SERVER decided: the client decides
//! nothing.
//!
//! "Answered" below means the call reached the server and the server answered by its own
//! rules (an answer, or a coded refusal), never that the client failed to ask.
use std::io::Write as _;
use std::path::{Path, PathBuf};
use holger_cli::CliError;
use holger_server_lib::promote::intake::{ArtifactState, TransitionRequest};
use holger_server_lib::{read_ron_config, wire_holger, ArtifactId, Holger};
use nornir_auth_webauthn::softkey::SoftKey;
use traits::test_support::hold_port;
const CONSOLE: &str = "c0nsole-bearer-0123456789abcdefghij";
const READER: &str = "r3ader-bearer-0123456789abcdefghijk";
const RP_ID: &str = "holger.cli.test";
const ORIGIN: &str = "https://holger.cli.test";
const WHEEL: &str = "demo_pkg-1.0.0-py3-none-any.whl";
struct Running {
dir: tempfile::TempDir,
_holger: Holger,
at: String,
base: String,
tokens: PathBuf,
}
impl Running {
/// `holger-cli --at <this server> [--token-file <who>] <args…>`, with its own home.
async fn cli(&self, who: Option<&str>, args: &[&str]) -> Result<String, CliError> {
let mut argv: Vec<String> = vec!["holger-cli".into(), "--at".into(), self.at.clone()];
if let Some(w) = who {
argv.push("--token-file".into());
argv.push(self.tokens.join(w).display().to_string());
}
argv.extend(args.iter().map(|a| a.to_string()));
let home = self.dir.path().join("cli-home").display().to_string();
holger_cli::run_args(argv, move |k| {
(k == "HOLGER_CLI_HOME").then(|| home.clone())
})
.await
}
fn path(&self, rel: &str) -> PathBuf {
self.dir.path().join(rel)
}
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for b in data {
crc ^= *b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
}
}
!crc
}
/// A small, well-formed wheel: a stored-only zip with its dist-info, MIT licensed.
fn wheel(project: &str, version: &str) -> Vec<u8> {
licensed_wheel(project, version, "MIT")
}
/// [`wheel`] under the SPDX licence expression `licence`.
fn licensed_wheel(project: &str, version: &str, licence: &str) -> Vec<u8> {
let module = project.replace('-', "_");
let di = format!("{module}-{version}.dist-info");
let members: Vec<(String, Vec<u8>)> = vec![
(format!("{module}/__init__.py"), format!("__version__ = \"{version}\"\n").into_bytes()),
(format!("{di}/METADATA"), format!("Metadata-Version: 2.1\nName: {project}\nVersion: {version}\nLicense-Expression: {licence}\n").into_bytes()),
(format!("{di}/WHEEL"), b"Wheel-Version: 1.0\nGenerator: holger-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n".to_vec()),
(format!("{di}/RECORD"), Vec::new()),
];
let (mut out, mut central) = (Vec::new(), Vec::new());
for (name, body) in &members {
let crc = crc32(body);
let offset = out.len() as u32;
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(body);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&(body.len() as u32).to_le_bytes());
central.extend_from_slice(&(body.len() as u32).to_le_bytes());
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
central.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd_offset = out.len() as u32;
let n = members.len() as u16;
out.extend_from_slice(¢ral);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&n.to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
/// The box's config, with `extra` RON at its end (an `intel:` block, or nothing) and
/// `promotion` RON inside its `promotion:` block (a licence policy, or nothing).
fn config_with(
d: &Path,
grpc: u16,
http: u16,
extra: &str,
promotion: &str,
required: &str,
) -> String {
let pki =
bench_scenarios::secure_update::mint_attestation_pki(&d.join("pki"), "cli-scan").unwrap();
let repo = |name: &str| {
format!(
r#"(ron_name: "{name}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")),
ron_data_dir: Some("{d}/{name}")),"#,
d = d.display()
)
};
format!(
r#"(
repositories: [ {files} {incoming} {quarantine} {approved} {production} ],
exposed_endpoints: [ (ron_name: "door", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")) ],
storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{d}") ],
audit: (dir: "{d}/audit", enabled: true),
attestation: Some((
trust_roots: ["{root}"],
signer_key: Some("{key}"),
signer_cert: Some("{cert}"),
signer_alg: Some("ecdsa-p256"),
)),
promotion: Some((
ladder_name: "intake",
{promotion}
stages: [
(name: "incoming", repo: "incoming"),
(name: "quarantine", repo: "quarantine"),
(name: "approved", repo: "approved"),
(name: "production", repo: "production"),
],
intake: Some((rules: (sandbox: off),
incoming: "incoming", quarantine: "quarantine",
approved: "approved", production: "production",
// The suite drives the CLI's verbs against the raw `files` repository. A box whose
// policy requires a scan refuses every write there, on every door (P19, SEC-REVIEW
// 2026-11-01 P2/P3; proved in server-lib's every_write_door_goes_through_intake), so
// only the Security page's test boots one that requires a scan ([`start_required`]).
scan: Some((required: [{required}])),
)),
)),
auth: Some((
methods: [
StaticToken(subject: "console", token: Some("{CONSOLE}")),
StaticToken(subject: "rita", token: Some("{READER}")),
Passkey(rp_id: "{RP_ID}", rp_origin: "{ORIGIN}"),
],
roles: {{"console": Admin, "rita": Reader}},
// Reads name their reader: the audit's download rows are the person's (`audit downloads`).
require_auth_for_reads: true,
group_roles: {{"holger-admins": Admin, "holger-writers": Writer, "holger-readers": Reader}},
)),
{extra}
)"#,
files = repo("files"),
incoming = repo("incoming"),
quarantine = repo("quarantine"),
approved = repo("approved"),
production = repo("production"),
d = d.display(),
root = pki.root_der.display(),
key = pki.key_pkcs8.display(),
cert = pki.cert_der.display(),
)
}
/// The wheel walked through the intake chain to AVAILABLE with signed scan evidence, before
/// the server starts (as `holger-server` would have, over days).
fn walk_the_wheel(cfg: &Path) {
let mut h = read_ron_config(cfg).unwrap();
h.instantiate_backends().unwrap();
let audit = h.audit_log().unwrap();
let chain = h.intake_chain(audit).unwrap().expect("an intake chain");
let incoming = h.get_repo("incoming").unwrap().clone();
let id = ArtifactId {
namespace: None,
name: WHEEL.into(),
version: String::new(),
};
let bytes = wheel("demo-pkg", "1.0.0");
chain
.ingest("ida", "test", "incoming", &id, &bytes, || {
incoming.put(&id, &bytes)
})
.unwrap();
for (to, who) in [
(ArtifactState::Scanned, "ida"),
(ArtifactState::PolicyChecked, "ida"),
(ArtifactState::Approved, "bob"),
(ArtifactState::Sealed, "bob"),
(ArtifactState::Promoted, "bob"),
(ArtifactState::Available, "bob"),
] {
chain
.advance(TransitionRequest::new(id.clone(), to, who))
.unwrap_or_else(|e| panic!("→ {to}: {e}"));
}
}
fn start(with_wheel: bool) -> Running {
start_with(with_wheel, |_| String::new())
}
/// [`start`] with `extra(data dir)` RON in the config (what it names is written there first).
fn start_with(with_wheel: bool, extra: impl Fn(&Path) -> String) -> Running {
start_full(with_wheel, extra, "")
}
/// [`start`] on a box whose policy requires the vulnerability and SBOM scans (its raw `files`
/// repository then refuses writes).
fn start_required(with_wheel: bool) -> Running {
start_scanned(with_wheel, |_| String::new(), "", "vulnerability, sbom")
}
/// [`start_with`] with `promotion` RON in the config's `promotion:` block.
fn start_full(with_wheel: bool, extra: impl Fn(&Path) -> String, promotion: &str) -> Running {
start_scanned(with_wheel, extra, promotion, "")
}
/// [`start_full`] with `required`, the scan kinds the policy requires.
fn start_scanned(
with_wheel: bool,
extra: impl Fn(&Path) -> String,
promotion: &str,
required: &str,
) -> Running {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut last = String::new();
for _ in 0..8 {
let dir = tempfile::tempdir().unwrap();
let (grpc, http) = (hold_port(), hold_port());
let cfg = dir.path().join("holger.ron");
std::fs::write(
&cfg,
config_with(
dir.path(),
grpc,
http,
&extra(dir.path()),
promotion,
required,
),
)
.unwrap();
if with_wheel {
walk_the_wheel(&cfg);
}
let tokens = dir.path().join("tokens");
std::fs::create_dir_all(&tokens).unwrap();
std::fs::write(tokens.join("console"), CONSOLE).unwrap();
std::fs::write(tokens.join("rita"), READER).unwrap();
let started = (|| -> Result<Holger, String> {
let mut h: Holger = read_ron_config(&cfg).map_err(|e| format!("config: {e}"))?;
h.instantiate_backends()
.map_err(|e| format!("backends: {e}"))?;
wire_holger(&mut h).map_err(|e| format!("wire: {e}"))?;
h.start().map_err(|e| format!("start: {e}"))?;
Ok(h)
})();
match started {
Ok(h) => {
return Running {
dir,
_holger: h,
at: format!("127.0.0.1:{grpc}"),
base: format!("http://127.0.0.1:{http}"),
tokens,
}
}
Err(e) => last = e,
}
}
panic!("server never started; last error: {last}");
}
async fn up(r: &Running) {
for _ in 0..250 {
if r.cli(None, &["health"]).await.is_ok() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("holger never answered at {}", r.at);
}
/// The call reached the server and the server answered by its own rules.
fn answered(what: &str, r: &Result<String, CliError>) {
match r {
Ok(_) | Err(CliError::Server(_)) => {}
Err(e) => panic!("{what}: the client did not get an answer: {e}"),
}
}
/// The server refused with `code`.
fn refused(what: &str, r: Result<String, CliError>, code: &str) {
match r {
Err(e) => assert_eq!(e.code().as_deref(), Some(code), "{what}: {e}"),
Ok(out) => panic!("{what}: expected {code}, got an answer:\n{out}"),
}
}
fn denied(what: &str, r: Result<String, CliError>) {
match r {
Err(CliError::Server(s)) => assert!(
matches!(
s.code(),
tonic::Code::PermissionDenied | tonic::Code::Unauthenticated
),
"{what}: {s:?}"
),
other => panic!("{what}: expected a refusal by the server, got {other:?}"),
}
}
// ─── the admin view ──────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn the_admin_views_read_what_the_console_reads() {
let r = start(false);
up(&r).await;
let health = r.cli(None, &["health"]).await.unwrap();
assert!(health.contains("status: \"ok\""), "{health}");
let me = r.cli(Some("console"), &["whoami"]).await.unwrap();
assert!(
me.contains("subject: \"console\"") && me.contains("authenticated: true"),
"{me}"
);
let anon = r.cli(None, &["whoami"]).await.unwrap();
assert!(anon.contains("authenticated: false"), "{anon}");
let repos = r.cli(Some("console"), &["repos"]).await.unwrap();
for name in ["files", "incoming", "production"] {
assert!(
repos.contains(&format!("name: \"{name}\"")),
"{name} in {repos}"
);
}
answered("profile", &r.cli(Some("console"), &["profile"]).await);
let snap = r
.cli(Some("console"), &["snapshot", "--log-lines", "5"])
.await;
assert!(snap.is_ok(), "the admin snapshot: {snap:?}");
denied(
"a reader's snapshot",
r.cli(Some("rita"), &["snapshot"]).await,
);
let board = r.cli(Some("console"), &["board"]).await.unwrap();
assert!(board.contains("boxes"), "{board}");
denied("a reader's board", r.cli(Some("rita"), &["board"]).await);
}
// ─── artifacts, archive, search, SBOM, properties, usage ─────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn an_artifact_goes_up_comes_back_and_is_found() {
let r = start(false);
up(&r).await;
let file = r.path("hello.txt");
std::fs::File::create(&file)
.unwrap()
.write_all(b"hello holger\n")
.unwrap();
let f = file.display().to_string();
let put = r
.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"hello.txt",
"--file",
&f,
],
)
.await;
assert!(put.is_ok(), "{put:?}");
refused(
"a file that is not there",
r.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"x",
"--file",
"/no/such/file",
],
)
.await,
"holger-cli-2",
);
denied(
"a reader's upload",
r.cli(
Some("rita"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"r.txt",
"--file",
&f,
],
)
.await,
);
let list = r
.cli(Some("console"), &["artifacts", "list", "-r", "files"])
.await
.unwrap();
assert!(list.contains("hello.txt"), "{list}");
let out = r.path("back.txt");
r.cli(
Some("rita"),
&[
"artifacts",
"fetch",
"-r",
"files",
"--name",
"hello.txt",
"--out",
&out.display().to_string(),
],
)
.await
.unwrap();
assert_eq!(
std::fs::read(&out).unwrap(),
b"hello holger\n",
"the same bytes back"
);
let found = r
.cli(Some("console"), &["search", "--name", "hello"])
.await
.unwrap();
assert!(found.contains("hello.txt"), "{found}");
refused(
"a property match without =",
r.cli(Some("console"), &["search", "--property", "team"])
.await,
"holger-cli-1",
);
answered(
"archive files",
&r.cli(Some("console"), &["archive", "files", "-r", "files"])
.await,
);
answered(
"archive info",
&r.cli(Some("console"), &["archive", "info", "-r", "files"])
.await,
);
let usage = r.cli(Some("console"), &["usage", "-r", "files"]).await;
answered("usage", &usage);
let sbom = r.path("sbom.json");
std::fs::write(
&sbom,
br#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","name":"hello","packages":[]}"#,
)
.unwrap();
let s = sbom.display().to_string();
answered(
"sbom attach",
&r.cli(
Some("console"),
&[
"sbom",
"attach",
"-r",
"files",
"--name",
"hello.txt",
"--file",
&s,
],
)
.await,
);
answered(
"sbom fetch",
&r.cli(
Some("console"),
&["sbom", "fetch", "-r", "files", "--name", "hello.txt"],
)
.await,
);
let set = r
.cli(
Some("console"),
&[
"property",
"set",
"-r",
"files",
"--name",
"hello.txt",
"--key",
"team",
"--value",
"core",
],
)
.await;
answered("property set", &set);
let got = r
.cli(
Some("console"),
&["property", "get", "-r", "files", "--name", "hello.txt"],
)
.await;
answered("property get", &got);
if set.is_ok() {
assert!(
got.unwrap().contains("core"),
"the property set is the property read"
);
}
}
// ─── a large artifact goes up streamed ───────────────────────────────────────
/// ★ **A file bigger than one message goes up streamed** (stinky 14's client half):
/// `artifacts put` sends `RepositoryService.PutArtifactStream` from the file as it reads it,
/// so an artifact larger than the client's per-message bound ([`holger_cli::run::MAX_MESSAGE`],
/// which a unary `PutArtifact` can never carry) is stored whole: the server says it took
/// every byte, streamed, its audit row counts them, and its backend holds the same bytes.
#[tokio::test(flavor = "multi_thread")]
async fn a_file_bigger_than_one_message_goes_up_streamed() {
let r = start(false);
up(&r).await;
let big = r.path("big.bin");
let len = holger_cli::run::MAX_MESSAGE as u64 + 3 * 1024 * 1024 + 7;
{
// A marked head and tail around a sparse middle: cheap on disk, checkable back.
let mut f = std::fs::File::create(&big).unwrap();
f.write_all(b"HEAD-of-the-big-artifact").unwrap();
f.set_len(len - 4).unwrap();
use std::io::Seek as _;
f.seek(std::io::SeekFrom::End(0)).unwrap();
f.write_all(b"TAIL").unwrap();
}
assert_eq!(std::fs::metadata(&big).unwrap().len(), len);
let b = big.display().to_string();
denied(
"a reader's streamed upload",
r.cli(
Some("rita"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"big.bin",
"--file",
&b,
],
)
.await,
);
let said = r
.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"big.bin",
"--file",
&b,
],
)
.await
.unwrap();
assert!(
said.contains(&format!("{len} bytes, streamed")),
"the server took every byte, streamed: {said}"
);
let events = holger_server_lib::audit::FileAuditLog::read_dir(r.path("audit"))
.expect("the audit log reads back");
assert!(
events.iter().any(|e| e.ident == "console"
&& e.status == 200
&& e.bytes == len
&& e.artifact.contains("big.bin")),
"the upload is audited with its byte count"
);
let id = ArtifactId {
namespace: None,
name: "big.bin".into(),
version: String::new(),
};
let stored = r
._holger
.get_repo("files")
.unwrap()
.fetch(&id)
.unwrap()
.expect("the backend holds it");
assert_eq!(stored.len() as u64, len);
assert!(
stored.starts_with(b"HEAD-of-the-big-artifact") && stored.ends_with(b"TAIL"),
"the same bytes"
);
}
// ─── the intake chain, promotion, audit ──────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn the_intake_chain_and_its_trail_are_read_and_driven() {
let r = start(true);
up(&r).await;
let state = r
.cli(Some("console"), &["intake", "state", "--name", WHEEL])
.await
.unwrap();
assert!(state.to_ascii_lowercase().contains("available"), "{state}");
answered(
"intake evidence",
&r.cli(Some("console"), &["intake", "evidence", "--name", WHEEL])
.await,
);
answered(
"intake list",
&r.cli(
Some("console"),
&["intake", "list", "--queue", "quarantine"],
)
.await,
);
let sha = {
use std::fmt::Write as _;
let bytes = wheel("demo-pkg", "1.0.0");
let d = holger_update_bundle::digest::sha256_hex(&bytes);
let mut s = String::new();
let _ = write!(s, "{d}");
s
};
let trail = r
.cli(Some("console"), &["audit", "trail", "--sha256", &sha])
.await;
answered("audit trail", &trail);
answered(
"intake advance (block)",
&r.cli(
Some("console"),
&[
"intake",
"advance",
"--name",
"nothing-here.whl",
"--verb",
"block",
"--reason",
"test",
],
)
.await,
);
answered(
"promote",
&r.cli(
Some("console"),
&[
"promote", "--from", "files", "--to", "files", "--name", "nothing",
],
)
.await,
);
}
// ─── a denied licence, end to end ────────────────────────────────────────────
const GPL_WHEEL: &str = "gpl_pkg-1.0.0-py3-none-any.whl";
const MIT_WHEEL: &str = "mit_pkg-1.0.0-py3-none-any.whl";
/// The sha256 of `bytes`, lowercase hex.
fn sha_of(bytes: &[u8]) -> String {
holger_update_bundle::digest::sha256_hex(bytes)
}
/// The audit log as text, every record of it.
fn audit_text(r: &Running) -> String {
let mut audit = String::new();
for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
if let Ok(t) = std::fs::read(e.path()) {
audit.push_str(&String::from_utf8_lossy(&t));
}
}
audit
}
/// One section of a `holger-cli security` answer (`key: "<key>"` up to the next section).
fn section<'a>(said: &'a str, key: &str) -> &'a str {
let at = said
.find(&format!("key: \"{key}\""))
.unwrap_or_else(|| panic!("no {key} section in {said}"));
let rest = &said[at + 1..];
let end = rest.find("key: \"").map_or(rest.len(), |e| e + 1);
&said[at..at + end]
}
/// The `state` of the row named `name` in `text` (a section of the Security page).
fn row_state<'a>(text: &'a str, name: &str) -> &'a str {
let at = text
.find(&format!("name: \"{name}\""))
.unwrap_or_else(|| panic!("no row {name} in {text}"));
let s = &text[at..];
let v = &s[s.find("state: \"").unwrap() + 8..];
&v[..v.find('"').unwrap()]
}
/// ★ **A denied licence is refused through the real intake** (S6, 697d7530, end to end): the
/// box's `promotion.deny_licenses` names GPL-3.0; a GPL-3.0 wheel goes up `artifacts put`
/// into incoming (the gRPC door, the intake chain's ingest), and the scan step (`intake
/// advance`) refuses it by the licence, naming GPL-3.0. An MIT wheel beside it passes the
/// same step. The refusal is in the artifact's audit trail (`audit trail`), on the Security
/// page (`security`: held or refused, and the recent events), and production serves nothing
/// of it.
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_licence_is_refused_through_the_intake_and_shows_on_the_security_page() {
let r = start_full(false, |_| String::new(), r#"deny_licenses: ["GPL-3.0"],"#);
up(&r).await;
let (gpl, mit) = (
licensed_wheel("gpl-pkg", "1.0.0", "GPL-3.0"),
licensed_wheel("mit-pkg", "1.0.0", "MIT"),
);
for (name, bytes) in [(GPL_WHEEL, &gpl), (MIT_WHEEL, &mit)] {
let f = r.path(name);
std::fs::write(&f, bytes).unwrap();
let said = r
.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"incoming",
"--name",
name,
"--file",
&f.display().to_string(),
],
)
.await;
let said = said.unwrap_or_else(|e| panic!("{name} is ingested: {e}"));
assert!(said.contains("INGESTED"), "{name}: {said}");
}
// The scan step: the MIT wheel passes, the GPL-3.0 one is refused by its licence.
let passed = r
.cli(Some("console"), &["intake", "advance", "--name", MIT_WHEEL])
.await
.unwrap();
assert!(
passed.contains("SCANNED"),
"the MIT wheel is scanned: {passed}"
);
let refused = r
.cli(Some("console"), &["intake", "advance", "--name", GPL_WHEEL])
.await;
let why = match refused {
Err(CliError::Server(s)) => s.message().to_owned(),
other => panic!("the GPL-3.0 wheel is refused by the server: {other:?}"),
};
assert!(
why.contains("GPL-3.0"),
"the refusal names the licence: {why}"
);
let state = r
.cli(Some("console"), &["intake", "state", "--name", GPL_WHEEL])
.await
.unwrap();
assert!(
!state.contains("SCANNED") && !state.contains("AVAILABLE"),
"it did not move: {state}"
);
// In the artifact's trail, by its sha256.
let trail = r
.cli(
Some("console"),
&["audit", "trail", "--sha256", &sha_of(&gpl)],
)
.await
.unwrap();
assert!(
trail.contains("GPL-3.0"),
"the trail holds the refusal: {trail}"
);
assert!(audit_text(&r).contains("GPL-3.0"), "the audit log holds it");
// On the Security page: held or refused, by name, and among the recent events.
let page = r.cli(Some("console"), &["security"]).await.unwrap();
let held = section(&page, "held");
assert!(
held.contains(GPL_WHEEL) && held.contains("GPL-3.0"),
"held or refused names it and why: {held}"
);
assert_eq!(row_state(held, GPL_WHEEL), "refused", "{held}");
assert!(
!held.contains(MIT_WHEEL),
"the MIT wheel is not held: {held}"
);
let events = section(&page, "events");
assert!(
events.contains("GPL-3.0") && events.contains("refused"),
"a recent security event: {events}"
);
// Never served.
let out = r.path("gpl-back.whl").display().to_string();
assert!(
r.cli(
Some("console"),
&[
"artifacts",
"fetch",
"-r",
"production",
"--name",
GPL_WHEEL,
"--out",
&out
]
)
.await
.is_err(),
"production serves nothing of it"
);
}
// ─── the Security page, against a real holger ────────────────────────────────
/// ★ **`holger-cli security` reads the server's Security page** (lane HOL-SEC): every
/// section, in the page's order, and the chain's counts are the chain's own state (the walked
/// wheel served, a new upload in, one scanned); a waiting package is in the approvals; the
/// required scanners are named; a Reader and an anonymous caller are refused.
#[tokio::test(flavor = "multi_thread")]
async fn the_security_page_counts_what_the_chain_holds() {
let r = start_required(true);
up(&r).await;
for (name, version) in [("one-pkg", "1.0.0"), ("two-pkg", "2.0.0")] {
let file = format!("{}-{version}-py3-none-any.whl", name.replace('-', "_"));
let f = r.path(&file);
std::fs::write(&f, wheel(name, version)).unwrap();
r.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"incoming",
"--name",
&file,
"--file",
&f.display().to_string(),
],
)
.await
.unwrap();
}
for _ in 0..2 {
r.cli(
Some("console"),
&[
"intake",
"advance",
"--name",
"two_pkg-2.0.0-py3-none-any.whl",
],
)
.await
.unwrap();
}
let page = r.cli(Some("console"), &["security"]).await.unwrap();
let keys = [
"chain",
"sources",
"scanners",
"approvals",
"held",
"access",
"sync",
"events",
];
let at: Vec<usize> = keys
.iter()
.map(|k| {
page.find(&format!("key: \"{k}\""))
.unwrap_or_else(|| panic!("no {k} section: {page}"))
})
.collect();
assert!(
at.windows(2).all(|w| w[0] < w[1]),
"the sections in the page's order: {at:?}"
);
// The chain's counts are the chain's state, as the server holds it.
let chain = r._holger.intake().expect("an intake chain");
let n = |states: &[&str]| {
chain
.list()
.into_iter()
.filter(|(_, s)| states.contains(&s.state.name()))
.count()
.to_string()
};
let steps = section(&page, "chain");
assert_eq!(row_state(steps, "In"), n(&["UNTRUSTED", "INGESTED"]));
assert_eq!(
row_state(steps, "In"),
"1",
"one upload waits at the door: {steps}"
);
assert_eq!(row_state(steps, "Policy checked"), "1", "{steps}");
assert_eq!(row_state(steps, "Served"), n(&["AVAILABLE"]));
assert_eq!(row_state(steps, "Served"), "1", "the walked wheel: {steps}");
let waiting = section(&page, "approvals");
assert!(
waiting.contains("two_pkg-2.0.0-py3-none-any.whl"),
"{waiting}"
);
let scanners = section(&page, "scanners");
assert!(
scanners.contains("required"),
"the required scanners are named: {scanners}"
);
assert!(
page.contains("1 package waits for approval"),
"the headline counts it: {page}"
);
denied(
"a reader's Security page",
r.cli(Some("rita"), &["security"]).await,
);
denied(
"an anonymous Security page",
r.cli(None, &["security"]).await,
);
}
// ─── what did this person download ───────────────────────────────────────────
/// ★ **"What did this person download"** (7492403d): `holger-cli audit downloads` asks
/// `AuditService.QueryDownloads`, and the answer is the server's audit log: rita's fetch is
/// rita's row, by repository it is everybody's, a time range narrows, and only an Auditor or
/// an Admin reads it.
#[tokio::test(flavor = "multi_thread")]
async fn what_a_person_downloaded_is_read_from_the_audit_log() {
let r = start(false);
up(&r).await;
let file = r.path("hello.txt");
std::fs::write(&file, b"hello holger\n").unwrap();
let f = file.display().to_string();
r.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"hello.txt",
"--file",
&f,
],
)
.await
.unwrap();
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as i64;
for who in ["rita", "console"] {
let out = r.path(&format!("back-{who}.txt"));
r.cli(
Some(who),
&[
"artifacts",
"fetch",
"-r",
"files",
"--name",
"hello.txt",
"--out",
&out.display().to_string(),
],
)
.await
.unwrap();
}
let ritas = r
.cli(Some("console"), &["audit", "downloads", "--who", "rita"])
.await
.unwrap();
assert!(
ritas.contains("ident: \"rita\"")
&& ritas.contains("repo: \"files\"")
&& ritas.contains("hello.txt"),
"{ritas}"
);
assert!(
!ritas.contains("ident: \"console\""),
"rita's rows only: {ritas}"
);
assert!(ritas.contains("matched: 1"), "{ritas}");
let files = r
.cli(
Some("console"),
&["audit", "downloads", "--repository", "files"],
)
.await
.unwrap();
assert!(
files.contains("ident: \"rita\"")
&& files.contains("ident: \"console\"")
&& files.contains("matched: 2"),
"everybody's from files: {files}"
);
let none = r
.cli(
Some("console"),
&["audit", "downloads", "--repository", "production"],
)
.await
.unwrap();
assert!(none.contains("matched: 0"), "{none}");
let since = before.to_string();
let after = r
.cli(
Some("console"),
&["audit", "downloads", "--who", "rita", "--since", &since],
)
.await
.unwrap();
assert!(after.contains("matched: 1"), "{after}");
let until = r
.cli(
Some("console"),
&["audit", "downloads", "--who", "rita", "--until", &since],
)
.await
.unwrap();
assert!(
until.contains("matched: 0"),
"nothing before the fetch: {until}"
);
let one = r
.cli(
Some("console"),
&[
"audit",
"downloads",
"--repository",
"files",
"--limit",
"1",
],
)
.await
.unwrap();
assert!(
one.contains("matched: 2") && one.contains("truncated: true"),
"{one}"
);
refused(
"a time that is not a number",
r.cli(
Some("console"),
&["audit", "downloads", "--since", "yesterday"],
)
.await,
"holger-cli-1",
);
denied(
"a reader's downloads",
r.cli(Some("rita"), &["audit", "downloads", "--who", "rita"])
.await,
);
}
/// ★ **The audit search, counters and export** (lane HOL-AUDIT): `audit query` filters by
/// actor and action, `audit aggregate` counts per repository and action, `audit export`
/// writes an Arrow IPC file of the rows, `audit verify` walks the chain; a reader is refused.
#[tokio::test(flavor = "multi_thread")]
async fn the_audit_log_is_searched_counted_and_exported() {
let r = start(false);
up(&r).await;
let file = r.path("hello.txt");
std::fs::write(&file, b"hello holger\n").unwrap();
let f = file.display().to_string();
r.cli(
Some("console"),
&[
"artifacts",
"put",
"-r",
"files",
"--name",
"hello.txt",
"--file",
&f,
],
)
.await
.unwrap();
for who in ["rita", "rita", "console"] {
let out = r.path(&format!("back-{who}.txt"));
r.cli(
Some(who),
&[
"artifacts",
"fetch",
"-r",
"files",
"--name",
"hello.txt",
"--out",
&out.display().to_string(),
],
)
.await
.unwrap();
}
let q = r
.cli(
Some("console"),
&["audit", "query", "--who", "rita", "--action", "download"],
)
.await
.unwrap();
assert!(
q.contains("matched: 2")
&& q.contains("ident: \"rita\"")
&& !q.contains("ident: \"console\""),
"{q}"
);
let agg = r
.cli(
Some("console"),
&[
"audit",
"aggregate",
"--by",
"repo,action",
"--action",
"download",
"--ok-only",
],
)
.await
.unwrap();
assert!(
agg.contains("repo: \"files\"")
&& agg.contains("action: \"download\"")
&& agg.contains("count: 3"),
"{agg}"
);
let out = r.path("audit.arrow");
let said = r
.cli(
Some("console"),
&[
"audit",
"export",
"--action",
"download",
"--out",
&out.display().to_string(),
],
)
.await
.unwrap();
assert!(said.contains("3 rows"), "{said}");
let back =
holger_server_lib::audit::arrow_ipc::read_arrow_file_bytes(&std::fs::read(&out).unwrap())
.unwrap();
assert_eq!(back.len(), 3);
let v = r.cli(Some("console"), &["audit", "verify"]).await.unwrap();
assert!(v.contains("intact: true"), "{v}");
refused(
"an action the log never writes",
r.cli(Some("console"), &["audit", "query", "--action", "teleport"])
.await,
"holger-server-3",
);
denied(
"a reader's audit query",
r.cli(Some("rita"), &["audit", "query"]).await,
);
}
// ─── the intel import door ───────────────────────────────────────────────────
const INTEL_FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../intel/tests/fixtures");
struct IntelSigner {
cert: PathBuf,
key: PathBuf,
fingerprint: String,
}
/// The intel bundle's three signers (the review, the owner, the release), the same in
/// every attempt: minted once, under `dir`.
fn intel_signers(dir: &Path) -> [IntelSigner; 3] {
use rcgen::{CertificateParams, DnType, KeyPair, PKCS_ED25519};
std::fs::create_dir_all(dir).unwrap();
["security-reviewer", "system-owner", "release"].map(|cn| {
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let mut params = CertificateParams::new(vec![]).unwrap();
params.distinguished_name.push(DnType::CommonName, cn);
let cert = params.self_signed(&key).unwrap();
let (c, k) = (dir.join(format!("{cn}.crt")), dir.join(format!("{cn}.key")));
std::fs::write(&c, cert.der()).unwrap();
std::fs::write(&k, key.serialize_der()).unwrap();
IntelSigner {
cert: c,
key: k,
fingerprint: holger_update_bundle::digest::sha256_hex(cert.der()),
}
})
}
fn intel_policy(s: &[IntelSigner; 3]) -> holger_update_bundle::policy::TrustPolicy {
use holger_update_bundle::policy::{
ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy,
};
TrustPolicy {
schema_version: holger_update_bundle::schema::POLICY_SCHEMA.into(),
trust_mode: Some(TrustMode::PinnedSigners),
trusted_roots: vec![],
certificate_validity: CertificateValidity::Enforce,
approved_signers: vec![
ApprovedSigner {
fingerprint: s[0].fingerprint.clone(),
subject: None,
roles: vec!["security-reviewer".into()],
},
ApprovedSigner {
fingerprint: s[1].fingerprint.clone(),
subject: None,
roles: vec!["system-owner".into()],
},
],
required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
minimum_distinct_approvers: 2,
trusted_release_signers: vec![s[2].fingerprint.clone()],
release_signer_must_be_distinct: true,
maximum_bundle_age_secs: None,
require_expiry: true,
on_expired_metadata: Some(ExpiredPosture::Refuse),
require_sbom: false,
require_vex: false,
require_scan: false,
allowed_products: vec!["holger.intel/v1".into()],
allowed_models: vec![],
}
}
/// A signed intel bundle (OSV PYSEC-2018-28 for `requests` < 2.20.0) at `sequence`.
fn intel_bundle(dir: &Path, s: &[IntelSigner; 3], sequence: u64) -> PathBuf {
use holger_server_lib::holger_intel as intel;
let feeds = dir.join("feeds");
std::fs::create_dir_all(&feeds).unwrap();
for (from, to) in [
("PYSEC-2018-28.json", "osv-PyPI.json"),
("nvd-recent.json", "nvd-recent.json"),
("kev.json", "kev.json"),
] {
std::fs::copy(Path::new(INTEL_FIXTURES).join(from), feeds.join(to)).unwrap();
}
let now = intel::chrono::Utc::now();
let raw: Vec<intel::RawFeed> = intel::FeedSpec::from_dir(&feeds)
.unwrap()
.into_iter()
.map(|f| intel::RawFeed::read_file(f).unwrap())
.collect();
let set = intel::feeds::normalize(&raw, now).unwrap();
let out = dir.join(format!("intel-{sequence}.holger.znippy"));
let files = |x: &IntelSigner| intel::SignerFiles::read(&x.cert, &x.key).unwrap();
intel::build_bundle(
&set,
intel::BuildOptions {
sequence,
created_at: now,
expires_at: Some(now + intel::chrono::Duration::days(30)),
supplier: intel::DEFAULT_SUPPLIER.into(),
producer: "the holger-cli test".into(),
policy: intel_policy(s),
approvals: vec![
("security-reviewer".into(), files(&s[0])),
("system-owner".into(), files(&s[1])),
],
release: files(&s[2]),
out: out.clone(),
},
)
.unwrap();
out
}
/// ★ **`AdminService.ImportIntelBundle` has a caller**: `holger-cli intel import` streams a
/// signed intel bundle to a REAL holger, which verifies it, sets the anti-rollback floor only
/// with an admin's first-import authorization, audits it first and makes it the live advisory database; a reader is refused,
/// the same bundle again is idempotent, and a file that is not a bundle is refused by its code.
#[tokio::test(flavor = "multi_thread")]
async fn an_intel_bundle_is_imported_through_the_admin_door() {
let keys = tempfile::tempdir().unwrap();
let signers = intel_signers(keys.path());
let policy = intel_policy(&signers).to_ron().unwrap();
let r = start_with(false, |d| {
let p = d.join("intel-trust.ron");
std::fs::write(&p, &policy).unwrap();
format!(
r#"intel: Some((db_dir: "{}/intel-db", offline: true, trust_policy: Some("{}"))),"#,
d.display(),
p.display()
)
});
up(&r).await;
let bundle = intel_bundle(keys.path(), &signers, 100);
let b = bundle.display().to_string();
denied(
"a reader's intel import",
r.cli(Some("rita"), &["intel", "import", "--file", &b])
.await,
);
// No floor yet and no admin's first-import authorization: refused, and nothing goes live.
refused(
"a floorless import",
r.cli(Some("console"), &["intel", "import", "--file", &b])
.await,
"holger-import-9",
);
assert!(
!r.path("intel-db/installed.json").exists(),
"nothing went live"
);
// The admin's action on the secure side (lane HOL-CDS, NCSC gap 2): this vendor, product
// and sequence may establish the floor. The thin client has no `--first-import`; it has
// the admin's verb (HOL-NCSC), over `BundleService.AllowFirstImport`. A reader is refused.
let (vendor, product) = (
holger_server_lib::holger_intel::DEFAULT_SUPPLIER,
holger_server_lib::holger_intel::INTEL_PRODUCT,
);
let grant = [
"bundle",
"allow-first-import",
"--vendor",
vendor,
"--product",
product,
"--sequence",
"100",
];
denied(
"a reader's first-import authorization",
r.cli(Some("rita"), &grant).await,
);
let said = r.cli(Some("console"), &grant).await.unwrap();
assert!(
said.contains("sequence: 100")
&& said.contains(&format!("vendor: \"{vendor}\""))
&& said.contains("by: \"console\""),
"{said}"
);
let said = r
.cli(Some("console"), &["intel", "import", "--file", &b])
.await
.unwrap();
assert!(
said.contains("outcome: \"new\"") && said.contains("sequence: 100"),
"{said}"
);
assert!(
r.path("intel-db/installed.json").exists(),
"the advisory database is live"
);
refused(
"an authorization once the floor stands",
r.cli(Some("console"), &grant).await,
"holger-import-11",
);
let again = r
.cli(Some("console"), &["intel", "import", "--file", &b])
.await
.unwrap();
assert!(again.contains("idempotent-reimport"), "{again}");
let mut audit = String::new();
for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
if let Ok(t) = std::fs::read(e.path()) {
audit.push_str(&String::from_utf8_lossy(&t));
}
}
assert!(
audit.contains("intel-import") && audit.contains("seq=100"),
"audited: {audit}"
);
// HOL-NCSC (NCSC P1 item 5): the reader's refused import and the floorless one left rows.
assert!(
audit
.lines()
.any(|l| l.contains("rita") && l.contains("refused") && l.contains("[holger-duty-")),
"the unauthorised importer's refusal is audited: {audit}"
);
assert!(
audit
.lines()
.any(|l| l.contains("intel import refused") && l.contains("[holger-import-9]")),
"the floorless import's refusal is audited: {audit}"
);
let junk = r.path("junk.znippy");
std::fs::write(&junk, b"not a bundle").unwrap();
match r
.cli(
Some("console"),
&["intel", "import", "--file", &junk.display().to_string()],
)
.await
{
Err(e) => {
assert!(
e.code().is_some_and(
|c| c.starts_with("holger-import-") || c.starts_with("holger-bundle-")
),
"{e}"
)
}
Ok(o) => panic!("a junk bundle imported? {o}"),
}
refused(
"an import of no file",
r.cli(
Some("console"),
&["intel", "import", "--file", "/no/such.znippy"],
)
.await,
"holger-cli-2",
);
}
// ─── jobs, admission, the verbs this build does not carry ────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn the_queue_and_the_admission_book_are_read_and_a_missing_verb_says_holger_cli_3() {
let r = start(false);
up(&r).await;
let jobs = r.cli(Some("console"), &["jobs"]).await;
assert!(jobs.is_ok(), "jobs: {jobs:?}");
denied("a reader's jobs", r.cli(Some("rita"), &["jobs"]).await);
answered(
"admission usage",
&r.cli(Some("console"), &["admission", "usage"]).await,
);
answered(
"admission plan",
&r.cli(Some("console"), &["admission", "plan", "--consumer", "ci"])
.await,
);
// No `migration` and no `mirror` in this server's build: the server says UNIMPLEMENTED.
refused(
"migration discover",
r.cli(
Some("console"),
&[
"migration",
"discover",
"--kind",
"nexus",
"--base-url",
"http://nexus.invalid",
],
)
.await,
"holger-cli-3",
);
refused(
"migration watch",
r.cli(Some("console"), &["migration", "watch", "--job", "j"])
.await,
"holger-cli-3",
);
refused(
"migration cancel",
r.cli(Some("console"), &["migration", "cancel", "--job", "j"])
.await,
"holger-cli-3",
);
refused(
"replicate",
r.cli(
Some("console"),
&["replicate", "--from", "files", "--to", "production"],
)
.await,
"holger-cli-3",
);
}
// ─── the person's pages: room, account, tokens ───────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn the_persons_pages_are_the_servers_answers() {
let r = start(false);
up(&r).await;
answered(
"room show",
&r.cli(Some("console"), &["room", "show", "-r", "files"])
.await,
);
answered(
"room private-estate",
&r.cli(Some("console"), &["room", "private-estate"]).await,
);
answered(
"account mine",
&r.cli(Some("rita"), &["account", "mine"]).await,
);
answered(
"account create",
&r.cli(
Some("console"),
&["account", "create", "--name", "team-x", "--format", "raw"],
)
.await,
);
answered(
"account grant",
&r.cli(
Some("console"),
&[
"account", "grant", "-r", "files", "--person", "rita", "--access", "read",
],
)
.await,
);
let list = r.cli(Some("console"), &["token", "list"]).await;
assert!(list.is_ok(), "the console lists every token: {list:?}");
// A token is a PERSON's act: the box's bearer mints none (holger-token-6), as in the console.
refused(
"the console's mint",
r.cli(Some("console"), &["token", "mint", "--repository", "files"])
.await,
"holger-token-6",
);
answered(
"token revoke",
&r.cli(
Some("console"),
&["token", "revoke", "--id", "no-such-token"],
)
.await,
);
}
// ─── login and logout ────────────────────────────────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn login_keeps_the_credential_0600_for_that_server_and_logout_forgets_it() {
let r = start(false);
up(&r).await;
let said = r.cli(Some("console"), &["login"]).await.unwrap();
assert!(
said.contains("console") && !said.contains(CONSOLE),
"logged in, the credential never printed: {said}"
);
let stored = r.dir.path().join("cli-home").join("credentials");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&stored).unwrap().permissions().mode() & 0o777,
0o600
);
}
let me = r.cli(None, &["whoami"]).await.unwrap();
assert!(
me.contains("subject: \"console\""),
"the stored credential is used: {me}"
);
// A credential the server does not accept is not stored.
let bad = r.path("bad-token");
std::fs::write(&bad, "not-a-credential").unwrap();
let mut argv = vec![
"holger-cli".to_string(),
"--at".into(),
r.at.clone(),
"--token-file".into(),
bad.display().to_string(),
"login".into(),
];
let home = r.dir.path().join("cli-home").display().to_string();
let env = move |k: &str| (k == "HOLGER_CLI_HOME").then(|| home.clone());
assert!(
holger_cli::run_args(argv.clone(), env.clone())
.await
.is_err(),
"a credential the server refuses is not stored"
);
argv.pop();
argv.push("logout".into());
holger_cli::run_args(argv, env).await.unwrap();
let anon = r.cli(None, &["whoami"]).await.unwrap();
assert!(anon.contains("authenticated: false"), "forgotten: {anon}");
}
// ─── the two new doors: bundle export, first key ─────────────────────────────
#[tokio::test(flavor = "multi_thread")]
async fn the_bundle_export_door_streams_the_candidate_admin_only_and_audited() {
let r = start(true);
up(&r).await;
let out = r.path("export.cand");
let o = out.display().to_string();
let args = [
"bundle",
"export",
"--out",
&o,
"--bundle-id",
"airgap-cli-1",
"--sequence",
"1",
"--supplier",
"online",
"--product",
"pypi",
"--release-version",
"1",
];
denied("a reader's export", r.cli(Some("rita"), &args).await);
denied("an anonymous export", r.cli(None, &args).await);
assert!(!out.exists(), "a refused export writes nothing");
let said = r.cli(Some("console"), &args).await.unwrap();
assert!(
said.contains("EXPORTED airgap-cli-1 seq 1") && said.contains(WHEEL),
"{said}"
);
let members = holger_update_bundle::prepare::read_members_dir(&out).unwrap();
assert!(
members.keys().any(|k| k.ends_with(WHEEL)),
"the wheel is a payload: {:?}",
members.keys().collect::<Vec<_>>()
);
assert!(!members.is_empty());
let wheel_bytes = wheel("demo-pkg", "1.0.0");
assert!(
members
.iter()
.any(|(k, v)| k.ends_with(WHEEL) && *v == wheel_bytes),
"the payload is the wheel's bytes"
);
// Audited first: the record names the bundle and the actor.
let mut audit = String::new();
for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
if let Ok(t) = std::fs::read(e.path()) {
audit.push_str(&String::from_utf8_lossy(&t));
}
}
assert!(
audit.contains("airgap export airgap-cli-1 seq 1"),
"the export is in the audit log"
);
// An existing directory is never overwritten.
refused(
"the same directory again",
r.cli(Some("console"), &args).await,
"holger-cli-1",
);
// An export of nothing that passes is refused by its own code.
let none = r.path("none.cand").display().to_string();
let r2 = r
.cli(
Some("console"),
&[
"bundle",
"export",
"--out",
&none,
"--bundle-id",
"b",
"--sequence",
"2",
"--supplier",
"s",
"--product",
"p",
"--release-version",
"1",
"--artifact",
"nothing-like-this",
],
)
.await;
match r2 {
Err(e) => assert!(
e.code().is_some_and(|c| c.starts_with("holger-airgap-")),
"{e}"
),
Ok(o) => panic!("exported nothing? {o}"),
}
// The import half takes a file and refuses one that is not a bundle by its own code.
let junk = r.path("junk.bundle");
std::fs::write(&junk, b"not a bundle").unwrap();
let j = junk.display().to_string();
let imported = r
.cli(Some("console"), &["bundle", "import", "--file", &j])
.await;
match imported {
Err(CliError::Server(s)) => assert!(s.message().contains("holger-"), "{s:?}"),
other => panic!("a junk bundle: {other:?}"),
}
refused(
"an import of no file",
r.cli(
Some("console"),
&["bundle", "import", "--file", "/no/such.bundle"],
)
.await,
"holger-cli-2",
);
}
async fn post(
c: &reqwest::Client,
base: &str,
path: &str,
body: serde_json::Value,
) -> (u16, serde_json::Value) {
let r = c
.post(format!("{base}{path}"))
.json(&body)
.send()
.await
.expect("the door answers");
let status = r.status().as_u16();
let text = r.text().await.unwrap_or_default();
(
status,
serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)),
)
}
#[tokio::test(flavor = "multi_thread")]
async fn the_first_key_door_opens_once_for_the_box_and_shuts_when_an_admin_key_exists() {
let r = start(false);
up(&r).await;
denied(
"a reader's first key",
r.cli(Some("rita"), &["first-key"]).await,
);
let first = r
.cli(Some("console"), &["first-key", "--display", "anna"])
.await
.unwrap();
let secret_1 = first.lines().next().unwrap().trim().to_owned();
assert!(
first.contains("/login?invite=") && first.contains("holger-admins"),
"{first}"
);
// A second ask revokes the first: one link open at a time.
let second = r
.cli(Some("console"), &["first-key", "--display", "anna"])
.await
.unwrap();
let secret = second.lines().next().unwrap().trim().to_owned();
assert_ne!(secret, secret_1);
let c = reqwest::Client::builder().build().unwrap();
let (status, _) = post(
&c,
&r.base,
"/-/auth/passkey/register/start",
serde_json::json!({ "invite": secret_1, "label": "old" }),
)
.await;
assert_eq!(status, 401, "the first link is spent by the second");
// The person opens the link and touches their key: the box's first admin key.
let mut key = SoftKey::new(0x61, b"cred-anna-first");
let (status, start) = post(
&c,
&r.base,
"/-/auth/passkey/register/start",
serde_json::json!({ "invite": secret, "label": "laptop" }),
)
.await;
assert_eq!(status, 200, "{start}");
let ceremony = start["ceremony_id"].as_str().unwrap().to_owned();
let challenge = start["publicKey"]["challenge"].as_str().unwrap();
let (client_data, attestation) = key.register(RP_ID, ORIGIN, challenge, true);
let (status, done) = post(
&c,
&r.base,
"/-/auth/passkey/register/finish",
serde_json::json!({ "ceremony_id": ceremony, "id": key.credential_id_b64(), "response": { "clientDataJSON": client_data, "attestationObject": attestation } }),
)
.await;
assert_eq!(status, 200, "the first admin key is enrolled: {done}");
// Now the door is shut, by its own code, for the box's bearer too.
refused(
"the first key, again",
r.cli(Some("console"), &["first-key"]).await,
"holger-auth-5",
);
let mut audit = String::new();
for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
if let Ok(t) = std::fs::read(e.path()) {
audit.push_str(&String::from_utf8_lossy(&t));
}
}
assert!(
audit.contains("first-key invite minted") && audit.contains("first-key invite refused"),
"both are audited"
);
}
/// One passkey registration over HTTP (start, the soft key's answer, finish), as `bearer`
/// when given: (start status, finish status and body). A refused start stops there.
async fn enrol(
c: &reqwest::Client,
base: &str,
key: &mut SoftKey,
bearer: Option<&str>,
body: serde_json::Value,
) -> (u16, u16, serde_json::Value) {
let send = |path: &str, body: serde_json::Value| {
let mut rq = c.post(format!("{base}{path}")).json(&body);
if let Some(b) = bearer {
rq = rq.bearer_auth(b);
}
rq
};
let r = send("/-/auth/passkey/register/start", body)
.send()
.await
.expect("the door answers");
let started = r.status().as_u16();
let start: serde_json::Value = r.json().await.unwrap_or_default();
if started != 200 {
return (started, 0, start);
}
finish(c, base, key, bearer, &start)
.await
.map_or_else(|(s, b)| (started, s, b), |b| (started, 200, b))
}
/// The finish half of [`enrol`] for an opened ceremony.
async fn finish(
c: &reqwest::Client,
base: &str,
key: &mut SoftKey,
bearer: Option<&str>,
start: &serde_json::Value,
) -> Result<serde_json::Value, (u16, serde_json::Value)> {
let (client_data, attestation) = key.register(
RP_ID,
ORIGIN,
start["publicKey"]["challenge"].as_str().unwrap(),
true,
);
let mut rq = c
.post(format!("{base}/-/auth/passkey/register/finish"))
.json(&serde_json::json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": client_data, "attestationObject": attestation } }));
if let Some(b) = bearer {
rq = rq.bearer_auth(b);
}
let r = rq.send().await.expect("the door answers");
let status = r.status().as_u16();
let body: serde_json::Value = r.json().await.unwrap_or_default();
if status == 200 {
Ok(body)
} else {
Err((status, body))
}
}
/// ★ **RA1-2: a first-key link is dead once the first admin enrolled another way.** The box's
/// bearer mints the first-key link and the person opens it (a ceremony in flight); meanwhile
/// the bearer enrols an admin passkey itself. The link's ceremony is refused at its finish by
/// the door's own sentence, the link is spent (a new start is the dead-invite 401), and the box
/// keeps one admin key: the bootstrap credential made no second admin around the break-glass
/// retirement.
#[tokio::test(flavor = "multi_thread")]
async fn a_first_key_link_is_dead_once_an_admin_enrolled_another_way() {
let r = start(false);
up(&r).await;
let minted = r
.cli(Some("console"), &["first-key", "--display", "anna"])
.await
.unwrap();
let secret = minted.lines().next().unwrap().trim().to_owned();
let c = reqwest::Client::builder().build().unwrap();
// The person opens the link: a ceremony in flight.
let (status, opened) = post(
&c,
&r.base,
"/-/auth/passkey/register/start",
serde_json::json!({ "invite": secret, "label": "laptop" }),
)
.await;
assert_eq!(
status, 200,
"no admin yet: the link opens a ceremony: {opened}"
);
// The box's bearer enrols an admin passkey by the admin path.
let mut boss = SoftKey::new(0x71, b"cred-boss-other-way");
let (s1, s2, done) = enrol(
&c,
&r.base,
&mut boss,
Some(CONSOLE),
serde_json::json!({ "display": "boss", "role": "admin" }),
)
.await;
assert_eq!(
(s1, s2),
(200, 200),
"the bearer enrols the first admin another way: {done}"
);
// The link's ceremony finishes after it: refused by name, and the link is spent.
let mut anna = SoftKey::new(0x72, b"cred-anna-late");
let late = finish(&c, &r.base, &mut anna, None, &opened).await;
let (status, body) = late.expect_err("a first-key ceremony after the first admin is refused");
assert_eq!(status, 409, "{body}");
assert!(
body.to_string()
.contains(holger_server_lib::passkey::FIRST_KEY_ENROLLED),
"refused by the door's own sentence: {body}"
);
let (status, again) = post(
&c,
&r.base,
"/-/auth/passkey/register/start",
serde_json::json!({ "invite": secret, "label": "laptop" }),
)
.await;
assert_eq!(status, 401, "the link is spent: {again}");
// The door stays shut for the bearer, by its own code.
refused(
"the first key, after the admin",
r.cli(Some("console"), &["first-key"]).await,
"holger-auth-5",
);
}