use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use clap::Parser;
use tempfile::TempDir;
use tokio::sync::oneshot;
use trusty_secrets::server::{BackendFactory, OnDemandSecrets, ServerSettings, serve};
use trusty_secrets::store::{MemoryBackend, SecretBackend};
use trusty_secrets::{BackendId, SecretKey, SecretsError, VaultName};
use super::*;
use crate::cli::{Cli, Command};
const VALUE: &str = "sk-live-TAIL-7521-c0ffee";
const HEAD: &str = "sk-live-";
const TAIL: &str = "TAIL-7521-c0ffee";
const SHORT: &str = "Q7xZ-9wK";
const PROJECT_VAULT: &str = "trusty/acme/web";
const OWNER_VAULT: &str = "trusty/acme";
const PINNED_MACHINE_CONFIG: &str = "secrets:\n default_backend: keychain\n";
struct Fixed(&'static str);
impl ValueSource for Fixed {
fn read(&self) -> anyhow::Result<String> {
Ok(self.0.to_owned())
}
}
struct Harness {
tmp: TempDir,
repo: PathBuf,
keychain: Arc<MemoryBackend>,
spare: Arc<MemoryBackend>,
client: OnDemandSecrets,
_shutdown: oneshot::Sender<()>,
}
fn git(dir: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git runs");
assert!(status.success(), "git {args:?} failed");
}
fn client_at(tmp: &Path, socket: &Path) -> OnDemandSecrets {
OnDemandSecrets::at(socket).with_program(tmp.join("no-such-trusty-secrets"))
}
#[derive(Debug)]
struct SlowSet {
inner: Arc<MemoryBackend>,
delay: Duration,
}
impl SecretBackend for SlowSet {
fn id(&self) -> BackendId {
self.inner.id()
}
fn capabilities(&self) -> trusty_secrets::store::Capabilities {
self.inner.capabilities()
}
fn get(
&self,
vault: &VaultName,
key: &SecretKey,
) -> Result<Option<trusty_secrets::SecretValue>, SecretsError> {
self.inner.get(vault, key)
}
fn set(
&self,
vault: &VaultName,
key: &SecretKey,
value: &trusty_secrets::SecretValue,
) -> Result<(), SecretsError> {
std::thread::sleep(self.delay);
self.inner.set(vault, key, value)
}
fn delete(&self, vault: &VaultName, key: &SecretKey) -> Result<bool, SecretsError> {
self.inner.delete(vault, key)
}
}
async fn harness() -> Harness {
harness_slowed(Duration::ZERO).await
}
async fn harness_slowed(set_delay: Duration) -> Harness {
let tmp = TempDir::new().expect("tempdir");
let repo = tmp.path().join("repo");
std::fs::create_dir(&repo).expect("mkdir repo");
git(&repo, &["init", "-q"]);
git(
&repo,
&["remote", "add", "origin", "git@github.com:Acme/Web.git"],
);
let settings = ServerSettings::new(
tmp.path().join("run").join("s.sock"),
tmp.path().join("index"),
tmp.path().join("machine.yaml"),
Duration::from_secs(60),
);
std::fs::write(&settings.machine_config, PINNED_MACHINE_CONFIG).expect("write machine.yaml");
let pinned = trusty_secrets::store::config::load_machine_at(&settings.machine_config)
.expect("load machine.yaml")
.and_then(|m| m.default_backend);
assert_eq!(
pinned,
Some(BackendId::keychain()),
"the harness must pin the memory `keychain` backend on every OS"
);
let keychain = Arc::new(MemoryBackend::new());
let spare = Arc::new(MemoryBackend::new());
let (kc, sp) = (Arc::clone(&keychain), Arc::clone(&spare));
let backends: BackendFactory = Arc::new(move |id: &BackendId| {
let backend: Arc<dyn SecretBackend> = match id.as_str() {
BackendId::KEYCHAIN if !set_delay.is_zero() => Arc::new(SlowSet {
inner: Arc::clone(&kc),
delay: set_delay,
}),
BackendId::KEYCHAIN => Arc::clone(&kc) as Arc<dyn SecretBackend>,
"spare" => Arc::clone(&sp) as Arc<dyn SecretBackend>,
_ => {
return Err(SecretsError::UnknownBackend {
backend: id.to_string(),
});
}
};
Ok(backend)
});
let (tx, rx) = oneshot::channel::<()>();
let socket = settings.socket.clone();
tokio::spawn(serve(settings, backends, async move {
let _ = rx.await;
}));
let started = Instant::now();
while !trusty_common::uds::socket_is_serving(&socket, Duration::from_millis(200)).await {
assert!(
started.elapsed() < Duration::from_secs(20),
"socket never served"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let client = client_at(tmp.path(), &socket);
Harness {
tmp,
repo,
keychain,
spare,
client,
_shutdown: tx,
}
}
#[derive(Clone, Default)]
struct LogSink(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for LogSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("log sink").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
struct Outcome {
out: String,
err: Option<String>,
logs: String,
}
impl Outcome {
fn assert_no_value(&self) {
let err = self.err.as_deref().unwrap_or("");
for (what, text) in [("stdout", &*self.out), ("error", err), ("logs", &self.logs)] {
assert!(!text.contains(TAIL), "{what} carries the value: {text}");
assert!(
!text.contains(&SHORT[..4]),
"{what} carries the short value: {text}"
);
}
}
fn err(&self) -> &str {
self.err.as_deref().expect("the verb failed")
}
}
async fn run_in(
client: &OnDemandSecrets,
project: &Path,
clipboard: &'static str,
stdin: &'static str,
args: &[&str],
) -> Outcome {
run_ci(client, project, clipboard, stdin, false, args).await
}
async fn run_ci(
client: &OnDemandSecrets,
project: &Path,
clipboard: &'static str,
stdin: &'static str,
ci: bool,
args: &[&str],
) -> Outcome {
let argv = ["tm", "secrets"].into_iter().chain(args.iter().copied());
let Some(Command::Secrets { action }) = Cli::try_parse_from(argv).expect("parse").command
else {
panic!("not a secrets command");
};
let sink = LogSink::default();
let writer = sink.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_ansi(false)
.with_writer(move || writer.clone())
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let ctx = Ctx {
client,
project,
clipboard: &Fixed(clipboard),
stdin: &Fixed(stdin),
ci,
};
let mut out = Vec::new();
let result = dispatch(&ctx, action, &mut out).await;
out.flush().expect("flush");
let logs = String::from_utf8_lossy(&sink.0.lock().expect("log sink")).into_owned();
let outcome = Outcome {
out: String::from_utf8(out).expect("utf-8 stdout"),
err: result.err().map(|e| format!("{e:#}\n{e:?}")),
logs,
};
outcome.assert_no_value();
outcome
}
async fn run(h: &Harness, clipboard: &'static str, args: &[&str]) -> Outcome {
run_in(&h.client, &h.repo, clipboard, "", args).await
}
async fn seed_owner(h: &Harness, key: &str, value: &str) {
let ctx = Ctx {
client: &h.client,
project: &h.repo,
clipboard: &Fixed(""),
stdin: &Fixed(""),
ci: false,
};
let params = serde_json::json!({ "vault": OWNER_VAULT, "key": key, "value": value });
let _: serde_json::Value = ctx
.call(trusty_secrets::api::methods::method::SET, params)
.await
.expect("owner-scope set");
}
fn env_file(h: &Harness, name: &str, text: &str) -> String {
let path = h.tmp.path().join(name);
std::fs::write(&path, text).expect("write env file");
path.to_str().expect("utf-8 path").to_owned()
}
fn stored(backend: &MemoryBackend, vault: &str, key: &str) -> Option<String> {
let vault = VaultName::new(vault).expect("vault");
let key = SecretKey::new(key).expect("key");
backend
.get(&vault, &key)
.expect("memory get")
.map(|v| v.expose().to_owned())
}
#[test]
fn cli_parses_every_secrets_verb() {
for args in [
vec!["set", "K"],
vec!["set", "K", "grp", "--value", "-"],
vec!["list"],
vec!["remove", "K"],
vec!["remove", "K", "grp"],
vec!["import", ".env"],
vec!["import", ".env", "grp"],
vec!["copy", "--from", "keychain", "--to", "spare"],
vec!["copy", "--from", "keychain", "--to", "spare", "A", "grp.B"],
vec!["doctor"],
] {
let argv = ["tm", "secrets"].into_iter().chain(args.iter().copied());
let cli = Cli::try_parse_from(argv).unwrap_or_else(|e| panic!("{args:?}: {e}"));
assert!(
matches!(cli.command, Some(Command::Secrets { .. })),
"{args:?}"
);
}
assert!(Cli::try_parse_from(["tm", "secrets", "set"]).is_err());
for args in [
vec!["set", "K", "--owner"],
vec!["remove", "K", "--owner"],
vec!["import", ".env", "--owner"],
vec!["copy", "--from", "keychain"],
vec!["remove"],
vec!["import"],
] {
let argv = ["tm", "secrets"].into_iter().chain(args.iter().copied());
assert!(Cli::try_parse_from(argv).is_err(), "{args:?} parsed");
}
}
#[test]
fn parsed_set_arguments_never_show_in_debug() {
let key = format!("API_KEY:{VALUE}");
let argv = ["tm", "secrets", "set", &key, VALUE, "--value", VALUE, VALUE];
let cli = Cli::try_parse_from(argv).expect("parse");
let debug = format!("{cli:?}");
assert!(debug.contains("Secrets"), "{debug}");
assert!(!debug.contains(TAIL), "Debug shows the value: {debug}");
}
#[tokio::test]
async fn set_reads_the_clipboard_and_confirms_head_and_length_only() {
let h = harness().await;
let first = run(&h, " sk-live-TAIL-7521-c0ffee\n", &["set", "API_KEY"]).await;
assert_eq!(first.err, None);
assert_eq!(
first.out,
format!("(new) secret set API_KEY: {HEAD}… [24 chars]\n")
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "API_KEY").as_deref(),
Some(VALUE)
);
let second = run(&h, "sk-live-TAIL-7521-c0ffee-v2", &["set", "API_KEY"]).await;
assert_eq!(
second.out,
format!("(updated) secret set API_KEY: {HEAD}… [27 chars]\n")
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_slower_than_the_old_client_wait_reports_the_real_outcome() {
let h = harness_slowed(Duration::from_secs(31)).await;
let started = Instant::now();
let outcome = run(&h, VALUE, &["set", "API_KEY"]).await;
assert_eq!(outcome.err, None, "tm gave up before the server answered");
assert!(started.elapsed() >= Duration::from_secs(31));
assert_eq!(
outcome.out,
format!("(new) secret set API_KEY: {HEAD}… [24 chars]\n")
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "API_KEY").as_deref(),
Some(VALUE)
);
}
#[tokio::test]
async fn set_with_a_short_value_shows_only_its_length() {
let h = harness().await;
let outcome = run(&h, SHORT, &["set", "PIN"]).await;
assert_eq!(outcome.out, "(new) secret set PIN: [8 chars]\n");
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "PIN").as_deref(),
Some(SHORT)
);
}
#[tokio::test]
async fn set_with_an_empty_clipboard_is_an_error_and_stores_nothing() {
let h = harness().await;
let outcome = run(&h, " \n\t", &["set", "API_KEY"]).await;
assert!(
outcome.err().contains("the clipboard is empty"),
"{}",
outcome.err()
);
assert_eq!(outcome.out, "");
let piped = run(&h, VALUE, &["set", "API_KEY", "--value", "-"]).await;
assert!(piped.err().contains("stdin is empty"), "{}", piped.err());
assert!(h.keychain.is_empty());
}
#[tokio::test]
async fn set_refuses_a_value_on_the_command_line_without_echoing_it() {
let h = harness().await;
let in_key = format!("API_KEY:{VALUE}");
for args in [
vec!["set", "API_KEY", "grp", VALUE],
vec!["set", "API_KEY", "--value", VALUE],
vec!["set", in_key.as_str()],
vec!["set", "API_KEY", VALUE],
] {
let outcome = run(&h, VALUE, &args).await;
assert!(outcome.err.is_some(), "{args:?} was accepted");
assert_eq!(outcome.out, "", "{args:?}");
}
assert!(h.keychain.is_empty());
}
#[tokio::test]
async fn set_with_a_group_namespaces_the_key() {
let h = harness().await;
let outcome = run_in(
&h.client,
&h.repo,
"",
VALUE,
&["set", "API_KEY", "stripe", "--value", "-"],
)
.await;
assert_eq!(
outcome.out,
format!("(new) secret set stripe.API_KEY: {HEAD}… [24 chars]\n")
);
let again = run(&h, VALUE, &["set", "API_KEY", "stripe"]).await;
assert!(
again
.out
.starts_with("(updated) secret set stripe.API_KEY: "),
"{}",
again.out
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "stripe.API_KEY").as_deref(),
Some(VALUE)
);
assert_eq!(stored(&h.keychain, PROJECT_VAULT, "API_KEY"), None);
let dotted = run(&h, VALUE, &["set", "API_KEY", "a.b"]).await;
assert!(
dotted.err().contains("a group is 1 to 16"),
"{}",
dotted.err()
);
}
#[tokio::test]
async fn list_prints_key_names_only() {
let h = harness().await;
run(&h, VALUE, &["set", "A_KEY"]).await;
seed_owner(&h, "B_KEY", SHORT).await;
let outcome = run(&h, "", &["list"]).await;
assert_eq!(
outcome.out,
"trusty/acme/web (Project):\n A_KEY\ntrusty/acme (Owner):\n B_KEY\n"
);
assert!(!outcome.out.contains(HEAD) && !outcome.out.contains("chars"));
}
#[tokio::test]
async fn remove_deletes_a_project_key_and_fails_by_name_on_a_missing_one() {
let h = harness().await;
run(&h, VALUE, &["set", "API_KEY", "grp"]).await;
seed_owner(&h, "B_KEY", SHORT).await;
let removed = run(&h, "", &["remove", "API_KEY", "grp"]).await;
assert_eq!(removed.err, None);
assert_eq!(removed.out, "removed grp.API_KEY from trusty/acme/web\n");
assert_eq!(stored(&h.keychain, PROJECT_VAULT, "grp.API_KEY"), None);
let missing = run(&h, "", &["remove", "API_KEY", "grp"]).await;
assert!(
missing
.err()
.contains("grp.API_KEY is not in trusty/acme/web"),
"{}",
missing.err()
);
assert_eq!(missing.out, "");
let owner = run(&h, "", &["remove", "B_KEY"]).await;
assert!(
owner.err().contains("B_KEY is not in trusty/acme/web"),
"{}",
owner.err()
);
assert_eq!(
stored(&h.keychain, OWNER_VAULT, "B_KEY").as_deref(),
Some(SHORT)
);
}
#[tokio::test]
async fn import_loads_a_dotenv_file_and_prints_names_only() {
let h = harness().await;
let text = format!("# keys\nAPI_KEY={VALUE}\nexport PIN='{SHORT}'\nREF=secret://OTHER\n");
let path = env_file(&h, "app.env", &text);
let outcome = run(&h, "", &["import", &path]).await;
assert_eq!(outcome.err, None);
assert_eq!(
outcome.out,
"imported API_KEY (new)\nimported PIN (new)\nskipped REF (a secret:// reference)\n\
trusty/acme/web: 2 imported, 1 skipped, 0 failed\n"
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "API_KEY").as_deref(),
Some(VALUE)
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "PIN").as_deref(),
Some(SHORT)
);
assert_eq!(stored(&h.keychain, PROJECT_VAULT, "REF"), None);
assert_eq!(h.keychain.len(), 2, "nothing outside the project scope");
assert!(std::path::Path::new(&path).exists(), "the file stays");
let grouped = run(&h, "", &["import", &path, "app"]).await;
assert!(
grouped.out.starts_with("imported app.API_KEY (new)\n"),
"{}",
grouped.out
);
let again = run(&h, "", &["import", &path]).await;
assert!(
again.out.starts_with("imported API_KEY (updated)\n"),
"{}",
again.out
);
}
#[tokio::test]
async fn import_fails_by_name_on_a_refused_key_and_imports_the_rest() {
let h = harness().await;
let path = env_file(&h, "app.env", &format!("EMPTY=\nAPI_KEY={VALUE}\n"));
let outcome = run(&h, "", &["import", &path]).await;
assert!(
outcome.out.starts_with("failed EMPTY: tm secrets: "),
"{}",
outcome.out
);
assert!(
outcome.out.ends_with(
"imported API_KEY (new)\ntrusty/acme/web: 1 imported, 0 skipped, 1 failed\n"
),
"{}",
outcome.out
);
assert!(
outcome.err().contains("1 key(s) failed: EMPTY"),
"{}",
outcome.err()
);
assert_eq!(
stored(&h.keychain, PROJECT_VAULT, "API_KEY").as_deref(),
Some(VALUE)
);
}
#[tokio::test]
async fn import_syntax_error_names_the_line_not_its_text() {
let h = harness().await;
let path = env_file(
&h,
"bad.env",
&format!("GOOD={SHORT}\nLEAKY_NAME={VALUE}'x\n"),
);
let outcome = run(&h, "", &["import", &path]).await;
let err = outcome.err();
assert!(err.contains(".env line 2: "), "{err}");
assert!(
!err.contains("LEAKY_NAME"),
"the error quotes the line: {err}"
);
assert!(!err.contains(HEAD), "the error quotes the line: {err}");
assert_eq!(outcome.out, "");
assert!(
h.keychain.is_empty(),
"a file that does not parse stores nothing"
);
let absent = h.tmp.path().join("absent.env");
let absent = run(&h, "", &["import", absent.to_str().expect("utf-8")]).await;
assert!(absent.err().contains("cannot read"), "{}", absent.err());
}
#[tokio::test]
async fn import_with_an_invalid_group_stores_nothing() {
let h = harness().await;
let path = env_file(&h, "app.env", &format!("API_KEY={VALUE}\n"));
let outcome = run(&h, "", &["import", &path, "a.b"]).await;
assert!(
outcome.err().contains("a group is 1 to 16"),
"{}",
outcome.err()
);
assert_eq!(outcome.out, "");
assert!(h.keychain.is_empty());
}
#[tokio::test]
async fn copy_moves_project_keys_between_backends_without_printing_them() {
let h = harness().await;
run(&h, VALUE, &["set", "API_KEY"]).await;
run(&h, SHORT, &["set", "PIN"]).await;
seed_owner(&h, "B_KEY", SHORT).await;
let named = run(
&h,
"",
&["copy", "--from", "keychain", "--to", "spare", "API_KEY"],
)
.await;
assert_eq!(named.err, None);
assert_eq!(
named.out,
"copied API_KEY\nkeychain -> spare: 1 copied, 0 not copied\n"
);
assert_eq!(stored(&h.spare, PROJECT_VAULT, "PIN"), None);
let all = run(&h, "", &["copy", "--from", "keychain", "--to", "spare"]).await;
assert_eq!(all.err, None);
assert!(all.out.contains("copied API_KEY\n"), "{}", all.out);
assert!(all.out.contains("copied PIN\n"), "{}", all.out);
assert!(
all.out
.ends_with("keychain -> spare: 2 copied, 0 not copied\n"),
"{}",
all.out
);
assert_eq!(
stored(&h.spare, PROJECT_VAULT, "API_KEY").as_deref(),
Some(VALUE)
);
assert_eq!(
stored(&h.spare, PROJECT_VAULT, "PIN").as_deref(),
Some(SHORT)
);
assert_eq!(stored(&h.spare, OWNER_VAULT, "B_KEY"), None);
}
#[tokio::test]
async fn copy_fails_by_name_on_a_missing_key() {
let h = harness().await;
run(&h, VALUE, &["set", "API_KEY"]).await;
let args = [
"copy", "--from", "keychain", "--to", "spare", "API_KEY", "NOPE",
];
let outcome = run(&h, "", &args).await;
assert_eq!(
outcome.out,
"copied API_KEY\nnot copied NOPE\nkeychain -> spare: 1 copied, 1 not copied\n"
);
assert!(
outcome
.err()
.contains("1 key(s) not copied (absent from keychain, or refused by spare): NOPE"),
"{}",
outcome.err()
);
assert_eq!(stored(&h.spare, PROJECT_VAULT, "NOPE"), None);
let same = run(&h, "", &["copy", "--from", "spare", "--to", "spare"]).await;
assert!(same.err().contains("are the same"), "{}", same.err());
let unknown = run(&h, "", &["copy", "--from", "keychain", "--to", "vault"]).await;
assert!(
unknown.err().contains("not available in this build"),
"{}",
unknown.err()
);
}
#[tokio::test]
async fn doctor_reports_socket_and_backends_without_values() {
let h = harness().await;
run(&h, VALUE, &["set", "API_KEY"]).await;
let outcome = run(&h, "", &["doctor"]).await;
assert_eq!(outcome.err, None);
let socket = h.client.socket().display().to_string();
assert!(
outcome.out.contains(&format!("socket {socket}: reachable")),
"{}",
outcome.out
);
assert!(
outcome.out.contains("selected backend: keychain"),
"{}",
outcome.out
);
assert!(
outcome
.out
.contains("backend keychain: available [READ, WRITE"),
"{}",
outcome.out
);
assert!(!outcome.out.contains(HEAD));
let outside = h.tmp.path().join("plain");
std::fs::create_dir(&outside).expect("mkdir");
let bare = run_in(&h.client, &outside, "", "", &["doctor"]).await;
assert!(bare.err().contains(PROJECT_NOT_JUDGED), "{}", bare.err());
assert!(
bare.out
.starts_with("project: tm secrets: secrets.doctor: "),
"{}",
bare.out
);
assert!(bare.out.contains(": reachable"), "{}", bare.out);
}
const PROJECT_NOT_JUDGED: &str = "the project was refused, so the report above is the machine \
default's";
#[tokio::test]
async fn doctor_reports_backends_when_the_remote_is_off_github() {
let h = harness().await;
let gitlab = h.tmp.path().join("gitlab");
std::fs::create_dir(&gitlab).expect("mkdir gitlab");
git(&gitlab, &["init", "-q"]);
git(
&gitlab,
&["remote", "add", "origin", "https://gitlab.com/acme/app.git"],
);
let outcome = run_in(&h.client, &gitlab, "", "", &["doctor"]).await;
assert!(
outcome.err().contains(PROJECT_NOT_JUDGED),
"{}",
outcome.err()
);
assert!(
outcome
.out
.starts_with("project: tm secrets: secrets.doctor: "),
"{}",
outcome.out
);
assert!(outcome.out.contains("github.com"), "{}", outcome.out);
let socket = h.client.socket().display().to_string();
assert!(
outcome.out.contains(&format!("socket {socket}: reachable")),
"{}",
outcome.out
);
assert!(
outcome
.out
.contains("backend keychain: available [READ, WRITE"),
"{}",
outcome.out
);
outcome.assert_no_value();
}
#[tokio::test]
async fn doctor_reports_a_refusing_server_as_reachable() {
let h = harness().await;
std::fs::write(
h.tmp.path().join("machine.yaml"),
"secrets:\n default_backend:\n - not-a-backend-id\n",
)
.expect("write machine.yaml");
let outcome = run(&h, "", &["doctor"]).await;
let socket = h.client.socket().display().to_string();
assert_eq!(outcome.out, format!("socket {socket}: reachable\n"));
assert!(
outcome.err().contains("config section is invalid"),
"{}",
outcome.err()
);
}
#[tokio::test]
async fn doctor_fails_when_the_selected_backend_is_unavailable() {
let h = harness().await;
std::fs::write(
h.tmp.path().join("machine.yaml"),
"secrets:\n default_backend: onepassword\n",
)
.expect("write machine.yaml");
let outcome = run(&h, "", &["doctor"]).await;
assert!(
outcome.out.contains("selected backend: onepassword\n"),
"{}",
outcome.out
);
assert!(
outcome.out.contains("backend keychain: available ["),
"{}",
outcome.out
);
assert!(
outcome
.out
.contains("backend onepassword: unavailable (not_compiled): "),
"{}",
outcome.out
);
assert!(
outcome
.err()
.contains("the selected backend `onepassword` is unavailable"),
"{}",
outcome.err()
);
}
#[tokio::test]
async fn doctor_renders_reasons_posture_and_headless_readiness() {
let h = harness().await;
let outcome = run(&h, "", &["doctor"]).await;
assert_eq!(outcome.err, None, "{}", outcome.out);
for line in [
"posture: keychain\n",
"backend file: unavailable (not_compiled): ",
"backend keeper: unavailable (not_compiled): ",
"headless: 1Password service-account token at server start: no\n",
] {
assert!(outcome.out.contains(line), "{line:?} in\n{}", outcome.out);
}
let report = |token: bool| -> trusty_secrets::server::DoctorResponse {
serde_json::from_value(serde_json::json!({
"socket": "/s", "index_root": "/i", "machine_config": "/m",
"account_config": "/home/u/.trusty-tools/trusty-common/config.yaml",
"project_root": null, "project_config": null,
"selected_backend": "onepassword", "posture": "other",
"backends": [
{"id": "file", "available": true, "capabilities": ["READ"]},
{"id": "onepassword", "available": true, "capabilities": ["READ", "WRITE"]},
{"id": "keeper", "available": false, "capabilities": [],
"reason": "cli_not_installed", "detail": "`keeper` is not installed"},
],
"headless": {"onepassword_token": token},
"tools": [
{"id": "bitwarden", "program": "bw", "installed": true, "path": "/usr/bin/bw"},
{"id": "vault", "program": "vault", "installed": false},
],
}))
.expect("decode")
};
let mut out = Vec::new();
doctor::render(&report(false), &mut out).expect("render");
let text = String::from_utf8(out).expect("utf-8");
assert!(text.contains("account machine config: /home/u/"), "{text}");
assert!(
text.contains("backend onepassword: available [READ, WRITE]; no service-account token"),
"{text}"
);
assert!(
text.contains("backend keeper: unavailable (cli_not_installed): `keeper` is not installed"),
"{text}"
);
let mut out = Vec::new();
doctor::render(&report(true), &mut out).expect("render");
let text = String::from_utf8(out).expect("utf-8");
assert!(
text.contains("backend onepassword: available [READ, WRITE]\n"),
"{text}"
);
assert!(text.contains("token at server start: yes\n"), "{text}");
assert!(
text.ends_with(
"tool bitwarden (bw): installed at /usr/bin/bw; no trusty-secrets backend\n\
tools not installed: vault\n"
),
"{text}"
);
}
fn ci_report(selected: &str, token: Option<bool>) -> trusty_secrets::server::DoctorResponse {
let mut report = serde_json::json!({
"socket": "/s", "index_root": "/i", "machine_config": "/m",
"project_root": null, "project_config": null, "selected_backend": selected,
"backends": [
{"id": "keychain", "available": true, "capabilities": ["READ", "WRITE"]},
{"id": "onepassword", "available": true, "capabilities": ["READ", "WRITE"]},
],
});
if let Some(token) = token {
report["headless"] = serde_json::json!({"onepassword_token": token});
}
serde_json::from_value(report).expect("decode")
}
#[tokio::test]
async fn doctor_under_ci_fails_when_onepassword_has_no_headless_credential() {
use doctor::{Judged, is_ci, verdict};
let err = verdict(
&ci_report("onepassword", Some(false)),
Judged::Project,
true,
)
.expect_err("CI with no token must fail");
assert!(
err.to_string().contains("OP_SERVICE_ACCOUNT_TOKEN"),
"{err}"
);
for (selected, token, ci) in [
("onepassword", false, false),
("onepassword", true, true),
("keychain", false, true),
] {
verdict(&ci_report(selected, Some(token)), Judged::Project, ci)
.unwrap_or_else(|e| panic!("{selected} token={token} ci={ci}: {e}"));
}
let old = ci_report("onepassword", None);
assert_eq!(old.headless, None);
verdict(&old, Judged::Project, true).expect_err("CI with no readiness reported must fail");
for (value, ci) in [
(Some("true"), true),
(Some("TRUE"), true),
(Some("1"), true),
(Some("false"), false),
(Some(""), false),
(None, false),
] {
assert_eq!(is_ci(value), ci, "{value:?}");
}
let h = harness().await;
let outcome = run_ci(&h.client, &h.repo, "", "", true, &["doctor"]).await;
assert_eq!(outcome.err, None, "{}", outcome.out);
}
#[tokio::test]
async fn every_verb_fails_without_a_value_when_the_socket_is_unreachable() {
let h = harness().await;
let dead = client_at(h.tmp.path(), &h.tmp.path().join("dead").join("s.sock"));
let env = env_file(&h, "app.env", &format!("API_KEY={VALUE}\nPIN={SHORT}\n"));
for args in [
vec!["set", "API_KEY"],
vec!["set", "PIN", "--value", "-"],
vec!["list"],
vec!["remove", "API_KEY"],
vec!["import", env.as_str()],
vec!["copy", "--from", "keychain", "--to", "spare"],
vec!["doctor"],
] {
let outcome = run_in(&dead, &h.repo, VALUE, SHORT, &args).await;
assert!(
outcome.err().contains("cannot start trusty-secrets"),
"{args:?}: {}",
outcome.err()
);
}
let doctor = run_in(&dead, &h.repo, "", "", &["doctor"]).await;
assert!(doctor.out.ends_with(": unreachable\n"), "{}", doctor.out);
assert!(h.keychain.is_empty() && h.spare.is_empty());
}