use just_shield::rules::Severity;
use std::path::Path;
use std::process::Command;
#[test]
fn detects_mutable_refs_with_file_and_line() {
let result = just_shield::scan(Path::new("tests/fixtures/violation")).unwrap();
assert_eq!(result.workflows_scanned, 1);
let r1: Vec<_> = result.findings.iter().filter(|f| f.rule == "R1").collect();
let lines: Vec<usize> = r1.iter().map(|f| f.line).collect();
assert_eq!(lines, vec![9, 10, 17, 18]);
for f in &result.findings {
assert!(f.file.contains("ci.yml"));
assert!(!f.evidence.is_empty(), "모든 발견에는 근거가 붙어야 한다");
assert!(
!f.fix_hint.is_empty(),
"모든 발견에는 해결 힌트가 붙어야 한다"
);
}
assert_eq!(r1[0].severity, Severity::Info);
assert_eq!(r1[1].severity, Severity::High);
assert_eq!(r1[2].severity, Severity::High);
assert_eq!(r1[3].severity, Severity::High);
assert_eq!(r1[1].uses, "aquasecurity/trivy-action@master");
}
#[test]
fn clean_workflows_pass_silently() {
let result = just_shield::scan(Path::new("tests/fixtures/clean")).unwrap();
assert_eq!(result.workflows_scanned, 1);
assert!(result.findings.is_empty());
}
#[test]
fn missing_workflows_dir_is_not_an_error() {
let result = just_shield::scan(Path::new("tests/fixtures")).unwrap();
assert_eq!(result.workflows_scanned, 0);
assert!(result.findings.is_empty());
}
#[test]
fn official_actions_are_info_and_never_fail() {
let result = just_shield::scan(Path::new("tests/fixtures/official")).unwrap();
assert_eq!(result.findings.len(), 2);
assert!(result.findings.iter().all(|f| f.severity == Severity::Info));
assert_eq!(just_shield::report::exit_code(&result, false), 0);
assert_eq!(just_shield::report::exit_code(&result, true), 0);
}
#[test]
fn same_owner_actions_are_first_party_and_silent() {
let root = std::env::temp_dir().join(format!("just-shield-it-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::create_dir_all(root.join(".github").join("workflows")).unwrap();
std::fs::write(
root.join(".git").join("config"),
"[remote \"origin\"]\n\turl = https://github.com/myorg/myrepo.git\n",
)
.unwrap();
std::fs::write(
root.join(".github").join("workflows").join("ci.yml"),
"on: push\npermissions:\n contents: read\njobs:\n b:\n steps:\n - uses: myorg/internal-action@v1\n - uses: evil/other-action@v1\n",
)
.unwrap();
let result = just_shield::scan(&root).unwrap();
let _ = std::fs::remove_dir_all(&root);
assert_eq!(result.findings.len(), 1);
assert!(result.findings[0].uses.starts_with("evil/"));
assert_eq!(result.findings[0].severity, Severity::High);
}
#[test]
fn blast_radius_rules_fire_together() {
let result = just_shield::scan(Path::new("tests/fixtures/blast")).unwrap();
let by_rule = |r: &str| -> Vec<_> { result.findings.iter().filter(|f| f.rule == r).collect() };
assert!(by_rule("R1").is_empty());
let r8 = by_rule("R8");
assert_eq!(r8.len(), 1);
assert_eq!(r8[0].severity, Severity::High);
let r6 = by_rule("R6");
assert_eq!(r6.len(), 1);
assert_eq!(r6[0].severity, Severity::Medium);
assert!(r6[0].uses.starts_with("evil/"));
let r7 = by_rule("R7");
assert_eq!(r7.len(), 1);
assert_eq!(r7[0].severity, Severity::Medium);
for f in &result.findings {
assert!(!f.evidence.is_empty());
assert!(!f.fix_hint.is_empty());
}
assert_eq!(just_shield::report::exit_code(&result, false), 1);
}
#[test]
fn write_all_permissions_is_flagged() {
let result = just_shield::scan(Path::new("tests/fixtures/writeall")).unwrap();
let r7: Vec<_> = result.findings.iter().filter(|f| f.rule == "R7").collect();
assert_eq!(r7.len(), 1);
assert_eq!(r7[0].severity, Severity::Medium);
assert!(r7[0].evidence.contains("write-all"));
assert_eq!(just_shield::report::exit_code(&result, false), 0);
assert_eq!(just_shield::report::exit_code(&result, true), 1);
}
#[test]
fn declared_minimal_permissions_silence_r7() {
let result = just_shield::scan(Path::new("tests/fixtures/clean")).unwrap();
assert!(result.findings.is_empty());
}
#[test]
fn strict_promotes_medium_to_failure() {
let medium_only = just_shield::ScanResult {
workflows_scanned: 1,
findings: vec![just_shield::rules::Finding {
rule: "R7",
severity: Severity::Medium,
file: "x.yml".into(),
line: 1,
uses: String::new(),
evidence: "합성 픽스처".into(),
fix_hint: "합성 픽스처".into(),
}],
suppressed: vec![],
online_rules_skipped: false,
};
assert_eq!(just_shield::report::exit_code(&medium_only, false), 0);
assert_eq!(just_shield::report::exit_code(&medium_only, true), 1);
}
#[test]
fn ignore_comment_with_reason_suppresses_only_that_line_and_rule() {
let result = just_shield::scan(Path::new("tests/fixtures/escape")).unwrap();
assert_eq!(result.suppressed.len(), 1);
assert!(result.suppressed[0].finding.uses.contains("vendor/tool@v2"));
assert!(result.suppressed[0].reason.contains("2026-07"));
let r1: Vec<_> = result.findings.iter().filter(|f| f.rule == "R1").collect();
assert_eq!(r1.len(), 2);
assert!(r1.iter().any(|f| f.uses.contains("tool2")));
assert!(r1.iter().any(|f| f.uses.contains("tool3")));
let ignore: Vec<_> = result
.findings
.iter()
.filter(|f| f.rule == "IGNORE")
.collect();
assert_eq!(ignore.len(), 1);
assert_eq!(ignore[0].severity, Severity::Info);
assert_eq!(just_shield::report::exit_code(&result, false), 1);
}
#[test]
fn suppressed_findings_appear_in_reports_with_reason() {
let result = just_shield::scan(Path::new("tests/fixtures/escape")).unwrap();
let text = just_shield::report::render(&result, false);
assert!(text.contains("⚪ R1"));
assert!(text.contains("사유: 내부 보안팀 검증 완료"));
assert!(text.contains("⚪ 무시 1건"));
let json = just_shield::report::render_json(&result, false);
assert!(json.contains("\"suppressed\": 1"));
assert!(json.contains("\"reason\": \"내부 보안팀 검증 완료, 2026-07 SHA 핀 예정\""));
}
#[test]
fn trusted_org_from_config_is_first_party() {
let result = just_shield::scan(Path::new("tests/fixtures/trusted")).unwrap();
assert_eq!(result.findings.len(), 1);
assert!(result.findings[0].uses.starts_with("stranger/"));
assert_eq!(result.findings[0].severity, Severity::High);
}
#[test]
fn pipe_install_is_info_only_and_never_fails() {
let result = just_shield::scan(Path::new("tests/fixtures/pipe")).unwrap();
let r3: Vec<_> = result.findings.iter().filter(|f| f.rule == "R3").collect();
assert_eq!(r3.len(), 1);
assert_eq!(r3[0].line, 9);
assert_eq!(r3[0].severity, Severity::Info);
assert_eq!(just_shield::report::exit_code(&result, false), 0);
assert_eq!(just_shield::report::exit_code(&result, true), 0);
}
#[test]
fn images_without_digest_are_medium() {
let result = just_shield::scan(Path::new("tests/fixtures/images")).unwrap();
let r4: Vec<_> = result.findings.iter().filter(|f| f.rule == "R4").collect();
assert_eq!(r4.len(), 2);
assert!(r4.iter().all(|f| f.severity == Severity::Medium));
assert!(r4.iter().any(|f| f.uses.contains("node:18")));
assert!(r4.iter().any(|f| f.uses.contains("postgres:16")));
assert_eq!(just_shield::report::exit_code(&result, false), 0);
assert_eq!(just_shield::report::exit_code(&result, true), 1);
}
#[test]
fn known_compromised_version_is_flagged_offline_from_bundled_db() {
let result = just_shield::scan(Path::new("tests/fixtures/advisory")).unwrap();
let r9: Vec<_> = result.findings.iter().filter(|f| f.rule == "R9").collect();
assert_eq!(r9.len(), 1, "등재된 SHA만 — 등재 안 된 SHA는 침묵");
assert_eq!(r9[0].line, 10);
assert_eq!(r9[0].severity, Severity::High);
assert!(
r9[0].evidence.contains("CVE-2025-30066"),
"근거에 권고 출처가 표시돼야 한다"
);
assert_eq!(just_shield::report::exit_code(&result, false), 1);
}
#[test]
fn teampcp_style_advisory_entries_match_tags_and_shas() {
let db = just_shield::advisory::AdvisoryDb::parse(
"checkmarx/kics-github-action@aaaa000000000000000000000000000000000000 GHSA-fake-kics 2026-03 태그 하이재킹 오염 커밋\n\
aquasecurity/trivy-action@v0.99.0 GHSA-fake-trivy 오염된 릴리스 태그\n",
);
let entries = just_shield::workflow::extract_uses_entries(
" - uses: checkmarx/kics-github-action@aaaa000000000000000000000000000000000000\n - uses: aquasecurity/trivy-action@v0.99.0\n - uses: aquasecurity/trivy-action@v0.28.0\n",
);
let findings = just_shield::rules::check_r9(Path::new("ci.yml"), &entries, &db);
assert_eq!(findings.len(), 2);
assert!(findings.iter().all(|f| f.severity == Severity::High));
assert!(
findings
.iter()
.any(|f| f.evidence.contains("GHSA-fake-kics"))
);
assert!(
findings
.iter()
.any(|f| f.evidence.contains("GHSA-fake-trivy"))
);
}
#[test]
fn json_output_for_clean_repo_is_pinned_snapshot() {
let result = just_shield::scan(Path::new("tests/fixtures/clean")).unwrap();
let json = just_shield::report::render_json(&result, false);
let expected = "{\n \"version\": 1,\n \"workflows_scanned\": 1,\n \"summary\": { \"high\": 0, \"medium\": 0, \"info\": 0, \"suppressed\": 0 },\n \"exit_code\": 0,\n \"findings\": [],\n \"suppressed\": []\n}\n";
assert_eq!(json, expected);
}
#[test]
fn json_output_contains_all_finding_fields() {
let bin = env!("CARGO_BIN_EXE_just-shield");
let out = Command::new(bin)
.args(["scan", "tests/fixtures/violation", "--format", "json"])
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1));
let json = String::from_utf8_lossy(&out.stdout);
for field in [
"\"rule\": \"R1\"",
"\"severity\": \"high\"",
"\"severity\": \"info\"",
"\"file\": \".github/workflows/ci.yml\"",
"\"line\": 9",
"\"uses\": \"aquasecurity/trivy-action@master\"",
"\"evidence\": ",
"\"fix_hint\": ",
"\"summary\": { \"high\": 3, \"medium\": 1, \"info\": 1, \"suppressed\": 0 }",
"\"exit_code\": 1",
] {
assert!(json.contains(field), "JSON에 {field} 가 없습니다:\n{json}");
}
assert!(
!json.contains('\\') || !json.contains("workflows\\"),
"경로 정규화 실패"
);
}
#[test]
fn exit_code_one_on_violation_zero_on_clean() {
let bin = env!("CARGO_BIN_EXE_just-shield");
let bad = Command::new(bin)
.args(["scan", "tests/fixtures/violation"])
.output()
.unwrap();
assert_eq!(bad.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&bad.stdout);
assert!(stdout.contains("R1"));
assert!(stdout.contains("ci.yml"));
let good = Command::new(bin)
.args(["scan", "tests/fixtures/clean"])
.output()
.unwrap();
assert_eq!(good.status.code(), Some(0));
let official = Command::new(bin)
.args(["scan", "tests/fixtures/official", "--strict"])
.output()
.unwrap();
assert_eq!(official.status.code(), Some(0));
let usage = Command::new(bin).output().unwrap();
assert_eq!(usage.status.code(), Some(2));
}