use assert_cmd::Command;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
const DEMO_FILES: [&str; 3] = ["policy.yaml", "assay.yaml", "traces.jsonl"];
fn run_demo(out: &Path) -> (i32, String, String) {
let output = Command::cargo_bin("assay")
.unwrap()
.current_dir(out)
.arg("demo")
.arg("--out")
.arg(out)
.output()
.expect("spawn assay demo");
let code = output.status.code().expect("assay demo exit code");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
(code, stdout, stderr)
}
fn run_printed_validate(stdout: &str) -> (i32, String, String) {
let line = stdout
.lines()
.find(|line| line.contains("assay validate --config"))
.unwrap_or_else(|| panic!("demo stdout missing next-step validate command:\n{stdout}"));
let command = line
.find("assay validate")
.map(|idx| &line[idx..])
.unwrap_or(line);
let mut parts = command.split_whitespace();
assert_eq!(parts.next(), Some("assay"));
assert_eq!(parts.next(), Some("validate"));
assert_eq!(parts.next(), Some("--config"));
let config = parts
.next()
.unwrap_or_else(|| panic!("next-step missing --config path:\n{line}"));
assert_eq!(parts.next(), Some("--trace-file"));
let trace = parts
.next()
.unwrap_or_else(|| panic!("next-step missing --trace-file path:\n{line}"));
let output = Command::cargo_bin("assay")
.unwrap()
.arg("validate")
.arg("--config")
.arg(config)
.arg("--trace-file")
.arg(trace)
.output()
.expect("spawn assay validate");
let code = output.status.code().expect("assay validate exit code");
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
(code, stdout, stderr)
}
fn rewrite_search_query(traces: &str, query: &str) -> String {
let needle = r#""query": "assay rules""#;
let replacement = format!(r#""query": "{query}""#);
assert!(
traces.contains(needle),
"traces.jsonl missing the demo Search query to rewrite:\n{traces}"
);
traces.replacen(needle, &replacement, 1)
}
#[test]
fn demo_completes_and_printed_validate_passes() {
let dir = tempdir().unwrap();
let out = dir.path();
let (code, stdout, stderr) = run_demo(out);
assert_eq!(
code, 0,
"assay demo --out should exit 0\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("Validation Passed"),
"assay demo should report Validation Passed\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let (vcode, vstdout, vstderr) = run_printed_validate(&stdout);
assert_eq!(
vcode, 0,
"printed next-step validate should exit 0\nstdout:\n{vstdout}\nstderr:\n{vstderr}"
);
assert!(
vstderr.contains("Validation OK"),
"printed next-step validate should report Validation OK\nstdout:\n{vstdout}\nstderr:\n{vstderr}"
);
}
#[test]
fn printed_validate_rejects_forbidden_search_query() {
let dir = tempdir().unwrap();
let out = dir.path();
let (code, stdout, stderr) = run_demo(out);
assert_eq!(
code, 0,
"assay demo --out should exit 0\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let traces = out.join("traces.jsonl");
let rewritten = rewrite_search_query(&fs::read_to_string(&traces).unwrap(), "assay;rules");
fs::write(&traces, rewritten).unwrap();
let (vcode, vstdout, vstderr) = run_printed_validate(&stdout);
assert_ne!(
vcode, 0,
"printed next-step validate should reject a forbidden Search query\nstdout:\n{vstdout}\nstderr:\n{vstderr}"
);
let combined = format!("{vstdout}{vstderr}");
assert!(
combined.contains("E_ARG_SCHEMA"),
"validate output should name E_ARG_SCHEMA\nstdout:\n{vstdout}\nstderr:\n{vstderr}"
);
}
#[test]
fn demo_reports_write_failure_when_target_is_a_directory() {
for name in DEMO_FILES {
let dir = tempdir().unwrap();
let out = dir.path();
let blocked = out.join(name);
fs::create_dir(&blocked).unwrap();
let (code, stdout, stderr) = run_demo(out);
assert_ne!(
code, 0,
"{name}: expected non-zero exit, got {code}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!stdout.contains("Created demo environment"),
"{name}: stdout claimed success after a failed write:\n{stdout}"
);
assert!(
stderr.contains("failed to write demo file"),
"{name}: stderr missing write-failure context:\n{stderr}"
);
assert!(
stderr.contains(&blocked.display().to_string()),
"{name}: stderr missing path {}:\n{stderr}",
blocked.display()
);
}
}
#[cfg(unix)]
#[test]
fn demo_does_not_report_validation_passed_on_stale_unwritable_files() {
let dir = tempdir().unwrap();
let out = dir.path();
let (seed_code, seed_stdout, seed_stderr) = run_demo(out);
assert_eq!(
seed_code, 0,
"stale-file seed must be the demo's own passing output\nstdout:\n{seed_stdout}\nstderr:\n{seed_stderr}"
);
assert!(
seed_stdout.contains("Validation Passed"),
"stale-file seed must be content that already printed Validation Passed:\n{seed_stdout}"
);
let restore = RestoreWritable {
dir: out.to_path_buf(),
};
for name in DEMO_FILES {
let path = out.join(name);
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o444);
fs::set_permissions(&path, perms).unwrap();
}
let mut dir_perms = fs::metadata(out).unwrap().permissions();
dir_perms.set_mode(0o555);
fs::set_permissions(out, dir_perms).unwrap();
if fs::write(out.join("probe_write"), "x").is_ok() {
eprintln!(
"skipping stale-file write-failure test: probe write succeeded (running as root)"
);
return;
}
let (code, stdout, stderr) = run_demo(out);
assert_ne!(
code, 0,
"expected non-zero exit on unwritable stale files\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
!stdout.contains("Validation Passed"),
"stdout reported validation success from stale files:\n{stdout}"
);
drop(restore);
}
#[cfg(unix)]
struct RestoreWritable {
dir: std::path::PathBuf,
}
#[cfg(unix)]
impl Drop for RestoreWritable {
fn drop(&mut self) {
let mut dir_perms = match fs::metadata(&self.dir) {
Ok(meta) => meta.permissions(),
Err(_) => return,
};
dir_perms.set_mode(0o755);
let _ = fs::set_permissions(&self.dir, dir_perms);
for name in DEMO_FILES {
let path = self.dir.join(name);
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o644);
let _ = fs::set_permissions(&path, perms);
}
}
let probe = self.dir.join("probe_write");
if probe.exists() {
let _ = fs::remove_file(probe);
}
}
}