use std::time::Instant;
use clap::{Args as _, FromArgMatches, Parser};
use crate::audit::{run_full_sweep, AuditSweepStats, StageStatus, SweepOptions, SweepStage};
use crate::commands::audit::AuditArgs;
use crate::core::config::Config;
use crate::core::db::Database;
use crate::core::progress::{ProgressBus, Stage};
const EXPECTED_ORDER: [SweepStage; 9] = [
SweepStage::Collect,
SweepStage::Correlate,
SweepStage::Classify,
SweepStage::JiraSync,
SweepStage::Deployments,
SweepStage::Incidents,
SweepStage::Dora,
SweepStage::PrMetrics,
SweepStage::Report,
];
#[test]
fn failed_stage_is_recorded_and_does_not_stop_the_sweep() {
let mut stats = AuditSweepStats::default();
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::JiraSync,
Instant::now(),
Err(anyhow::anyhow!("no JIRA project configured")),
);
stats.record(SweepStage::Report, Instant::now(), Ok(()));
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(
stages,
vec![
SweepStage::Collect,
SweepStage::JiraSync,
SweepStage::Report
]
);
let failures: Vec<_> = stats.failures().collect();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].stage, SweepStage::JiraSync);
assert_eq!(
failures[0].status,
StageStatus::Failed("no JIRA project configured".to_string())
);
assert!(stats.any_failed());
}
#[test]
fn error_cause_chain_is_preserved_in_the_stage_record() {
let mut stats = AuditSweepStats::default();
let err = anyhow::anyhow!("connection refused").context("fetching deploy events");
stats.record(SweepStage::Deployments, Instant::now(), Err(err));
let StageStatus::Failed(msg) = &stats.outcomes[0].status else {
panic!("expected a failed status");
};
assert!(
msg.contains("fetching deploy events") && msg.contains("connection refused"),
"cause chain lost: {msg}"
);
}
#[test]
fn summary_counts_successes_and_failures() {
let mut stats = AuditSweepStats::default();
assert_eq!(stats.summary(), "0 of 0 stage(s) succeeded");
assert!(!stats.any_failed());
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("boom")),
);
assert_eq!(stats.summary(), "1 of 2 stage(s) succeeded");
}
#[test]
fn stage_names_match_their_subcommands() {
assert_eq!(SweepStage::JiraSync.as_str(), "jira sync");
assert_eq!(SweepStage::PrMetrics.to_string(), "pr-metrics");
assert_eq!(SweepStage::Deployments.as_str(), "deployments collect");
}
#[tokio::test]
async fn sweep_runs_every_stage_in_order_and_survives_failures() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let stats = run_full_sweep(&Config::default(), &mut db, &options, None)
.await
.expect("sequencing itself must not fail");
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(stages, EXPECTED_ORDER.to_vec());
assert_eq!(stages.len(), super::sweep::TOTAL_STAGES);
assert!(
stats.any_failed(),
"an unconfigured JIRA sync should have been recorded as a failure"
);
assert!(
stats.failures().any(|o| o.stage == SweepStage::JiraSync),
"expected the jira sync stage among the failures, got {:?}",
stats.failures().map(|o| o.stage).collect::<Vec<_>>()
);
let jira_index = stages
.iter()
.position(|s| *s == SweepStage::JiraSync)
.expect("jira stage present");
assert_eq!(
&stages[jira_index + 1..],
&EXPECTED_ORDER[jira_index + 1..],
"stages after the failing jira-sync stage should still have run, but were missing"
);
}
#[tokio::test]
async fn sweep_runs_the_correlation_pass() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: None,
};
let stats = run_full_sweep(&Config::default(), &mut db, &options, None)
.await
.expect("sweep");
let correlate = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Correlate)
.expect("the sweep must run a correlation stage");
assert_eq!(
correlate.status,
StageStatus::Succeeded,
"correlation needs no credentials, so it must not fail: {:?}",
correlate.status
);
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(stages[0], SweepStage::Collect);
assert_eq!(stages[1], SweepStage::Correlate);
}
#[tokio::test]
async fn sweep_writes_reports_into_the_requested_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let out = dir.path().join("audit-out");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(out.clone()),
weeks: None,
};
let stats = run_full_sweep(&Config::default(), &mut db, &options, None)
.await
.expect("sweep");
let report = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Report)
.expect("report stage ran");
assert_eq!(
report.status,
StageStatus::Succeeded,
"report stage failed: {:?}",
report.status
);
assert!(
out.is_dir(),
"report stage did not create {}",
out.display()
);
let written: Vec<String> = std::fs::read_dir(&out)
.expect("read report dir")
.map(|e| {
e.expect("dir entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
written.len() > 1,
"report stage wrote {} file(s) into {}: {written:?}",
written.len(),
out.display()
);
for expected in [
crate::report::formatters::csv::SUMMARY_CSV,
crate::report::formatters::json::REPORT_JSON,
"pr-metrics.csv",
] {
assert!(
out.join(expected).is_file(),
"expected artifact {expected} missing from {}: {written:?}",
out.display()
);
}
}
#[tokio::test]
async fn sweep_emits_progress_for_non_collection_stages() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let bus = ProgressBus::new();
let stats = run_full_sweep(&Config::default(), &mut db, &options, Some(&bus))
.await
.expect("sweep");
let events = bus.drain();
assert_eq!(bus.dropped(), 0, "the run must fit in the default ring");
assert!(
!events.is_empty(),
"a bus handed to run_full_sweep received nothing at all"
);
for stage in [SweepStage::Classify, SweepStage::Report] {
let mine: Vec<_> = events
.iter()
.filter(|e| e.stage == Stage::Audit && e.target == stage.as_str())
.collect();
assert!(
mine.iter().any(|e| !e.is_terminal()),
"no start event for the {stage} stage: {events:#?}"
);
assert!(
mine.iter().any(|e| e.is_terminal()),
"no completion event for the {stage} stage: {events:#?}"
);
}
for outcome in &stats.outcomes {
let terminal = events
.iter()
.find(|e| {
e.stage == Stage::Audit && e.target == outcome.stage.as_str() && e.is_terminal()
})
.unwrap_or_else(|| panic!("no terminal event for {}", outcome.stage));
let announced_failure = terminal
.outcome
.as_ref()
.is_some_and(|o| matches!(o, crate::core::progress::Outcome::Failed { .. }));
assert_eq!(
announced_failure,
outcome.status.is_failure(),
"{} announced and recorded different verdicts",
outcome.stage
);
}
}
#[tokio::test]
async fn a_disabled_bus_stays_a_no_op() {
let dir = tempfile::tempdir().expect("tempdir");
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let bus = ProgressBus::disabled();
let stats = run_full_sweep(&Config::default(), &mut db, &options, Some(&bus))
.await
.expect("sweep");
assert!(bus.drain().is_empty(), "a disabled bus queued events");
assert_eq!(bus.dropped(), 0);
let stages: Vec<_> = stats.outcomes.iter().map(|o| o.stage).collect();
assert_eq!(
stages,
EXPECTED_ORDER.to_vec(),
"sequencing must not depend on whether anybody is watching"
);
}
#[derive(Parser, Debug)]
struct AuditOnly {
#[command(flatten)]
args: AuditArgs,
}
#[test]
fn audit_args_parse_every_flag() {
let parsed = AuditOnly::try_parse_from([
"tga-audit",
"--org",
"acme",
"--title",
"Acme DD",
"--analyst",
"J. Reviewer",
"--client",
"Acme Holdings",
"--output",
"/tmp/acme-dd",
"--weeks",
"26",
"--no-render",
])
.expect("flags must parse");
assert_eq!(parsed.args.org.as_deref(), Some("acme"));
assert_eq!(parsed.args.title.as_deref(), Some("Acme DD"));
assert_eq!(parsed.args.analyst.as_deref(), Some("J. Reviewer"));
assert_eq!(parsed.args.client.as_deref(), Some("Acme Holdings"));
assert_eq!(
parsed.args.output.as_deref(),
Some(std::path::Path::new("/tmp/acme-dd"))
);
assert_eq!(parsed.args.weeks, Some(26));
assert!(parsed.args.no_render);
}
#[test]
fn audit_runs_with_no_flags_at_all() {
let parsed = AuditOnly::try_parse_from(["tga-audit"]).expect("bare invocation must parse");
assert!(parsed.args.org.is_none());
assert!(parsed.args.output.is_none());
assert!(!parsed.args.no_render);
}
#[test]
fn audit_takes_no_positional_arguments() {
let err = AuditOnly::try_parse_from(["tga-audit", "acme"])
.expect_err("a positional argument must be rejected");
let rendered = err.to_string();
assert!(
rendered.contains("acme"),
"rejection must name the offending argument, got: {rendered}"
);
}
#[test]
fn audit_args_expose_a_complete_clap_command() {
let cmd = AuditArgs::augment_args(clap::Command::new("audit"));
let names: Vec<_> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
for expected in ["org", "title", "analyst", "client", "output", "weeks"] {
assert!(
names.contains(&expected),
"missing --{expected} in {names:?}"
);
}
let matches = cmd
.try_get_matches_from(["audit", "--analyst", "me"])
.expect("parse");
let args = AuditArgs::from_arg_matches(&matches).expect("from_arg_matches");
assert_eq!(args.analyst.as_deref(), Some("me"));
}
const NO_SECRETS: &[&str] = &[];
#[test]
fn sweep_gap_lines_name_each_failed_stage() {
let mut stats = AuditSweepStats::default();
stats.record(SweepStage::Collect, Instant::now(), Ok(()));
stats.record(
SweepStage::JiraSync,
Instant::now(),
Err(anyhow::anyhow!("no JIRA project configured")),
);
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("fact_deployments is empty")),
);
let lines = crate::audit::sweep_gap_lines(&stats, NO_SECRETS);
assert_eq!(lines.len(), 2, "one line per failure, none for success");
assert!(lines[0].contains("`jira sync`"), "{}", lines[0]);
assert!(
lines[0].contains("no JIRA project configured"),
"{}",
lines[0]
);
assert!(lines[1].contains("`dora`"), "{}", lines[1]);
for line in &lines {
assert!(line.contains("not assessed"), "{line}");
}
assert_eq!(lines, crate::audit::sweep_gap_lines(&stats, NO_SECRETS));
}
#[tokio::test]
async fn a_repo_that_fell_back_to_stale_local_refs_is_named_in_the_gap_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_path = dir.path().join("acme-service");
init_repo_with_dead_origin(
&repo_path,
"sshx://git@example.invalid/acme/acme-service.git",
);
let mut config = Config::default();
config
.repositories
.push(crate::core::config::RepositoryConfig {
name: Some("acme-service".to_string()),
path: repo_path,
branch: None,
since_date: None,
until_date: None,
org: None,
head_only: false,
fetch_timeout_secs: None,
});
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let stats = run_full_sweep(&config, &mut db, &options, None)
.await
.expect("sweep");
let collect = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Collect)
.expect("collect stage ran");
assert_eq!(
collect.status,
StageStatus::Succeeded,
"collect should still report ok under --allow-stale: {:?}",
collect.status
);
let lines = crate::audit::sweep_gap_lines(&stats, NO_SECRETS);
let stale = lines
.iter()
.find(|l| l.contains("acme-service"))
.unwrap_or_else(|| {
panic!("no Gaps & Caveats line names the repo that fell back to stale refs: {lines:#?}")
});
assert!(
stale.contains("origin"),
"the line must name the affected remote: {stale}"
);
assert!(
stale.contains("behind the true remote state"),
"the line must say the data may be out of date: {stale}"
);
}
fn init_repo_with_dead_origin(path: &std::path::Path, remote_url: &str) {
std::fs::create_dir_all(path).expect("mkdir");
let repo = git2::Repository::init(path).expect("git init");
repo.remote("origin", remote_url).expect("add origin");
std::fs::write(path.join("README.md"), "acme").expect("write file");
let mut index = repo.index().expect("index");
index
.add_path(std::path::Path::new("README.md"))
.expect("index add");
index.write().expect("index write");
let tree = repo
.find_tree(index.write_tree().expect("write_tree"))
.expect("find_tree");
let sig = git2::Signature::now("Test", "t@example.com").expect("signature");
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.expect("commit");
}
#[test]
fn a_credential_in_a_fetch_error_never_reaches_the_gap_line() {
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("fact_deployments is empty")),
);
stats.record_stale_fetch(crate::audit::StaleFetch {
repo: "acme-service".to_string(),
remote: "origin".to_string(),
error: "failed to connect to https://x-access-token:ghp_SECRETVALUE@github.com/acme/svc"
.to_string(),
});
let lines = crate::audit::sweep_gap_lines(&stats, &["ghp_SECRETVALUE"]);
assert_eq!(lines.len(), 2, "{lines:#?}");
assert!(lines[0].contains("`dora`"), "{}", lines[0]);
assert!(lines[1].contains("acme-service"), "{}", lines[1]);
assert!(
!lines[1].contains("ghp_SECRETVALUE"),
"the token survived into the report: {}",
lines[1]
);
}
#[tokio::test]
async fn a_declared_absent_leg_is_named_in_the_gap_lines() {
let dir = tempfile::tempdir().expect("tempdir");
let repo_path = dir.path().join("acme-service");
init_repo_with_dead_origin(
&repo_path,
"sshx://git@example.invalid/acme/acme-service.git",
);
let mut config = Config::default();
config.github = Some(crate::core::config::GithubConfig {
repo: None,
work_items_unavailable: Some(
"the checkout at /srv/acme-service names no GitHub remote".to_string(),
),
..Default::default()
});
config
.repositories
.push(crate::core::config::RepositoryConfig {
name: Some("acme-service".to_string()),
path: repo_path,
branch: None,
since_date: None,
until_date: None,
org: None,
head_only: false,
fetch_timeout_secs: None,
});
let mut db = Database::open(&dir.path().join("tga.db")).expect("open db");
let options = SweepOptions {
output: Some(dir.path().join("out")),
weeks: Some(1),
};
let stats = run_full_sweep(&config, &mut db, &options, None)
.await
.expect("sweep");
let collect = stats
.outcomes
.iter()
.find(|o| o.stage == SweepStage::Collect)
.expect("collect stage ran");
assert_eq!(
collect.status,
StageStatus::Succeeded,
"a declared-absent leg must not fail the stage: {:?}",
collect.status
);
let lines = crate::audit::sweep_gap_lines(&stats, NO_SECRETS);
let declared = lines
.iter()
.find(|l| l.contains("GitHub work items"))
.unwrap_or_else(|| panic!("no Gaps line names the declared-absent leg: {lines:#?}"));
assert!(
declared.contains("was not attempted"),
"the line must say the leg never ran: {declared}"
);
assert!(
declared.contains("names no GitHub remote"),
"the line must carry the declared reason: {declared}"
);
assert!(
declared.contains("unassessed"),
"the line must mark the affected sections unassessed: {declared}"
);
}
#[test]
fn a_credential_in_a_declared_reason_never_reaches_the_gap_line() {
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Dora,
Instant::now(),
Err(anyhow::anyhow!("fact_deployments is empty")),
);
stats.record_declared_skip(crate::audit::DeclaredSkip {
leg: "GitHub work items".to_string(),
reason: "no remote at https://x-access-token:ghp_SECRETVALUE@github.com/acme/svc"
.to_string(),
});
let lines = crate::audit::sweep_gap_lines(&stats, &["ghp_SECRETVALUE"]);
assert_eq!(lines.len(), 2, "{lines:#?}");
assert!(lines[0].contains("`dora`"), "{}", lines[0]);
assert!(lines[1].contains("GitHub work items"), "{}", lines[1]);
assert!(
!lines[1].contains("ghp_SECRETVALUE"),
"the token survived into the report: {}",
lines[1]
);
}
#[test]
fn sweep_gap_lines_are_empty_for_a_clean_run() {
let mut stats = AuditSweepStats::default();
for stage in EXPECTED_ORDER {
stats.record(stage, Instant::now(), Ok(()));
}
assert!(crate::audit::sweep_gap_lines(&stats, NO_SECRETS).is_empty());
}
#[test]
fn long_stage_reasons_are_truncated() {
let mut stats = AuditSweepStats::default();
stats.record(
SweepStage::Collect,
Instant::now(),
Err(anyhow::anyhow!("x".repeat(4000))),
);
let line = crate::audit::sweep_gap_lines(&stats, NO_SECRETS).remove(0);
assert!(line.contains('…'), "a long reason is excerpted: {line}");
assert!(
line.chars().count() < 400,
"one verbose error must not dominate the Gaps section ({} chars)",
line.chars().count()
);
}
#[test]
fn data_handling_note_is_a_pending_claim() {
let note = crate::audit::DATA_HANDLING_NOTE;
assert!(note.contains("pending"), "{note}");
assert!(note.contains("#5218"), "{note}");
assert!(note.contains("no file content, diffs, patches, hunks, or blobs"));
assert!(!note.contains("no code"), "{note}");
}
#[test]
fn artifact_paths_are_parsed_from_stdout() {
let stdout = " /out/acme.md\n\n/out/acme.json\n";
let paths = crate::audit::artifact_paths(stdout);
assert_eq!(
paths,
vec![
std::path::PathBuf::from("/out/acme.md"),
std::path::PathBuf::from("/out/acme.json")
]
);
assert!(crate::audit::artifact_paths("").is_empty());
}
#[tokio::test]
async fn missing_binary_is_a_named_actionable_error() {
let dir = tempfile::tempdir().expect("tempdir");
let manifest = dir.path().join("manifest.toml");
std::fs::write(&manifest, "[report]\ntitle = \"T\"\n").expect("write");
let missing = dir.path().join("definitely-not-installed");
let err =
super::review::run_review_report_with(missing.display().to_string(), &manifest, dir.path())
.await
.expect_err("a missing binary must be an error");
let msg = err.to_string();
assert!(
matches!(err, crate::audit::ReviewRunError::BinaryNotFound { .. }),
"{msg}"
);
assert!(
msg.contains("TRUSTY_REVIEW_BIN"),
"names the override: {msg}"
);
assert!(msg.contains("cargo install"), "names the fix: {msg}");
assert!(
msg.contains(&manifest.display().to_string()),
"the written manifest is still usable and must be named: {msg}"
);
}
#[test]
fn binary_resolution_prefers_the_env_override() {
use super::review::binary_from_override;
assert_eq!(
binary_from_override(Some("/opt/bin/trusty-review")),
"/opt/bin/trusty-review"
);
assert_eq!(
binary_from_override(Some("")),
crate::audit::DEFAULT_REVIEW_BIN,
"an empty override falls back to the PATH lookup"
);
assert_eq!(binary_from_override(None), crate::audit::DEFAULT_REVIEW_BIN);
let resolved = crate::audit::resolve_review_binary();
assert!(!resolved.is_empty(), "{resolved}");
}
#[test]
fn invocation_requests_inference() {
use std::path::Path;
let args = super::review::report_args(Path::new("/o/manifest.toml"), Path::new("/o"));
let rendered: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
rendered,
vec![
"report",
"--manifest",
"/o/manifest.toml",
"--analyze",
"--synthesize",
"--out",
"/o",
],
"the audit's renderer invocation must request inference"
);
}
#[test]
fn absent_credential_is_a_named_actionable_error() {
use super::review::credential_is_present;
assert!(!credential_is_present(None), "unset is absent");
assert!(!credential_is_present(Some("")), "empty is absent");
assert!(
!credential_is_present(Some(" \n")),
"whitespace-only is absent"
);
let msg = crate::audit::MissingInferenceCredential.to_string();
assert!(
msg.contains(crate::audit::ENV_INFERENCE_CREDENTIAL),
"names the variable: {msg}"
);
assert!(
msg.contains("export OPENROUTER_API_KEY="),
"says how to set it: {msg}"
);
}
#[test]
fn present_credential_passes_the_precheck() {
use super::review::credential_is_present;
let secret = "sk-or-v1-DEADBEEFdeadbeef";
assert!(credential_is_present(Some(secret)));
let msg = crate::audit::MissingInferenceCredential.to_string();
assert!(!msg.contains(secret), "no key material may appear: {msg}");
assert!(!msg.contains("sk-or"), "no key-shaped text: {msg}");
}
fn successful_run_over(dir: &std::path::Path, report_json: &str) -> crate::audit::ReviewRun {
let md = dir.join("2026-08-11-acme.md");
let json = dir.join("2026-08-11-acme.json");
std::fs::write(&md, "# Acme\n").expect("write md");
std::fs::write(&json, report_json).expect("write json");
crate::audit::ReviewRun {
success: true,
code: Some(0),
stdout: format!("{}\n{}\n", md.display(), json.display()),
stderr: String::new(),
artifacts: vec![md, json],
}
}
const DEGRADED_0_14_REPORT: &str = r#"{
"title": "Acme — Technical Due Diligence",
"synthesis": {
"status": { "state": "unavailable", "reason": "provider build failed: 401 Unauthorized" },
"top_risks": [],
"findings": [],
"notes": []
}
}"#;
#[test]
fn exit_zero_over_a_narrative_free_report_is_a_failure() {
let dir = tempfile::tempdir().expect("tempdir");
let run = successful_run_over(dir.path(), DEGRADED_0_14_REPORT);
assert!(run.success, "the child exited 0 — that is the whole point");
let err = crate::audit::require_rendered_report_carries_synthesis(&run)
.expect_err("a report with no written analysis must not pass as a successful audit");
let msg = err.to_string();
assert!(
msg.contains("trusty-review") && msg.contains("predates"),
"the message must name the stale renderer as the cause: {msg}"
);
assert!(
msg.contains("tctl install trusty-review"),
"the message must name the upgrade that fixes it: {msg}"
);
use crate::audit::review::json_carries_synthesis;
assert_eq!(json_carries_synthesis(DEGRADED_0_14_REPORT), Some(false));
assert_eq!(json_carries_synthesis(r#"{"title": "Acme"}"#), Some(false));
}
#[test]
fn a_synthesized_report_passes_the_check() {
use crate::audit::review::json_carries_synthesis;
let dir = tempfile::tempdir().expect("tempdir");
let full = r#"{
"title": "Acme",
"synthesis": {
"executive_summary": "Two of three applications carry RED findings.",
"top_risks": [{"description": "No tests", "severity": "RED", "cost": "high", "apps": "web"}],
"findings": [],
"notes": []
}
}"#;
crate::audit::require_rendered_report_carries_synthesis(&successful_run_over(dir.path(), full))
.expect("a synthesized report passes");
assert_eq!(
json_carries_synthesis(
r#"{"synthesis": {"top_risks": [{"description": "x"}], "findings": [], "notes": ["synthesis: rejected (unverified figure)"]}}"#
),
Some(true)
);
assert_eq!(
json_carries_synthesis(r#"{"synthesis": {"top_risks": [], "findings": [{"title": "x"}]}}"#),
Some(true)
);
assert_eq!(
json_carries_synthesis(r#"{"synthesis": {"executive_summary": " "}}"#),
Some(false)
);
}
#[test]
fn an_uncheckable_report_fails_rather_than_passes() {
use crate::audit::review::json_carries_synthesis;
let no_json = crate::audit::ReviewRun {
success: true,
code: Some(0),
stdout: "/o/report.md\n".to_string(),
stderr: String::new(),
artifacts: vec![std::path::PathBuf::from("/o/report.md")],
};
let err = crate::audit::require_rendered_report_carries_synthesis(&no_json)
.expect_err("no .json artifact means nothing can be asserted about the report");
assert!(err.to_string().contains("could not be checked"), "{err}");
let dir = tempfile::tempdir().expect("tempdir");
let err = crate::audit::require_rendered_report_carries_synthesis(&successful_run_over(
dir.path(),
"not json at all",
))
.expect_err("an unparseable report cannot be claimed to carry a narrative");
assert!(err.to_string().contains("could not be checked"), "{err}");
assert_eq!(json_carries_synthesis("not json at all"), None);
}
#[test]
fn stale_renderer_is_rejected_before_the_sweep() {
use crate::audit::review::{parse_review_version, version_verdict};
use crate::audit::MIN_REVIEW_VERSION;
assert_eq!(MIN_REVIEW_VERSION, (0, 15, 0));
let err = version_verdict("trusty-review", "trusty-review 0.14.1\n")
.expect_err("a pre-0.15 renderer must not clear the preflight");
let msg = err.to_string();
for needle in ["0.14.1", "0.15.0", "tctl install trusty-review", "exits 0"] {
assert!(msg.contains(needle), "missing {needle:?}: {msg}");
}
for ok in ["trusty-review 0.15.0", "trusty-review 0.15.1", "tr v1.0.0"] {
version_verdict("trusty-review", ok).unwrap_or_else(|e| panic!("{ok} must pass: {e}"));
}
assert_eq!(
parse_review_version("trusty-review 0.15.0-rc.1+build.7"),
Some((0, 15, 0))
);
for unreadable in ["", "\n\n", "trusty-review", "trusty-review unknown"] {
assert_eq!(parse_review_version(unreadable), None, "{unreadable:?}");
version_verdict("trusty-review", unreadable)
.unwrap_or_else(|e| panic!("{unreadable:?} must proceed, not fail: {e}"));
}
}
pub(super) fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind an ephemeral port");
let port = listener
.local_addr()
.expect("read the bound address")
.port();
drop(listener);
port
}
async fn serve_health(degraded_replies: usize) -> u16 {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral port");
let port = listener
.local_addr()
.expect("read the bound address")
.port();
let served = Arc::new(AtomicUsize::new(0));
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let served = Arc::clone(&served);
tokio::spawn(async move {
use tokio::io::AsyncWriteExt as _;
let nth = served.fetch_add(1, Ordering::SeqCst);
let reply: &[u8] = if nth < degraded_replies {
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"
} else {
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
};
let _ = stream.write_all(reply).await;
});
}
});
port
}
async fn serve_healthy() -> u16 {
serve_health(0).await
}
#[cfg(unix)]
fn stub_binary(dir: &std::path::Path, name: &str, script: &str) -> String {
use std::os::unix::fs::PermissionsExt as _;
let stub = dir.join(name);
std::fs::write(&stub, script).expect("write the stub");
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755))
.expect("make the stub executable");
stub.to_str().expect("a UTF-8 temp path").to_string()
}
fn serve_analyze_health(
dir: &std::path::Path,
verdict: impl Fn() -> bool + Send + Sync + 'static,
) -> std::path::PathBuf {
let socket = dir.join("sockets").join("trusty-analyze.sock");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind the stub socket");
tokio::spawn(async move {
let verdict = std::sync::Arc::new(verdict);
while let Ok((mut conn, _)) = listener.accept().await {
let verdict = std::sync::Arc::clone(&verdict);
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let mut sink = Vec::new();
let _ = conn.read_to_end(&mut sink).await;
let status = if verdict() { "ok" } else { "degraded" };
let reply = format!(
"{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\
{{\"status\":\"{status}\",\"version\":\"0.0.0\",\
\"search_reachable\":{}}}}}\n",
verdict()
);
let _ = conn.write_all(reply.as_bytes()).await;
let _ = conn.flush().await;
});
}
});
socket
}
fn guard_on(socket: std::path::PathBuf, binary: &str) -> crate::audit::AnalyzeGuard {
crate::audit::AnalyzeGuard {
socket,
binary: binary.to_string(),
startup_timeout: std::time::Duration::from_millis(400),
poll_interval: std::time::Duration::from_millis(50),
}
}
fn absent_socket(dir: &std::path::Path) -> std::path::PathBuf {
dir.join("absent.sock")
}
#[test]
fn analyze_resolution_prefers_the_env_overrides() {
use crate::audit::analyze::{binary_from_override, socket_from_override};
use crate::audit::{default_analyze_socket, DEFAULT_ANALYZE_BIN};
assert_eq!(
binary_from_override(Some("/pinned/trusty-analyze")),
"/pinned/trusty-analyze"
);
assert_eq!(binary_from_override(None), DEFAULT_ANALYZE_BIN);
assert_eq!(binary_from_override(Some("")), DEFAULT_ANALYZE_BIN);
assert_eq!(
socket_from_override(Some("/tmp/pinned-analyze.sock")).expect("an override cannot fail"),
std::path::PathBuf::from("/tmp/pinned-analyze.sock")
);
let derived = default_analyze_socket().expect("resolve the default socket");
assert_eq!(socket_from_override(None).expect("fallback"), derived);
assert_eq!(socket_from_override(Some("")).expect("fallback"), derived);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_reachable_analyze_daemon_is_not_restarted() {
let dir = tempfile::tempdir().expect("create a temp dir");
let socket = serve_analyze_health(dir.path(), || true);
let guard = guard_on(socket, "/nonexistent/trusty-analyze");
crate::audit::ensure_analyze_daemon_with(&guard)
.await
.expect("a healthy daemon must satisfy the preflight without a spawn");
}
#[tokio::test]
async fn an_unspawnable_analyze_binary_refuses_the_audit() {
let dir = tempfile::tempdir().expect("create a temp dir");
let guard = guard_on(absent_socket(dir.path()), "/nonexistent/trusty-analyze");
let err = crate::audit::ensure_analyze_daemon_with(&guard)
.await
.expect_err("a binary that cannot be spawned must stop the audit");
let msg = err.to_string();
for needle in [
"trusty-analyze",
"trusty-search",
"/nonexistent/trusty-analyze",
] {
assert!(msg.contains(needle), "missing {needle:?}: {msg}");
}
}
#[test]
fn the_spawn_arguments_are_a_bare_serve() {
use crate::audit::analyze::serve_args;
assert_eq!(serve_args(), vec!["serve"]);
}
#[cfg(unix)]
#[tokio::test]
async fn an_analyze_daemon_that_never_comes_up_refuses_the_audit() {
let dir = tempfile::tempdir().expect("create a temp dir");
let stub = stub_binary(dir.path(), "trusty-analyze-stub", "#!/bin/sh\nexit 1\n");
let guard = guard_on(absent_socket(dir.path()), &stub);
let err = crate::audit::ensure_analyze_daemon_with(&guard)
.await
.expect_err("a daemon that exits at once must stop the audit");
assert!(
err.cause.contains("did not answer healthy"),
"expected the readiness arm, got: {}",
err.cause
);
let msg = err.to_string();
for needle in ["trusty-search start", "reads as a clean bill of health"] {
assert!(msg.contains(needle), "missing {needle:?}: {msg}");
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn a_degraded_analyze_daemon_refuses_the_audit() {
let dir = tempfile::tempdir().expect("create a temp dir");
let stub = stub_binary(dir.path(), "trusty-analyze-stub", "#!/bin/sh\nexit 1\n");
let socket = serve_analyze_health(dir.path(), || false);
let guard = guard_on(socket, &stub);
let err = crate::audit::ensure_analyze_daemon_with(&guard)
.await
.expect_err("a daemon answering degraded must stop the audit");
assert!(
err.cause.contains("did not answer healthy"),
"expected the readiness arm against a live-but-degraded daemon, got: {}",
err.cause
);
assert!(
err.to_string().contains("trusty-search start"),
"the refusal must name trusty-search: {err}"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn two_concurrent_guards_both_resolve_against_one_slow_daemon() {
let dir = tempfile::tempdir().expect("create a temp dir");
let spawn_log = dir.path().join("spawns.log");
let stub = stub_binary(
dir.path(),
"trusty-analyze-stub",
&format!(
"#!/bin/sh\necho \"$@\" >> {}\n",
spawn_log.to_str().expect("a UTF-8 temp path")
),
);
let gate = spawn_log.clone();
let socket = serve_analyze_health(dir.path(), move || {
std::fs::read_to_string(&gate)
.unwrap_or_default()
.lines()
.count()
>= 2
});
let expected_socket = socket.clone();
let mut guard = guard_on(socket, &stub);
guard.startup_timeout = std::time::Duration::from_secs(60);
let (first, second) = tokio::join!(
crate::audit::ensure_analyze_daemon_with(&guard),
crate::audit::ensure_analyze_daemon_with(&guard),
);
first.expect("the first concurrent guard must resolve");
second.expect("the second concurrent guard must resolve");
assert_eq!(guard.socket, expected_socket);
assert_eq!(guard.binary, stub);
let spawned = settled_spawn_log(&spawn_log).await;
assert_eq!(
spawned.len(),
2,
"each call spawns for its own missed probe; got {spawned:?}"
);
for line in &spawned {
assert_eq!(
line.trim(),
"serve",
"#6287: every spawn is a bare `serve` — the daemon derives its own socket"
);
}
}
async fn settled_spawn_log(spawn_log: &std::path::Path) -> Vec<String> {
const STABLE_ROUNDS: usize = 4;
let round = std::time::Duration::from_millis(25);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let read = || -> Vec<String> {
std::fs::read_to_string(spawn_log)
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
};
let mut lines = read();
let mut stable = 0usize;
while stable < STABLE_ROUNDS && std::time::Instant::now() < deadline {
tokio::time::sleep(round).await;
let next = read();
stable = if next.len() == lines.len() {
stable + 1
} else {
0
};
lines = next;
}
lines
}
use crate::audit::repo_index::{binary_from_override as search_binary_from_override, index_args};
use crate::audit::{
index_gap_lines, index_id_for, RepoIndexOutcome, RepoIndexStatus, DEFAULT_SEARCH_BIN,
};
use crate::report::dd_manifest::{
build_dd_manifest, dd_repository_entries, DdManifestOptions, DdRepositoryEntry,
};
fn repo_entry(name: &str, path: &std::path::Path) -> DdRepositoryEntry {
DdRepositoryEntry {
name: name.to_string(),
path: path.to_path_buf(),
authorship: None,
}
}
#[test]
fn search_binary_resolution_prefers_the_env_override() {
assert_eq!(
search_binary_from_override(Some("/opt/bin/trusty-search")),
"/opt/bin/trusty-search"
);
assert_eq!(search_binary_from_override(Some("")), DEFAULT_SEARCH_BIN);
assert_eq!(search_binary_from_override(None), DEFAULT_SEARCH_BIN);
assert!(!crate::audit::resolve_search_binary().is_empty());
}
#[test]
fn the_index_id_distinguishes_two_checkouts_of_one_repo() {
use std::path::Path;
let one = index_id_for(Path::new("/src/northwind-web")).expect("id");
let other = index_id_for(Path::new("/w/repos/local/northwind-web")).expect("id");
assert_ne!(one, other, "{one} vs {other}");
assert!(one.starts_with("northwind-web-"), "{one}");
assert_eq!(
index_id_for(Path::new("/src/northwind-web/")).as_deref(),
Some(one.as_str()),
"a trailing separator is not a component"
);
assert_eq!(
index_id_for(Path::new("/src/northwind-web/.")).as_deref(),
Some(one.as_str()),
"`base_dir.join(\".\")` is what a `path: .` config entry anchors to"
);
assert_eq!(index_id_for(Path::new("/")), None);
}
#[test]
fn the_index_id_is_the_shared_derivation() {
use std::path::Path;
for path in ["/src/northwind-web", "/w/repos/local/northwind-web", "/"] {
let path = Path::new(path);
assert_eq!(
index_id_for(path),
trusty_common::derive_checkout_index_id(path),
"{}",
path.display()
);
}
}
#[test]
fn index_ids_match_the_manifest_paths_the_renderer_reads() {
let cfg = Config {
repositories: vec![
crate::core::config::RepositoryConfig {
path: std::path::PathBuf::from("/src/northwind-web"),
name: Some("Northwind Web".to_string()),
..Default::default()
},
crate::core::config::RepositoryConfig {
path: std::path::PathBuf::from("checkouts/northwind-api"),
..Default::default()
},
],
..Default::default()
};
let base_dir = std::path::PathBuf::from("/work");
let entries = dd_repository_entries(&cfg, &base_dir);
let manifest = build_dd_manifest(
&cfg,
&DdManifestOptions {
title: "T".to_string(),
base_dir: base_dir.clone(),
..Default::default()
},
)
.expect("builds");
assert_eq!(
entries, manifest.repositories,
"the indexer must be handed the manifest's own entries"
);
let ids: Vec<Option<String>> = entries.iter().map(|e| index_id_for(&e.path)).collect();
assert_eq!(
ids,
entries
.iter()
.map(|e| trusty_common::derive_checkout_index_id(&e.path))
.collect::<Vec<_>>(),
"each id derives from the path written into manifest.toml, through the \
function the renderer calls"
);
assert!(
ids[0]
.as_deref()
.is_some_and(|id| id.starts_with("northwind-web-")),
"{ids:?}"
);
assert!(
ids[1]
.as_deref()
.is_some_and(|id| id.starts_with("northwind-api-")),
"{ids:?}"
);
}
#[test]
fn the_index_invocation_names_the_path_and_the_id() {
use crate::audit::repo_index::probe_args;
use std::path::Path;
let rendered = |args: Vec<std::ffi::OsString>| {
args.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
assert_eq!(
rendered(index_args(Path::new("/src/acme-web"), "acme-web")),
vec!["index", "/src/acme-web", "--name", "acme-web"]
);
assert_eq!(
rendered(probe_args("acme-web")),
vec!["index-status", "acme-web"]
);
}
#[cfg(unix)]
fn search_stub(dir: &std::path::Path, log: &std::path::Path) -> String {
let script = format!(
"#!/bin/sh\n\
echo \"$@\" >> {log}\n\
case \"$1\" in\n\
index-status)\n\
case \"$2\" in *served*) exit 0 ;; *) exit 1 ;; esac ;;\n\
index)\n\
case \"$2\" in\n\
*broken*) echo 'progress: walking' >&2; echo 'error: not a directory' >&2; exit 3 ;;\n\
*) exit 0 ;;\n\
esac ;;\n\
esac\n\
exit 9\n",
log = log.display()
);
stub_binary(dir, "trusty-search-stub", &script)
}
#[cfg(unix)]
fn stub_log(log: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(log)
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(unix)]
#[tokio::test]
async fn an_unindexed_repository_is_indexed_before_the_render() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("invocations");
let stub = search_stub(dir.path(), &log);
let entries = vec![repo_entry("Acme Web", &dir.path().join("acme-web"))];
let outcomes = crate::audit::repo_index::ensure_repositories_indexed_with(stub, &entries).await;
assert_eq!(outcomes.len(), 1);
assert_eq!(outcomes[0].status, RepoIndexStatus::Indexed);
let id = index_id_for(&dir.path().join("acme-web")).expect("id");
assert_eq!(outcomes[0].index_id.as_deref(), Some(id.as_str()));
let calls = stub_log(&log);
assert_eq!(calls.len(), 2, "one probe, then one index: {calls:?}");
assert_eq!(calls[0], format!("index-status {id}"));
assert!(
calls[1].starts_with("index ") && calls[1].ends_with(&format!("--name {id}")),
"the index is built under the id the renderer looks up: {calls:?}"
);
assert!(
index_gap_lines(&outcomes, &[] as &[String]).is_empty(),
"an indexed repository is not a gap"
);
}
#[cfg(unix)]
#[tokio::test]
async fn an_already_indexed_repository_is_not_reindexed() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("invocations");
let stub = search_stub(dir.path(), &log);
let entries = vec![repo_entry("Acme Web", &dir.path().join("acme-served"))];
let outcomes = crate::audit::repo_index::ensure_repositories_indexed_with(stub, &entries).await;
assert_eq!(outcomes[0].status, RepoIndexStatus::AlreadyServed);
let id = index_id_for(&dir.path().join("acme-served")).expect("id");
let calls = stub_log(&log);
assert_eq!(
calls,
vec![format!("index-status {id}")],
"the probe is the only invocation — no reindex: {calls:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn one_repository_that_fails_to_index_does_not_stop_the_others() {
let dir = tempfile::tempdir().expect("tempdir");
let log = dir.path().join("invocations");
let stub = search_stub(dir.path(), &log);
let entries = vec![
repo_entry("Acme Web", &dir.path().join("acme-web")),
repo_entry("Acme Broken", &dir.path().join("acme-broken")),
repo_entry("Acme API", &dir.path().join("acme-api")),
];
let outcomes = crate::audit::repo_index::ensure_repositories_indexed_with(stub, &entries).await;
assert_eq!(outcomes[0].status, RepoIndexStatus::Indexed);
assert!(outcomes[1].failed(), "{:?}", outcomes[1]);
assert_eq!(
outcomes[2].status,
RepoIndexStatus::Indexed,
"the run must continue past the failure"
);
let calls = stub_log(&log);
assert!(
calls
.iter()
.any(|c| c.contains("acme-api") && c.starts_with("index ")),
"the repository after the failure is still indexed: {calls:?}"
);
let gaps = index_gap_lines(&outcomes, &[] as &[String]);
assert_eq!(gaps.len(), 1, "one line per distinct reason: {gaps:?}");
assert!(
gaps[0].contains("Acme Broken") && gaps[0].contains("not a directory"),
"the failure is named, with its cause: {}",
gaps[0]
);
assert!(
!gaps[0].contains("Acme Web") && !gaps[0].contains("Acme API"),
"a repository that indexed is not a gap: {}",
gaps[0]
);
assert!(
gaps[0].contains("not assessed, not clean"),
"an empty section must not read as a clean pass: {}",
gaps[0]
);
}
#[tokio::test]
async fn a_missing_search_binary_is_named_and_the_run_continues() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("definitely-not-installed");
let entries = vec![
repo_entry("Acme Web", &dir.path().join("acme-web")),
repo_entry("Acme API", &dir.path().join("acme-api")),
];
let outcomes = crate::audit::repo_index::ensure_repositories_indexed_with(
missing.display().to_string(),
&entries,
)
.await;
assert_eq!(outcomes.len(), 2, "every repository is still reported on");
assert!(outcomes.iter().all(RepoIndexOutcome::failed));
let gaps = index_gap_lines(&outcomes, &[] as &[String]);
assert_eq!(
gaps.len(),
1,
"one fault affecting every repository is one line, not N: {gaps:?}"
);
assert!(
gaps[0].contains("Acme Web") && gaps[0].contains("Acme API"),
"every unassessed application is named: {}",
gaps[0]
);
assert!(
gaps[0].contains("TRUSTY_SEARCH_BIN") && gaps[0].contains("cargo install trusty-search"),
"the remedy names both ways to supply the binary: {}",
gaps[0]
);
}
#[test]
fn index_gap_lines_are_empty_when_every_repository_is_served() {
let outcomes = vec![
RepoIndexOutcome {
repo: "Acme Web".to_string(),
index_id: Some("acme-web".to_string()),
status: RepoIndexStatus::AlreadyServed,
},
RepoIndexOutcome {
repo: "Acme API".to_string(),
index_id: Some("acme-api".to_string()),
status: RepoIndexStatus::Indexed,
},
];
assert!(index_gap_lines(&outcomes, &[] as &[String]).is_empty());
}
#[test]
fn a_credential_in_an_index_failure_never_reaches_the_gap_line() {
let token = "ghp_averyrealisticlookingtoken0123456789";
let outcomes = vec![RepoIndexOutcome {
repo: "Acme Web".to_string(),
index_id: Some("acme-web".to_string()),
status: RepoIndexStatus::Failed(format!(
"failed to clone https://{token}@github.com/acme/web.git"
)),
}];
let gaps = index_gap_lines(&outcomes, &[token.to_string()]);
assert_eq!(gaps.len(), 1);
assert!(!gaps[0].contains(token), "{}", gaps[0]);
assert!(
gaps[0].contains("Acme Web"),
"redaction must not cost the reader the repository name: {}",
gaps[0]
);
}
use crate::audit::search_daemon::start_args;
use crate::audit::{ensure_search_daemon_with, SearchGuard};
async fn serve_health_gated_on(flag: std::path::PathBuf) -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind an ephemeral port");
let port = listener
.local_addr()
.expect("read the bound address")
.port();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let flag = flag.clone();
tokio::spawn(async move {
use tokio::io::AsyncWriteExt as _;
let reply: &[u8] = if flag.exists() {
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"
} else {
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"
};
let _ = stream.write_all(reply).await;
});
}
});
port
}
fn search_guard_on(port: u16, binary: &str) -> SearchGuard {
SearchGuard {
url: format!("http://127.0.0.1:{port}"),
binary: binary.to_string(),
startup_timeout: std::time::Duration::from_millis(400),
poll_interval: std::time::Duration::from_millis(50),
}
}
#[test]
fn the_search_guard_resolves_its_address_from_the_shared_layout() {
use trusty_common::daemon_guard::DaemonAddrLayout;
let guard = SearchGuard::from_env();
assert_eq!(
guard.url,
DaemonAddrLayout::TRUSTY_SEARCH.resolve_base_url()
);
assert_eq!(guard.binary, crate::audit::resolve_search_binary());
assert_eq!(guard.startup_timeout, crate::audit::SEARCH_STARTUP_TIMEOUT);
}
#[test]
fn the_search_spawn_arguments_are_start_in_the_foreground() {
assert_eq!(start_args(), vec!["start", "--foreground"]);
}
#[tokio::test]
async fn a_reachable_search_daemon_is_not_restarted() {
let port = serve_healthy().await;
let guard = search_guard_on(port, "/nonexistent/trusty-search");
ensure_search_daemon_with(&guard)
.await
.expect("a healthy daemon must satisfy the preflight without a spawn");
}
#[tokio::test]
async fn an_unspawnable_search_binary_refuses_the_audit() {
let guard = search_guard_on(free_port(), "/nonexistent/trusty-search");
let err = ensure_search_daemon_with(&guard)
.await
.expect_err("a binary that cannot be spawned must stop the audit");
let msg = err.to_string();
for needle in [
"trusty-search",
"/nonexistent/trusty-search",
"TRUSTY_SEARCH_BIN",
] {
assert!(msg.contains(needle), "missing {needle:?}: {msg}");
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_search_daemon_that_never_comes_up_refuses_the_audit() {
let dir = tempfile::tempdir().expect("create a temp dir");
let stub = stub_binary(dir.path(), "trusty-search-stub", "#!/bin/sh\nexit 1\n");
let started = std::time::Instant::now();
let guard = search_guard_on(free_port(), &stub);
let err = ensure_search_daemon_with(&guard)
.await
.expect_err("a daemon that exits at once must stop the audit");
assert!(
err.cause.contains("did not become ready"),
"expected the readiness arm, got: {}",
err.cause
);
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"the refusal must be bounded by the budget, not hang: {:?}",
started.elapsed()
);
}
#[cfg(unix)]
const FORK_BUDGET: std::time::Duration = std::time::Duration::from_secs(20);
#[cfg(unix)]
const ANALYZE_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg(unix)]
const _: () = assert!(ANALYZE_BUDGET.as_secs() < FORK_BUDGET.as_secs());
#[cfg(unix)]
async fn degraded_stack(
dir: &std::path::Path,
) -> (SearchGuard, crate::audit::AnalyzeGuard, std::path::PathBuf) {
let flag = dir.join("trusty-search-is-up");
let search_stub = stub_binary(
dir,
"trusty-search-stub",
&format!(
"#!/bin/sh\ntouch {}\nsleep 30\n",
flag.to_str().expect("a UTF-8 temp path")
),
);
let analyze_stub = stub_binary(dir, "trusty-analyze-stub", "#!/bin/sh\nexit 1\n");
let mut search = search_guard_on(serve_health_gated_on(flag.clone()).await, &search_stub);
search.startup_timeout = FORK_BUDGET;
let gate = flag.clone();
let mut analyze = guard_on(
serve_analyze_health(dir, move || gate.exists()),
&analyze_stub,
);
analyze.startup_timeout = ANALYZE_BUDGET;
(search, analyze, flag)
}
#[cfg(unix)]
#[tokio::test]
async fn the_search_guard_recovers_a_stale_degraded_analyze_daemon() {
let ok_dir = tempfile::tempdir().expect("create a temp dir");
let (search, analyze, flag) = degraded_stack(ok_dir.path()).await;
assert!(!flag.exists(), "trusty-search starts this run down");
ensure_search_daemon_with(&search)
.await
.expect("the search guard must start trusty-search");
assert!(
flag.exists(),
"the search guard must have started the daemon"
);
crate::audit::ensure_analyze_daemon_with(&analyze)
.await
.expect("a stale 503 analyze daemon recovers once trusty-search is back");
let bad_dir = tempfile::tempdir().expect("create a temp dir");
let (search, analyze, flag) = degraded_stack(bad_dir.path()).await;
let err = crate::audit::ensure_analyze_daemon_with(&analyze)
.await
.expect_err("running the analyze guard first must refuse the audit");
assert!(
err.cause.contains("did not answer healthy"),
"the analyze preflight must fail at its readiness poll against the live degraded \
answer, got: {}",
err.cause
);
assert!(
!flag.exists(),
"nothing in the analyze preflight starts trusty-search — that is the defect"
);
ensure_search_daemon_with(&search)
.await
.expect("the search guard still works; it was simply run too late");
assert!(
flag.exists(),
"the late search guard must still have started the daemon"
);
}
#[test]
fn the_audit_command_runs_the_search_guard_before_the_analyze_guard() {
let source = include_str!("../commands/audit.rs");
let search = source
.find("ensure_search_daemon().await?")
.expect("`tga audit` must ensure trusty-search before its sweep");
let analyze = source
.find("ensure_analyze_daemon().await?")
.expect("`tga audit` must still ensure trusty-analyze");
assert!(
search < analyze,
"trusty-analyze cannot boot without trusty-search, so its guard must run second"
);
}