use std::process::{Command, Output};
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_recall-server"));
cmd.args(args).env_clear();
for (k, v) in env {
cmd.env(k, v);
}
cmd.output().expect("recall-server runs")
}
#[test]
fn refuses_to_start_without_a_token() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("x.db");
let out = run(&[], &[("RECALL_DB_PATH", &db.to_string_lossy())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
assert!(stderr.contains("RECALL_TOKEN"), "stderr: {stderr}");
}
#[test]
fn every_way_of_asking_for_the_version_says_the_same() {
let answers: Vec<String> = ["version", "--version", "-V"]
.iter()
.map(|a| {
let out = run(&[a], &[]);
assert_eq!(out.status.code(), Some(0));
String::from_utf8_lossy(&out.stdout).to_string()
})
.collect();
let want = format!("recall-server {} (", env!("CARGO_PKG_VERSION"));
assert!(answers[0].starts_with(&want), "got {:?}", answers[0]);
assert_eq!(answers[0], answers[1]);
assert_eq!(answers[0], answers[2]);
}
#[test]
fn every_way_of_asking_for_help_starts_with_the_version() {
let version = run(&["version"], &[]);
let version = String::from_utf8_lossy(&version.stdout);
let first = version.lines().next().expect("a version line");
for a in ["help", "--help", "-h"] {
let out = run(&[a], &[]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code(), Some(0), "{a}: {stdout}");
assert_eq!(stdout.lines().next(), Some(first), "{a}: {stdout}");
assert!(stdout.contains("Usage: recall-server"), "{a}: {stdout}");
}
}
#[test]
fn the_version_says_which_features_are_built_in() {
let out = run(&["version"], &[]);
let stdout = String::from_utf8_lossy(&out.stdout);
let features = stdout
.lines()
.find_map(|l| l.strip_prefix("features: "))
.unwrap_or_else(|| panic!("no features line in {stdout:?}"));
let has_passkeys = features.split(' ').any(|f| f == "passkeys");
assert_eq!(has_passkeys, cfg!(feature = "passkeys"), "{stdout:?}");
assert_eq!(stdout.lines().count(), 2, "{stdout:?}");
}
#[test]
fn an_unknown_argument_is_a_usage_error() {
let out = run(&["serve"], &[("RECALL_TOKEN", "t")]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("Usage: recall-server"), "stderr: {stderr}");
}
#[test]
fn reset_passkeys_empties_the_passkeys_and_prints_a_bootstrap_code() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("r.db");
let db_str = db.to_string_lossy().to_string();
let out = run(&["reset-passkeys"], &[("RECALL_DB_PATH", &db_str)]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "no database there yet");
assert!(stderr.contains("there is no database at"), "{stderr}");
assert!(!db.exists(), "and it did not make one");
let store = recall_server::Store::open(&db).unwrap();
store
.add_admin_credential_audited(
&recall_server::store::NewAdminCredential {
id: "cred",
user_handle: "u",
name: "phone",
passkey: "{}",
sign_count: 0,
created_at: "2026-09-23T10:00:00.000Z",
},
None,
|seq, at| {
use recall_server::audit::leaf;
leaf::encode(
seq,
at,
leaf::action::PASSKEY_ADD,
&leaf::Actor::Session {
credential_id: "cred",
},
leaf::subject_passkey("cred", "phone", Some(false)),
None,
)
},
)
.unwrap();
let before = store.audit_checkpoint().0;
drop(store);
let out = run(&["reset-passkeys"], &[("RECALL_DB_PATH", &db_str)]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(out.status.code(), Some(0), "{stdout}");
assert!(stdout.contains("Removed 1 passkey"), "{stdout}");
let store = recall_server::Store::open(&db).unwrap();
assert!(!store.has_admin_credentials().unwrap());
assert_eq!(store.audit_checkpoint().0, before + 1);
let entry = store.audit_entries(before, before + 1, usize::MAX).unwrap();
let leaf: serde_json::Value = serde_json::from_slice(&entry[0].leaf).unwrap();
assert_eq!(leaf["action"], "passkey_reset");
assert_eq!(leaf["actor"], serde_json::json!({"kind": "host"}));
assert_eq!(leaf["subject"]["passkeys_removed"], 1);
let code = stdout
.lines()
.map(str::trim)
.find(|l| l.len() == 19 && l.matches('-').count() == 3)
.unwrap_or_else(|| panic!("no code in {stdout}"));
let hash = recall_server::bootstrap::sha256(code).unwrap();
assert_eq!(
store
.check_bootstrap_code(&hash, &recall_server::now())
.unwrap(),
recall_server::store::BootstrapCode::Valid
);
}
#[test]
fn half_a_tls_pair_refuses_to_start() {
for (env, want) in [
(
vec![("RECALL_TOKEN", "t"), ("RECALL_TLS_CERT", "/x/cert.pem")],
"RECALL_TLS_CERT and RECALL_TLS_KEY",
),
(
vec![
("RECALL_TOKEN", "t"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
],
"RECALL_TLS_ACME_DOMAINS and RECALL_TLS_ACME_EMAIL",
),
] {
let out = run(&[], &env);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
assert!(stderr.contains(want), "stderr: {stderr}");
}
}
#[test]
fn both_tls_modes_at_once_refuses_to_start() {
let out = run(
&[],
&[
("RECALL_TOKEN", "t"),
("RECALL_TLS_CERT", "/x/cert.pem"),
("RECALL_TLS_KEY", "/x/key.pem"),
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
assert!(
stderr.contains("two different TLS modes"),
"stderr: {stderr}"
);
}
#[test]
fn trusted_ip_header_with_tls_refuses_to_start() {
for tls_env in [
vec![
("RECALL_TLS_CERT", "/x/cert.pem"),
("RECALL_TLS_KEY", "/x/key.pem"),
],
vec![
("RECALL_TLS_ACME_DOMAINS", "example.com"),
("RECALL_TLS_ACME_EMAIL", "me@example.com"),
],
] {
let mut env = vec![
("RECALL_TOKEN", "t"),
("RECALL_TRUSTED_IP_HEADER", "x-real-ip"),
];
env.extend(tls_env);
let out = run(&[], &env);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
assert!(
stderr.contains("RECALL_TRUSTED_IP_HEADER"),
"stderr: {stderr}"
);
}
}
#[test]
fn tls_required_without_tls_refuses_to_start() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("x.db");
let db = db.to_string_lossy();
let out = run(
&[],
&[
("RECALL_TOKEN", "t"),
("RECALL_DB_PATH", &db),
("RECALL_TLS_REQUIRED", "true"),
("RECALL_TLS_ACME_DOMAINS", ""),
("RECALL_TLS_ACME_EMAIL", ""),
("RECALL_TRUSTED_IP_HEADER", ""),
],
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
assert!(stderr.contains("RECALL_TLS_REQUIRED"), "stderr: {stderr}");
assert!(!stderr.contains("listening"), "stderr: {stderr}");
}