use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::process::Command;
use std::sync::Arc;
use sysknife_daemon::audit_chain::{AuditKey, ChainRow};
use sysknife_daemon::auth::CallerPrincipal;
use sysknife_daemon::transactions::{NewTransaction, TransactionStore};
use sysknife_types::{CallerRole, RiskLevel};
fn fake_socket(dir: &tempfile::TempDir) -> std::path::PathBuf {
dir.path().join("does-not-exist.sock")
}
fn cli() -> Command {
Command::cargo_bin("sysknife").expect("sysknife binary builds")
}
#[test]
fn help_lists_every_top_level_subcommand() {
let output = cli()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("doctor"))
.stdout(predicate::str::contains("history"))
.stdout(predicate::str::contains("audit"))
.stdout(predicate::str::contains("mcp-server"))
.stdout(predicate::str::contains("completions"))
.stdout(predicate::str::contains("daemon work may continue"));
drop(output);
}
#[test]
fn unknown_subcommand_via_clap_usage_error() {
cli()
.arg("--no-such-flag")
.assert()
.code(2)
.stderr(predicate::str::contains("unexpected").or(predicate::str::contains("unknown")));
}
#[test]
fn doctor_fails_loudly_when_daemon_socket_is_unreachable() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.arg("doctor")
.assert()
.failure()
.stderr(predicate::str::contains("daemon"))
.stdout(predicate::str::is_empty());
}
#[test]
fn doctor_json_failure_goes_to_stdout_not_stderr() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.args(["doctor", "--json"])
.assert()
.failure()
.stdout(predicate::str::contains("\"ok\":false"));
}
#[test]
fn history_rejects_unparseable_since_timestamp() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.arg("history")
.arg("--since")
.arg("not-a-timestamp")
.assert()
.code(4);
}
#[test]
fn audit_verify_exits_with_code_2_when_the_key_file_is_missing() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("XDG_DATA_HOME", dir.path())
.env("HOME", dir.path())
.args(["audit", "verify", "--pubkey"])
.arg(dir.path().join("no-such-key.pub"))
.assert()
.code(2);
}
fn audit_fixture_cli(dir: &tempfile::TempDir) -> Command {
let mut command = cli();
command
.env("SYSKNIFE_SOCKET", fake_socket(dir))
.env("SYSKNIFE_DATABASE_PATH", dir.path().join("daemon.sqlite"))
.env_remove("SYSKNIFE_CHECKPOINT_DB")
.env("HOME", dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.env("XDG_DATA_HOME", dir.path())
.args(["audit", "verify", "--pubkey"])
.arg(dir.path().join("audit-key.pub"));
command
}
fn assert_empty_unanchored_store_is_inconclusive(dir: &tempfile::TempDir) {
for json in [false, true] {
let mut command = audit_fixture_cli(dir);
if json {
command.arg("--json");
}
let output = command.output().expect("run audit verify on empty store");
assert_eq!(
output.status.code(),
Some(2),
"json={json}; stdout: {}; stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if json {
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("one JSON report");
assert_eq!(report["status"], "cannot_verify");
assert_eq!(report["chain"]["rows_checked"], 0);
assert_eq!(report["approval_events"]["rows_checked"], 0);
assert_eq!(report["rows_censused"], 0);
assert_eq!(report["audit_anchor"]["configured"], false);
} else {
let text = String::from_utf8_lossy(&output.stdout);
assert!(text.starts_with("CANNOT VERIFY:"), "{text}");
assert!(text.contains("empty"), "{text}");
assert!(!text.starts_with("OK:"), "{text}");
}
}
}
#[test]
fn audit_verify_fresh_empty_unanchored_store_is_inconclusive() {
let dir = tempfile::tempdir().expect("create audit fixture");
let key =
AuditKey::load_or_generate(&dir.path().join("audit-key")).expect("generate audit key");
let store = TransactionStore::open_with_key(dir.path().join("daemon.sqlite"), Arc::new(key))
.expect("create fresh empty store");
assert!(store.fetch_chain_rows().unwrap().is_empty());
assert!(store.fetch_event_rows().unwrap().is_empty());
drop(store);
assert_empty_unanchored_store_is_inconclusive(&dir);
}
#[test]
fn audit_verify_erased_recreated_empty_unanchored_store_is_inconclusive() {
let dir = tempfile::tempdir().expect("create audit fixture");
let db_path = dir.path().join("daemon.sqlite");
let key = AuditKey::load_or_generate(&dir.path().join("audit-key"))
.expect("generate retained audit key");
let store = TransactionStore::open_with_key(&db_path, Arc::new(key.clone()))
.expect("create populated audit store");
store
.record(NewTransaction {
request_id: "erased-store".to_string(),
request_hash: "erased-store-hash".to_string(),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Synthetic audit fixture; no action is executed".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
})
.expect("record signed fixture row");
assert_eq!(store.fetch_chain_rows().unwrap().len(), 1);
drop(store);
let output = audit_fixture_cli(&dir).arg("--json").output().unwrap();
assert_eq!(output.status.code(), Some(0));
let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["status"], "intact");
assert_eq!(report["chain"]["rows_checked"], 1);
std::fs::remove_file(&db_path).expect("erase isolated fixture database");
let store = TransactionStore::open_with_key(&db_path, Arc::new(key))
.expect("recreate readable empty store");
assert!(store.fetch_chain_rows().unwrap().is_empty());
assert!(store.fetch_event_rows().unwrap().is_empty());
drop(store);
assert_empty_unanchored_store_is_inconclusive(&dir);
}
#[test]
fn audit_verify_exit_code_prefers_broken_chain_to_inconclusive_anchor() {
let dir = tempfile::tempdir().expect("create CLI fixture directory");
let db_path = dir.path().join("daemon.sqlite");
let key =
AuditKey::load_or_generate(&dir.path().join("audit-key")).expect("generate audit key");
let store = TransactionStore::open_with_key(&db_path, Arc::new(key.clone()))
.expect("create audit store");
store
.record(NewTransaction {
request_id: "broken-chain-exit-code".to_string(),
request_hash: "broken-chain-exit-code-hash".to_string(),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Upgrade the system".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
})
.expect("record audit row");
drop(store);
let wrong_key_path = dir.path().join("wrong-audit-key");
let wrong_key = AuditKey::load_or_generate(&wrong_key_path).expect("generate mismatched key");
assert_ne!(key.verifying_key_hex(), wrong_key.verifying_key_hex());
cli()
.env("SYSKNIFE_DATABASE_PATH", &db_path)
.env("SYSKNIFE_CHECKPOINT_DB", "not-a-postgres-url")
.env("HOME", dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.args(["audit", "verify", "--pubkey"])
.arg(dir.path().join("wrong-audit-key.pub"))
.assert()
.code(1);
}
#[tokio::test]
#[ignore = "live Postgres; set SYSKNIFE_TEST_POSTGRES_URL and run with --ignored"]
async fn audit_verify_exits_with_code_1_when_anchor_is_truncated() {
use sqlx_postgres::{PgConnectOptions, PgPoolOptions};
use std::str::FromStr;
use sysknife_daemon::audit_chain::resolve_audit_key_path;
use sysknife_daemon::checkpoint_sink::{CheckpointSink, PostgresCheckpointSink};
let required = std::env::var_os("SYSKNIFE_REQUIRE_POSTGRES").is_some();
let url = std::env::var("SYSKNIFE_TEST_POSTGRES_URL")
.ok()
.filter(|url| !url.is_empty());
let Some(url) = url else {
assert!(
!required,
"SYSKNIFE_REQUIRE_POSTGRES is set but SYSKNIFE_TEST_POSTGRES_URL is unset"
);
return;
};
assert!(
url.contains("sysknife_test"),
"refusing destructive integration test against a non-test database"
);
const SCHEMA: &str = "cli_smoke_anchor_exit";
let admin = PgPoolOptions::new()
.max_connections(1)
.connect_with(PgConnectOptions::from_str(&url).expect("valid test URL"))
.await
.expect("connect for schema setup");
for statement in [
format!("DROP SCHEMA IF EXISTS {SCHEMA} CASCADE"),
format!("CREATE SCHEMA {SCHEMA}"),
] {
sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(statement))
.execute(&admin)
.await
.expect("prepare isolated schema");
}
let separator = if url.contains('?') { '&' } else { '?' };
let scoped_url = format!("{url}{separator}options=-csearch_path%3D{SCHEMA}");
let dir = tempfile::tempdir().expect("create CLI fixture directory");
let db_path = dir.path().join("daemon.sqlite");
let key =
AuditKey::load_or_generate(&resolve_audit_key_path(&db_path)).expect("generate audit key");
let store = TransactionStore::open_with_key(&db_path, Arc::new(key.clone()))
.expect("create audit store");
store
.record(NewTransaction {
request_id: "anchor-exit-code".to_string(),
request_hash: "anchor-exit-code-hash".to_string(),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Upgrade the system".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
})
.expect("record intact local chain");
drop(store);
let sink = PostgresCheckpointSink::connect(&scoped_url)
.await
.expect("connect checkpoint sink");
sink.append(&key.sign_checkpoint(2, "anchored-tip-beyond-local-chain", "2026-09-02T12:00:00Z"))
.await
.expect("store checkpoint beyond the local tip");
drop(sink);
let output = cli()
.env("SYSKNIFE_DATABASE_PATH", &db_path)
.env("SYSKNIFE_CHECKPOINT_DB", &scoped_url)
.env("HOME", dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.args(["audit", "verify", "--json"])
.output()
.expect("run audit verify");
sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(format!(
"DROP SCHEMA {SCHEMA} CASCADE"
)))
.execute(&admin)
.await
.expect("drop isolated schema");
assert_eq!(
output.status.code(),
Some(1),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let doc: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("--json emits one document");
assert_eq!(
doc["status"],
"broken",
"the --json verdict must reflect the anchor: {}",
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn audit_export_emits_the_stored_rows_as_parseable_json() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("daemon.sqlite");
let key = Arc::new(AuditKey::load_or_generate(&dir.path().join("audit-key")).unwrap());
let store = TransactionStore::open_with_key(&db_path, key).unwrap();
for request_id in ["req-1", "req-2"] {
store
.record(NewTransaction {
request_id: request_id.to_string(),
request_hash: format!("hash-{request_id}"),
action_name: "UpdateSystem".to_string(),
risk_level: RiskLevel::High,
summary: "Upgrade the system".to_string(),
warnings: vec![],
caller_role: CallerRole::Dev,
caller_principal: CallerPrincipal::Uid(1000),
})
.unwrap();
}
let stored = store.fetch_chain_rows().unwrap();
drop(store);
let output = cli()
.env("SYSKNIFE_DATABASE_PATH", &db_path)
.env("HOME", dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.args(["audit", "export", "--limit", "1"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let exported: Vec<ChainRow> = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0], stored[0]);
assert_eq!(
exported[0].chain_hash.as_bytes(),
stored[0].chain_hash.as_bytes()
);
}
#[test]
fn audit_export_rejects_an_invalid_since_timestamp() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_DATABASE_PATH", dir.path().join("unused.sqlite"))
.env("HOME", dir.path())
.env("XDG_CONFIG_HOME", dir.path())
.args(["audit", "export", "--since", "not-a-timestamp"])
.assert()
.code(4)
.stderr(predicate::str::contains("--since"));
}
#[test]
fn audit_checkpoint_without_a_database_is_a_configuration_error() {
let dir = tempfile::tempdir().unwrap();
cli()
.env_clear()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("XDG_CONFIG_HOME", dir.path())
.env("XDG_DATA_HOME", dir.path())
.env("HOME", dir.path())
.args(["audit", "checkpoint"])
.assert()
.code(4)
.stderr(predicate::str::contains("config/daemon error"))
.stderr(predicate::str::contains(
"no checkpoint database configured",
))
.stdout(predicate::str::is_empty());
let db_path = dir.path().join("system-daemon.sqlite");
let key_path = sysknife_daemon::audit_chain::resolve_audit_key_path(&db_path);
let key = AuditKey::load_or_generate(&key_path).expect("create selected audit key");
drop(
TransactionStore::open_with_key(&db_path, Arc::new(key))
.expect("create selected audit database"),
);
cli()
.env_clear()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("SYSKNIFE_DATABASE_PATH", &db_path)
.env("SYSKNIFE_CHECKPOINT_DB", "not-a-postgres-url")
.env("XDG_CONFIG_HOME", dir.path())
.env("XDG_DATA_HOME", dir.path())
.env("HOME", dir.path())
.args(["audit", "checkpoint"])
.assert()
.code(2)
.stderr(predicate::str::contains(
"connecting to checkpoint database failed",
));
}
#[test]
fn a_blank_intent_returns_the_planning_failure_exit_code() {
let dir = tempfile::tempdir().unwrap();
cli()
.env_clear()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("XDG_CONFIG_HOME", dir.path())
.env("XDG_DATA_HOME", dir.path())
.env("HOME", dir.path())
.env("SYSKNIFE_LLM_PROVIDER", "ollama")
.env("SYSKNIFE_LLM_MODEL", "test-model")
.args(["--dry-run", " "])
.assert()
.code(3)
.stderr(predicate::str::contains("planning failed"))
.stdout(predicate::str::is_empty());
}
#[test]
fn a_planning_timeout_does_not_claim_an_execution_failure() {
let dir = tempfile::tempdir().unwrap();
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
listener.set_nonblocking(true).unwrap();
let provider_url = format!("http://{}", listener.local_addr().unwrap());
let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
let (stop_tx, stop_rx) = std::sync::mpsc::channel();
let accept_thread = std::thread::spawn(move || {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
loop {
match listener.accept() {
Ok((stream, _)) => {
accepted_tx
.send(Ok(stream))
.expect("send accepted provider connection");
return;
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if stop_rx.try_recv().is_ok() {
return;
}
if std::time::Instant::now() >= deadline {
let _ = accepted_tx.send(Err(
"the CLI did not reach the local provider before the test deadline"
.to_string(),
));
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
Err(error) => {
let _ = accepted_tx.send(Err(error.to_string()));
return;
}
}
}
});
let mut command = assert_cmd::Command::cargo_bin("sysknife").expect("sysknife binary builds");
command
.timeout(std::time::Duration::from_secs(10))
.env_clear()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("XDG_CONFIG_HOME", dir.path())
.env("XDG_DATA_HOME", dir.path())
.env("HOME", dir.path())
.env("SYSKNIFE_LLM_PROVIDER", "ollama")
.env("SYSKNIFE_LLM_MODEL", "test-model")
.env("SYSKNIFE_OLLAMA_URL", provider_url)
.args(["--dry-run", "--json", "--timeout", "5", "check disk usage"])
.assert()
.code(2)
.stderr(predicate::str::contains("operation timed out after 5s"))
.stderr(predicate::str::contains("execution failed").not())
.stdout(predicate::str::is_empty());
let provider_connection = match accepted_rx.recv_timeout(std::time::Duration::from_secs(3)) {
Ok(Ok(stream)) => stream,
Ok(Err(error)) => {
let _ = stop_tx.send(());
accept_thread
.join()
.expect("provider listener thread should exit");
panic!("the CLI did not reach the local provider: {error}");
}
Err(error) => {
let _ = stop_tx.send(());
accept_thread
.join()
.expect("provider listener thread should exit");
panic!("the CLI did not reach the local provider: {error}");
}
};
accept_thread
.join()
.expect("provider listener thread should exit");
drop(provider_connection);
}
#[test]
fn completions_subcommand_emits_a_shell_script() {
cli()
.arg("completions")
.arg("bash")
.assert()
.success()
.stdout(predicate::str::contains("_sysknife").or(predicate::str::starts_with("#")));
}
#[test]
fn timeout_flag_bounds_a_daemon_that_accepts_but_never_replies() {
use std::os::unix::net::UnixListener;
let dir = tempfile::tempdir().unwrap();
let socket_path = dir.path().join("silent.sock");
let listener = UnixListener::bind(&socket_path).expect("bind test socket");
std::thread::spawn(move || {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept() {
held.push(stream);
}
});
let started = std::time::Instant::now();
cli()
.env("SYSKNIFE_SOCKET", &socket_path)
.args(["--timeout", "1", "doctor"])
.assert()
.code(2)
.stderr(predicate::str::contains("operation timed out after 1s"))
.stderr(predicate::str::contains("execution failed").not());
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(30),
"--timeout 1 must give up promptly against a silent daemon, took {elapsed:?}"
);
}
#[test]
#[cfg(unix)]
fn audit_export_distinguishes_unreadable_from_absent() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store_dir = dir.path().join("state");
std::fs::create_dir_all(&store_dir).unwrap();
let db = store_dir.join("daemon.sqlite");
std::fs::write(&db, b"not really a database").unwrap();
std::fs::set_permissions(&store_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let unreadable = std::fs::metadata(&db).is_err();
let assertion = if unreadable {
Some(
cli()
.env("SYSKNIFE_DATABASE_PATH", &db)
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.args(["audit", "export"])
.assert()
.failure(),
)
} else {
None
};
std::fs::set_permissions(&store_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
match assertion {
Some(a) => {
a.stderr(
predicate::str::contains("not readable")
.or(predicate::str::contains("Permission denied")),
);
}
None => {
eprintln!("skipped: this user stats through mode 000 (probably root)");
}
}
}
#[test]
fn the_skip_approval_flag_alone_refuses_and_names_the_second_key() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env_remove("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT")
.args(["--dangerously-skip-approval", "doctor"])
.assert()
.code(1)
.stderr(
predicate::str::contains("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT")
.and(predicate::str::contains("as root")),
);
}
#[test]
fn a_non_exact_consent_value_does_not_arm_the_flag() {
let dir = tempfile::tempdir().unwrap();
for value in ["0", "false", "true", "yes", ""] {
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT", value)
.args(["--dangerously-skip-approval", "doctor"])
.assert()
.failure()
.stderr(predicate::str::contains(
"SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT",
));
}
}
#[test]
fn both_keys_print_the_banner_and_let_the_command_run() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT", "1")
.args(["--dangerously-skip-approval", "doctor"])
.assert()
.stderr(
predicate::str::contains("UNATTENDED MODE")
.and(predicate::str::contains("approval gate is OFF"))
.and(predicate::str::contains("polkit allowlist"))
.and(predicate::str::contains("is not set to").not()),
);
}
#[test]
fn the_consent_variable_alone_is_inert() {
let dir = tempfile::tempdir().unwrap();
cli()
.env("SYSKNIFE_SOCKET", fake_socket(&dir))
.env("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT", "1")
.args(["doctor"])
.assert()
.stderr(predicate::str::contains("UNATTENDED MODE").not());
}
#[test]
fn help_says_what_the_skip_approval_flag_does_and_does_not_do() {
cli()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("--dangerously-skip-approval"));
cli().arg("--help").assert().success().stdout(
predicate::str::contains("SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT")
.or(predicate::str::contains("Requires")),
);
}