use assert_cmd::Command;
use chrono::{Duration, Utc};
use serde_json::{json, Value};
use tempfile::tempdir;
use tsafe_core::{age_crypto, team};
fn tsafe() -> Command {
Command::cargo_bin("tsafe").unwrap()
}
fn help_stdout(args: &[&str]) -> String {
let output = tsafe().args(args).output().unwrap();
assert!(
output.status.success(),
"stderr:\n{}\nstdout:\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
String::from_utf8_lossy(&output.stdout).to_string()
}
#[test]
fn mobile_help_exposes_operator_and_release_boundaries() {
let help = help_stdout(&["mobile", "--help"]);
assert!(help.contains("public enrollment envelopes only"));
assert!(help.contains("release readiness"));
assert!(help.contains("store-console acceptance"));
assert!(help.contains("device custody"));
assert!(help.contains("platform runtime evidence"));
}
#[test]
fn mobile_enroll_help_exposes_source_local_handoff_boundary() {
let enroll_help = help_stdout(&["mobile", "enroll", "--help"]);
let start_help = help_stdout(&["mobile", "enroll", "start", "--help"]);
let accept_help = help_stdout(&["mobile", "enroll", "accept", "--help"]);
assert!(enroll_help.contains("source-local"));
assert!(enroll_help.contains("operator-held evidence"));
assert!(start_help.contains("public enrollment payload"));
assert!(start_help.contains("no secret values"));
assert!(accept_help.contains("operator age identity"));
assert!(accept_help.contains("handoff_required"));
}
fn start_enrollment(dir: &tempfile::TempDir, profile: &str) -> Value {
start_enrollment_with_vault_path(dir, profile, None)
}
fn start_enrollment_with_vault_path(
dir: &tempfile::TempDir,
profile: &str,
vault_path: Option<&std::path::Path>,
) -> Value {
let mut args = vec![
"--profile".to_string(),
profile.to_string(),
"mobile".to_string(),
"enroll".to_string(),
"start".to_string(),
"--repo".to_string(),
"example-org/example-vault".to_string(),
"--json".to_string(),
];
if let Some(vault_path) = vault_path {
args.push("--vault-path".to_string());
args.push(vault_path.display().to_string());
}
let output = tsafe()
.args(args)
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.env("TOP_SECRET_CANARY", "do-not-emit-this")
.output()
.unwrap();
assert!(
output.status.success(),
"stderr:\n{}\nstdout:\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.contains("do-not-emit-this"));
assert!(!stdout.to_ascii_lowercase().contains("private_key"));
assert!(!stdout.to_ascii_lowercase().contains("password"));
serde_json::from_slice(&output.stdout).unwrap()
}
fn response_for(start: &Value, profile: &str, nonce: &str) -> (Value, String, String) {
let (identity, recipient) = age_crypto::generate_identity();
let now = Utc::now();
let response = json!({
"schema": "tsafe/mobile/enrollment-response/v1",
"version": "1.0",
"status": "accepted",
"session_id": start["session_id"],
"created_at": now.to_rfc3339(),
"expires_at": (now + Duration::minutes(10)).to_rfc3339(),
"nonce": nonce,
"repo": start["repo"],
"profile": profile,
"mobile_recipient": recipient,
"device_label": "Test phone"
});
(response, recipient, identity)
}
#[test]
fn mobile_enroll_start_json_is_public_and_safe() {
let dir = tempdir().unwrap();
let start = start_enrollment(&dir, "mobile-start-safe");
assert_eq!(start["schema"], "tsafe/mobile/enrollment-start/v1");
assert_eq!(start["profile"], "mobile-start-safe");
assert_eq!(start["repo"]["owner"], "example-org");
assert_eq!(start["repo"]["name"], "example-vault");
assert!(start["nonce"].as_str().unwrap().len() >= 32);
assert!(start.get("desktop_public_key").is_some());
assert!(start.get("private_key").is_none());
assert!(start.get("password").is_none());
assert!(start.get("secret").is_none());
}
#[test]
fn mobile_enroll_accept_records_recipient_and_reports_rewrap_handoff() {
let dir = tempdir().unwrap();
let profile = "mobile-accept";
let start = start_enrollment(&dir, profile);
let nonce = start["nonce"].as_str().unwrap();
let (response, recipient, _) = response_for(&start, profile, nonce);
let start_path = dir.path().join("start.json");
let response_path = dir.path().join("response.json");
let team_keys_path = dir.path().join(".tsafe/team-keys.json");
std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
std::fs::write(
&response_path,
serde_json::to_vec_pretty(&response).unwrap(),
)
.unwrap();
let output = tsafe()
.args([
"--profile",
profile,
"mobile",
"enroll",
"accept",
"--start",
start_path.to_str().unwrap(),
"--response",
response_path.to_str().unwrap(),
"--team-keys",
team_keys_path.to_str().unwrap(),
"--json",
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.output()
.unwrap();
assert!(
output.status.success(),
"stderr:\n{}\nstdout:\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.to_ascii_lowercase().contains("private_key"));
assert!(!stdout.to_ascii_lowercase().contains("password"));
let receipt: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(receipt["status"], "accepted");
assert_eq!(receipt["profile"], profile);
assert_eq!(receipt["nonce"], nonce);
assert_eq!(receipt["mobile_recipient"], recipient);
assert_eq!(receipt["team_rewrap"]["status"], "handoff_required");
assert_eq!(
receipt["team_rewrap"]["reason"],
"source-local team vault rewrap requires an operator age identity"
);
let team_keys: Value =
serde_json::from_slice(&std::fs::read(&team_keys_path).unwrap()).unwrap();
assert_eq!(team_keys["members"][0]["public_key"], recipient);
assert_eq!(team_keys["members"][0]["name"], "Test phone");
}
#[test]
fn mobile_enroll_accept_rewraps_source_local_team_vault_for_mobile_recipient() {
let dir = tempdir().unwrap();
let profile = "mobile-rewrap";
let vault_path = dir.path().join(".tsafe/vaults/mobile-rewrap.vault");
let operator_identity_path = dir.path().join("operator-age.txt");
let mobile_identity_path = dir.path().join("mobile-age.txt");
let team_keys_path = dir.path().join(".tsafe/team-keys.json");
let (operator_identity, operator_recipient) = age_crypto::generate_identity();
std::fs::write(&operator_identity_path, &operator_identity).unwrap();
let (file, _) = team::create_team_vault(&[operator_recipient]).unwrap();
std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
std::fs::write(&vault_path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
let start = start_enrollment_with_vault_path(&dir, profile, Some(&vault_path));
let nonce = start["nonce"].as_str().unwrap();
let (response, recipient, mobile_identity) = response_for(&start, profile, nonce);
std::fs::write(&mobile_identity_path, &mobile_identity).unwrap();
let start_path = dir.path().join("start.json");
let response_path = dir.path().join("response.json");
std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
std::fs::write(
&response_path,
serde_json::to_vec_pretty(&response).unwrap(),
)
.unwrap();
let output = tsafe()
.args([
"--profile",
profile,
"mobile",
"enroll",
"accept",
"--start",
start_path.to_str().unwrap(),
"--response",
response_path.to_str().unwrap(),
"--team-keys",
team_keys_path.to_str().unwrap(),
"--identity",
operator_identity_path.to_str().unwrap(),
"--json",
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.output()
.unwrap();
assert!(
output.status.success(),
"stderr:\n{}\nstdout:\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let receipt: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(receipt["team_rewrap"]["status"], "rewrapped");
assert_eq!(receipt["team_rewrap"]["already_rewrapped"], false);
assert_eq!(receipt["team_rewrap"]["recipient_count"], 2);
let rewrapped_file: tsafe_core::vault::VaultFile =
serde_json::from_slice(&std::fs::read(&vault_path).unwrap()).unwrap();
assert!(rewrapped_file.age_recipients.contains(&recipient));
let mobile_identities = age_crypto::load_identities(&mobile_identity_path).unwrap();
team::unwrap_dek(&rewrapped_file, &mobile_identities).unwrap();
let team_keys: Value =
serde_json::from_slice(&std::fs::read(&team_keys_path).unwrap()).unwrap();
assert_eq!(team_keys["members"][0]["public_key"], recipient);
}
#[test]
fn mobile_enroll_accept_rejects_nonce_mismatch() {
let dir = tempdir().unwrap();
let profile = "mobile-reject-nonce";
let start = start_enrollment(&dir, profile);
let (response, _, _) = response_for(&start, profile, "wrong-nonce");
let start_path = dir.path().join("start.json");
let response_path = dir.path().join("response.json");
let team_keys_path = dir.path().join(".tsafe/team-keys.json");
std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
std::fs::write(
&response_path,
serde_json::to_vec_pretty(&response).unwrap(),
)
.unwrap();
tsafe()
.args([
"--profile",
profile,
"mobile",
"enroll",
"accept",
"--start",
start_path.to_str().unwrap(),
"--response",
response_path.to_str().unwrap(),
"--team-keys",
team_keys_path.to_str().unwrap(),
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.assert()
.failure()
.stderr(predicates::str::contains("nonce mismatch"));
assert!(!team_keys_path.exists());
}
#[test]
fn mobile_enroll_accept_rejects_profile_mismatch() {
let dir = tempdir().unwrap();
let profile = "mobile-reject-profile";
let start = start_enrollment(&dir, profile);
let nonce = start["nonce"].as_str().unwrap();
let (response, _, _) = response_for(&start, "other-profile", nonce);
let start_path = dir.path().join("start.json");
let response_path = dir.path().join("response.json");
let team_keys_path = dir.path().join(".tsafe/team-keys.json");
std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
std::fs::write(
&response_path,
serde_json::to_vec_pretty(&response).unwrap(),
)
.unwrap();
tsafe()
.args([
"--profile",
profile,
"mobile",
"enroll",
"accept",
"--start",
start_path.to_str().unwrap(),
"--response",
response_path.to_str().unwrap(),
"--team-keys",
team_keys_path.to_str().unwrap(),
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.assert()
.failure()
.stderr(predicates::str::contains("profile mismatch"));
assert!(!team_keys_path.exists());
}
#[test]
fn mobile_enroll_accept_rejects_secret_like_response_fields() {
let dir = tempdir().unwrap();
let profile = "mobile-reject-secret";
let start = start_enrollment(&dir, profile);
let nonce = start["nonce"].as_str().unwrap();
let (mut response, _, _) = response_for(&start, profile, nonce);
response["private_key"] = json!("forbidden-public-test-marker");
let start_path = dir.path().join("start.json");
let response_path = dir.path().join("response.json");
let team_keys_path = dir.path().join(".tsafe/team-keys.json");
std::fs::write(&start_path, serde_json::to_vec_pretty(&start).unwrap()).unwrap();
std::fs::write(
&response_path,
serde_json::to_vec_pretty(&response).unwrap(),
)
.unwrap();
tsafe()
.args([
"--profile",
profile,
"mobile",
"enroll",
"accept",
"--start",
start_path.to_str().unwrap(),
"--response",
response_path.to_str().unwrap(),
"--team-keys",
team_keys_path.to_str().unwrap(),
])
.current_dir(dir.path())
.env("TSAFE_VAULT_DIR", dir.path().join("vaults"))
.assert()
.failure()
.stderr(predicates::str::contains("secret-like material"));
assert!(!team_keys_path.exists());
}