Adds run_stdin + create_collection_and_grant fixture helpers and three acceptance tests for org add card/key/totp. Red until B1/B2 wire the subcommands (currently: unrecognized subcommand). Asserts org get masks card number + key material without --show. Edit round-trips land with B3.
324 lines
14 KiB
Rust
324 lines
14 KiB
Rust
use assert_cmd::cargo::CommandCargoExt as _;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use tempfile::TempDir;
|
|
|
|
/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
|
|
struct OrgFixture {
|
|
_config: TempDir,
|
|
vault: TempDir,
|
|
xdg: PathBuf,
|
|
}
|
|
|
|
impl OrgFixture {
|
|
/// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
|
|
/// register it as the current device, then `org init`.
|
|
fn new() -> Self {
|
|
let config = TempDir::new().unwrap();
|
|
let xdg = config.path().to_path_buf();
|
|
let devices = xdg.join("relicario").join("devices").join("laptop");
|
|
std::fs::create_dir_all(&devices).unwrap();
|
|
|
|
// Generate an OpenSSH ed25519 keypair without a passphrase.
|
|
let keyfile = devices.join("signing.key");
|
|
let status = Command::new("ssh-keygen")
|
|
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
|
|
.arg(&keyfile)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.expect("ssh-keygen");
|
|
assert!(status.success(), "ssh-keygen failed");
|
|
// ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
|
|
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
|
|
// Mark this device current.
|
|
std::fs::write(
|
|
xdg.join("relicario").join("devices").join("current"),
|
|
"laptop\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let vault = TempDir::new().unwrap();
|
|
let f = OrgFixture { _config: config, vault, xdg };
|
|
|
|
let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
|
|
assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
|
|
f
|
|
}
|
|
|
|
fn vault_path(&self) -> &Path { self.vault.path() }
|
|
fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }
|
|
|
|
fn run(&self, args: &[&str]) -> std::process::Output {
|
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
|
cmd.env("XDG_CONFIG_HOME", &self.xdg)
|
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
cmd.output().unwrap()
|
|
}
|
|
|
|
/// Owner member id printed by `org init`/`org status`. We read it from
|
|
/// members.json directly to avoid parsing stdout.
|
|
fn owner_member_id(&self) -> String {
|
|
let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
|
|
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
|
|
v["members"][0]["member_id"].as_str().unwrap().to_string()
|
|
}
|
|
|
|
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
|
|
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
|
|
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
|
|
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
|
|
use std::io::Write as _;
|
|
let mut child = Command::cargo_bin("relicario")
|
|
.unwrap()
|
|
.env("XDG_CONFIG_HOME", &self.xdg)
|
|
.env("RELICARIO_ORG_DIR", self.vault.path())
|
|
.args(args)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
|
|
child.wait_with_output().unwrap()
|
|
}
|
|
|
|
/// Create collection `slug` and grant the owner access to it — the common
|
|
/// setup the item-type round-trip tests share.
|
|
fn create_collection_and_grant(&self, slug: &str) {
|
|
let owner = self.owner_member_id();
|
|
assert!(
|
|
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
|
|
"create-collection {slug} failed",
|
|
);
|
|
assert!(
|
|
self.run(&["org", "grant", &owner, slug]).status.success(),
|
|
"grant {slug} failed",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn org_add_get_list_round_trip() {
|
|
let f = OrgFixture::new();
|
|
let owner = f.owner_member_id();
|
|
|
|
// Create a collection and grant the owner access to it.
|
|
let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
|
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
|
let out = f.run(&["org", "grant", &owner, "prod"]);
|
|
assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
// Add a login into the prod collection.
|
|
let out = f.run(&[
|
|
"org", "add", "login", "--collection", "prod",
|
|
"--title", "GitHub", "--username", "alice",
|
|
"--url", "https://github.com", "--password", "hunter2",
|
|
]);
|
|
assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
// The blob must live under items/prod/, NOT flat items/.
|
|
let prod_dir = f.vault_path().join("items").join("prod");
|
|
let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
|
|
assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");
|
|
|
|
// list shows it.
|
|
let out = f.run(&["org", "list"]);
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");
|
|
|
|
// get masks by default.
|
|
let out = f.run(&["org", "get", "GitHub"]);
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
assert!(stdout.contains("********"), "expected masked secret: {stdout}");
|
|
assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");
|
|
|
|
// get --show reveals.
|
|
let out = f.run(&["org", "get", "GitHub", "--show"]);
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");
|
|
|
|
// The commit trailer records the action + collection + item.
|
|
let log = Command::new("git")
|
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
|
.output()
|
|
.unwrap();
|
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
|
assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
|
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
|
assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_add_rejects_ungranted_collection() {
|
|
let f = OrgFixture::new();
|
|
// Create the collection but do NOT grant the owner.
|
|
let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
|
|
assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let out = f.run(&[
|
|
"org", "add", "login", "--collection", "secret",
|
|
"--title", "X", "--username", "u", "--password", "p",
|
|
]);
|
|
assert!(!out.status.success(), "add into ungranted collection must fail");
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_add_rejects_unknown_collection() {
|
|
let f = OrgFixture::new();
|
|
let out = f.run(&[
|
|
"org", "add", "login", "--collection", "ghost",
|
|
"--title", "X", "--username", "u", "--password", "p",
|
|
]);
|
|
assert!(!out.status.success(), "add into nonexistent collection must fail");
|
|
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_edit_updates_fields_and_commits_update_trailer() {
|
|
let f = OrgFixture::new();
|
|
let owner = f.owner_member_id();
|
|
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
|
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
|
assert!(f.run(&[
|
|
"org", "add", "login", "--collection", "prod",
|
|
"--title", "Mail", "--username", "old", "--password", "pw",
|
|
]).status.success());
|
|
|
|
// Edit the username.
|
|
let out = f.run(&[
|
|
"org", "edit", "Mail", "--username", "new-user",
|
|
]);
|
|
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
// get --show reflects the new username.
|
|
let out = f.run(&["org", "get", "Mail", "--show"]);
|
|
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
|
assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
|
|
|
|
let log = Command::new("git")
|
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
|
.output().unwrap();
|
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
|
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
|
|
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_rm_restore_purge_cycle() {
|
|
let f = OrgFixture::new();
|
|
let owner = f.owner_member_id();
|
|
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
|
|
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
|
|
assert!(f.run(&[
|
|
"org", "add", "secure-note", "--collection", "prod",
|
|
"--title", "Recovery", "--body", "codes-here",
|
|
]).status.success());
|
|
|
|
// rm → appears only with --trashed.
|
|
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
|
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
|
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
|
|
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
|
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
|
|
|
|
// restore → back in default list.
|
|
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
|
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
|
|
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
|
|
|
|
// purge → blob gone, entry gone, item-purge trailer.
|
|
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
|
|
let prod_dir = f.vault_path().join("items").join("prod");
|
|
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
|
|
assert_eq!(count, 0, "blob not purged from items/prod/");
|
|
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
|
|
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
|
|
|
|
let log = Command::new("git")
|
|
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
|
|
.output().unwrap();
|
|
let body = String::from_utf8_lossy(&log.stdout).to_string();
|
|
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
|
|
}
|
|
|
|
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
|
|
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
|
|
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
|
|
// must mask every secret unless `--show` is passed — asserted below.
|
|
|
|
#[test]
|
|
fn org_add_card_via_stdin_then_get_masks_secret() {
|
|
let f = OrgFixture::new();
|
|
f.create_collection_and_grant("eng");
|
|
|
|
// build_card reads number, then cvv, then pin — one line each, in that order.
|
|
let out = f.run_stdin(
|
|
&[
|
|
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
|
|
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
|
|
],
|
|
"4111111111111111\n123\n4321\n",
|
|
);
|
|
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
// get masks the card number by default.
|
|
let got = f.run(&["org", "get", "Corp Visa"]);
|
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
|
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
|
|
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
|
|
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
|
|
|
|
// --show reveals it.
|
|
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
|
|
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
|
|
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_add_key_via_stdin_then_get_masks_material() {
|
|
let f = OrgFixture::new();
|
|
f.create_collection_and_grant("eng");
|
|
|
|
// build_key reads key material from stdin to EOF (multiline secret).
|
|
let out = f.run_stdin(
|
|
&[
|
|
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
|
|
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
|
|
],
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
|
|
);
|
|
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let got = f.run(&["org", "get", "Deploy Key"]);
|
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
|
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
|
|
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
|
|
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn org_add_totp_with_secret_flag_round_trips() {
|
|
let f = OrgFixture::new();
|
|
f.create_collection_and_grant("eng");
|
|
|
|
// Totp accepts the base32 secret via --secret (no stdin needed).
|
|
let out = f.run(&[
|
|
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
|
|
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
|
|
]);
|
|
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
|
|
|
|
let got = f.run(&["org", "get", "AWS root"]);
|
|
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
|
|
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
|
|
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
|
|
}
|