Files
pulse/scripts/tests/test_npm_audit_retry.py
T
pulse-triage[bot] b74b4f126a Preserve literal Bash audit invocation inside the owned group
The exact-source broader audit suite exposed ENOEXEC differences in the direct Python exec path. Retain the former Bash executable-script fallback with a fixed exec program and literal argv, including whitespace and shell metacharacters in the executable path. Isolate the two path-lookalike fixtures so each still asserts its own one-commit guard result.

Contract-Neutral: Restore the existing Bash command compatibility within the new audit process group; audit classification, retry rules, dependency graphs, command arguments and external authority are unchanged.
Change-source: pulse-maintainer
2026-10-13 05:39:13 +01:00

694 lines
32 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import textwrap
import time
import unittest
from unittest.mock import patch
import yaml
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "npm-audit-retry.sh"
@unittest.skipUnless(os.name == "posix", "audit process groups require Unix")
class NpmAuditProcessCleanupTest(unittest.TestCase):
def live(self, pid: int) -> bool:
result = subprocess.run(
["ps", "-o", "stat=", "-p", str(pid)],
capture_output=True, text=True, check=False,
)
state = result.stdout.strip()
return bool(state) and not state.startswith("Z")
def run_with_child(self, mode: str, *, finding: bool = False):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
pidfile = root / "child.pid"
npm = root / "npm fixture;not-code"
counts = dict.fromkeys(("info", "low", "moderate", "high", "critical", "total"), 0)
if finding:
counts.update(high=1, total=1)
report = json.dumps({"metadata": {"vulnerabilities": counts}})
actions = {
"success": "exit 0",
"failure": "exit 42",
"timeout": "wait",
"timeout-exits-zero": "trap 'exit 0' TERM; wait",
"interrupted": "wait",
}
npm.write_text(textwrap.dedent(f"""\
#!/bin/sh
(trap '' TERM; exec sleep 60) &
printf '%s\\n' "$!" > "$CHILD_PID_FILE"
printf '%s\\n' '{report}'
{actions[mode]}
"""), encoding="utf-8")
npm.chmod(0o755)
env = os.environ.copy()
env.update(
CHILD_PID_FILE=str(pidfile), NPM_AUDIT_CMD=str(npm),
NPM_AUDIT_ATTEMPTS="1", NPM_AUDIT_ATTEMPT_TIMEOUT="1",
NPM_AUDIT_MAX_SECONDS="3", NPM_AUDIT_RETRY_DELAY="0",
NPM_AUDIT_REQUIRE_RESULT="true",
)
unrelated = subprocess.Popen(["sleep", "60"], start_new_session=True)
wrapper = subprocess.Popen(
["bash", str(SCRIPT), "all"], cwd=ROOT, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
start_new_session=True,
)
child = None
started = time.monotonic()
try:
if mode == "interrupted":
deadline = started + 4
while not pidfile.exists() and time.monotonic() < deadline:
time.sleep(0.01)
self.assertTrue(pidfile.exists(), "audit never started")
# Cancel the caller's actual group. The audit's separate
# group must be cleaned by its owner, not this signal.
os.killpg(wrapper.pid, signal.SIGTERM)
stdout, stderr = wrapper.communicate(timeout=8)
child = int(pidfile.read_text(encoding="utf-8").strip())
deadline = time.monotonic() + 1
while self.live(child) and time.monotonic() < deadline:
time.sleep(0.01)
self.assertFalse(self.live(child), f"audit child {child} survived {mode}")
self.assertIsNone(unrelated.poll(), "audit cleanup killed an unrelated process")
self.assertLess(time.monotonic() - started, 6, stdout + stderr)
return wrapper.returncode, stdout, stderr
finally:
# These exact fixture children/groups are owned by this test,
# including on the unfixed parent or an assertion failure.
if child is None and pidfile.exists():
child = int(pidfile.read_text(encoding="utf-8").strip())
if child is not None:
try:
os.kill(child, signal.SIGKILL)
except ProcessLookupError:
pass
for process in (wrapper, unrelated):
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=3)
if wrapper.stdout is not None:
wrapper.stdout.close()
if wrapper.stderr is not None:
wrapper.stderr.close()
def test_completed_and_failed_audits_clean_children_without_changing_verdict(self):
for mode, expected in (("success", 0), ("failure", 1)):
with self.subTest(mode=mode):
status, stdout, stderr = self.run_with_child(mode)
self.assertEqual(status, expected, stdout + stderr)
self.assertEqual("no vulnerabilities" in stdout, mode == "success")
if mode == "failure":
self.assertIn("status 42", stdout)
def test_timeout_cleans_term_resistant_descendants_and_keeps_strict_verdict(self):
status, stdout, stderr = self.run_with_child("timeout")
self.assertEqual(status, 1, stdout + stderr)
self.assertIn("command was stopped", stdout)
self.assertNotIn("no vulnerabilities", stdout)
def test_zero_exit_from_timeout_handler_is_not_completed_audit(self):
status, stdout, stderr = self.run_with_child("timeout-exits-zero")
self.assertEqual(status, 1, stdout + stderr)
self.assertIn("status 143", stdout)
self.assertNotIn("no vulnerabilities", stdout)
def test_timeout_keeps_captured_positive_findings_without_a_second_query(self):
status, stdout, stderr = self.run_with_child("timeout", finding=True)
self.assertEqual(status, 1, stdout + stderr)
self.assertIn("vulnerabilities present", stdout)
self.assertNotIn("could not reach the advisory endpoint", stdout)
self.assertNotIn("retrying", stdout)
def test_interrupted_caller_cleans_the_separate_audit_group(self):
status, stdout, stderr = self.run_with_child("interrupted")
self.assertEqual(status, -signal.SIGTERM, stdout + stderr)
class NpmAuditRetryTest(unittest.TestCase):
def run_check(
self, mode: str, *arguments: str, require: str = "true", report=None,
attempts: int = 3, attempt_timeout: int = 60, max_seconds: int = 240,
):
with tempfile.TemporaryDirectory() as directory:
fake_bin = Path(directory)
count = fake_bin / "count"
calls = fake_bin / "calls"
report_path = fake_bin / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
count.write_text("0\n", encoding="utf-8")
fake_npm = fake_bin / "npm"
fake_npm.write_text(
textwrap.dedent(
"""\
#!/bin/sh
count=$(cat "$FAKE_NPM_COUNT")
count=$((count + 1))
printf '%s\n' "$count" > "$FAKE_NPM_COUNT"
printf '%s\n' "$*" >> "$FAKE_NPM_CALLS"
clean='{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":0,"critical":0,"total":0}}}'
unavailable='{"error":{"code":"ENOAUDIT","summary":"503 Service Unavailable"}}'
vulnerable='{"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":1,"critical":0,"total":1}}}'
vulnerable_with_error='{"error":{"code":"ETIMEDOUT"},"metadata":{"vulnerabilities":{"info":0,"low":0,"moderate":0,"high":1,"critical":0,"total":1}}}'
case "$FAKE_NPM_MODE" in
success)
printf '%s\n' "$clean"
exit 0
;;
clean-failure)
printf '%s\n' "$clean"
exit 42
;;
clean-signal)
printf '%s\n' "$clean"
kill -TERM "$$"
;;
clean-hang)
printf '%s\n' "$clean"
exec sleep 300
;;
vulnerability-hang)
printf '%s\n' "$vulnerable"
exec sleep 300
;;
vulnerability-success)
printf '%s\n' "$vulnerable"
exit 0
;;
transient-success)
if [ "$count" -eq 1 ]; then
printf '%s\n' "$unavailable"
exit 1
fi
printf '%s\n' "$clean"
exit 0
;;
transient-failure)
printf '%s\n' "$unavailable"
exit 42
;;
vulnerability)
printf '%s\n' "$vulnerable"
exit 1
;;
vulnerability-with-error)
printf '%s\n' "$vulnerable_with_error"
exit 1
;;
captured-vulnerability)
if [ "$count" -eq 1 ]; then
cat "$FAKE_NPM_REPORT"
else
# A diagnostic re-query could return a different verdict.
printf '%s\n' "$clean"
fi
exit 1
;;
captured-report)
# Malformed reports must remain the same on retry.
cat "$FAKE_NPM_REPORT"
exit 1
;;
captured-clean)
cat "$FAKE_NPM_REPORT"
exit 0
;;
garbage)
echo 'not json'
exit 1
;;
esac
exit 64
"""
),
encoding="utf-8",
)
fake_npm.chmod(0o755)
env = os.environ.copy()
env.update(
{
"FAKE_NPM_CALLS": str(calls),
"FAKE_NPM_COUNT": str(count),
"FAKE_NPM_MODE": mode,
"FAKE_NPM_REPORT": str(report_path),
"NPM_AUDIT_ATTEMPTS": str(attempts),
"NPM_AUDIT_ATTEMPT_TIMEOUT": str(attempt_timeout),
"NPM_AUDIT_MAX_SECONDS": str(max_seconds),
"NPM_AUDIT_CMD": str(fake_npm),
"NPM_AUDIT_REQUIRE_RESULT": require,
"NPM_AUDIT_RETRY_DELAY": "0",
}
)
result = subprocess.run(
[str(SCRIPT), *arguments],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=False,
timeout=30,
)
recorded_calls = (
calls.read_text(encoding="utf-8").splitlines()
if calls.exists()
else []
)
return result, recorded_calls
def test_fake_audit_budget_is_not_inherited_from_the_caller(self) -> None:
# These are fixture controls, not a real advisory query. A caller's
# exhausted/short budget must not prevent the canned verdict from
# being read or change the exact one-request assertion.
with patch.dict(os.environ, {
"NPM_AUDIT_ATTEMPTS": "0",
"NPM_AUDIT_ATTEMPT_TIMEOUT": "0",
"NPM_AUDIT_MAX_SECONDS": "0",
"NPM_AUDIT_RETRY_DELAY": "10000",
"NPM_AUDIT_CMD": "/must-not-be-executed",
"NPM_AUDIT_REQUIRE_RESULT": "false",
}):
for require, status, annotation in [
("true", 1, "::error::"), ("false", 0, "::warning::")
]:
with self.subTest(require=require):
result, calls = self.run_check("vulnerability", "all", require=require)
self.assertEqual(result.returncode, status, result.stdout)
self.assertEqual(calls, ["audit --json"])
self.assertIn(annotation, result.stdout)
self.assertIn("vulnerabilities present", result.stdout)
self.assertNotIn("retrying", result.stdout)
def test_shell_fixture_owns_its_default_and_explicit_timing_controls(self) -> None:
env = os.environ.copy()
env.update(NPM_AUDIT_ATTEMPTS="0", NPM_AUDIT_ATTEMPT_TIMEOUT="0",
NPM_AUDIT_MAX_SECONDS="0", NPM_AUDIT_RETRY_DELAY="10000",
NPM_AUDIT_CMD="/must-not-be-executed")
result = subprocess.run(
["bash", str(ROOT / "scripts/tests/test-npm-audit-retry.sh")],
cwd=ROOT, env=env, text=True, capture_output=True, check=False,
timeout=90,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("all npm-audit-retry tests passed", result.stdout)
self.assertIn("a hung audit is stopped at the per-attempt limit", result.stdout)
self.assertIn("the wall-clock budget ends the retry sequence", result.stdout)
def test_passes_a_clean_production_audit_and_forwards_arguments(self) -> None:
result, calls = self.run_check(
"success", "production", "--package-lock-only"
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
calls,
["audit --json --package-lock-only --omit=dev"],
)
def test_retries_an_unavailable_audit_endpoint(self) -> None:
result, calls = self.run_check("transient-success", "all")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
calls,
["audit --json"] * 2,
)
self.assertIn("retrying", result.stdout)
def test_zero_summary_from_a_failed_or_signalled_audit_is_not_clean(self) -> None:
for mode in ["clean-failure", "clean-signal"]:
for require in ["true", "false"]:
with self.subTest(mode=mode, require=require):
result, calls = self.run_check(mode, "all", require=require)
self.assertEqual(result.returncode, 1 if require == "true" else 0,
result.stdout)
self.assertEqual(calls, ["audit --json"] * 3, result.stdout)
self.assertIn("could not reach the advisory endpoint", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
self.assertNotIn("vulnerabilities present", result.stdout)
self.assertIn("::error::" if require == "true" else "::warning::",
result.stdout)
self.assertIn("status 42" if mode == "clean-failure" else "status 143",
result.stdout)
def test_zero_summary_written_before_timeout_is_not_clean(self) -> None:
for require in ["true", "false"]:
with self.subTest(require=require):
result, calls = self.run_check(
"clean-hang", "production", require=require,
attempts=1, attempt_timeout=1, max_seconds=5,
)
self.assertEqual(result.returncode, 1 if require == "true" else 0,
result.stdout)
self.assertEqual(calls, ["audit --json --omit=dev"], result.stdout)
self.assertIn("was stopped", result.stdout)
self.assertIn("could not reach the advisory endpoint", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
self.assertIn("::error::" if require == "true" else "::warning::",
result.stdout)
def test_positive_findings_survive_zero_exit_or_timeout_without_requery(self) -> None:
for mode in ["vulnerability-success", "vulnerability-hang"]:
for require in ["true", "false"]:
with self.subTest(mode=mode, require=require):
result, calls = self.run_check(
mode, "all", require=require,
attempts=3, attempt_timeout=1, max_seconds=5,
)
self.assertEqual(result.returncode, 1 if require == "true" else 0,
result.stdout)
self.assertEqual(calls, ["audit --json"], result.stdout)
self.assertIn("vulnerabilities present", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
self.assertNotIn("retrying", result.stdout)
self.assertNotIn("could not reach the advisory endpoint", result.stdout)
self.assertIn("::error::" if require == "true" else "::warning::",
result.stdout)
def test_does_not_retry_a_vulnerability_report(self) -> None:
result, calls = self.run_check("vulnerability", "all")
self.assertEqual(result.returncode, 1)
self.assertEqual(
calls,
["audit --json"],
)
self.assertIn("vulnerabilities present", result.stdout)
self.assertIn("::error::", result.stdout)
self.assertNotIn("retrying", result.stdout)
def test_inherited_vulnerability_warns_for_an_unchanged_dependency_graph(self) -> None:
result, calls = self.run_check("vulnerability", "all", require="false")
self.assertEqual(result.returncode, 0, result.stdout)
self.assertEqual(calls, ["audit --json"])
self.assertIn("vulnerabilities present", result.stdout)
self.assertIn("::warning::", result.stdout)
self.assertIn("base commit already has", result.stdout)
self.assertNotIn("::error::", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
self.assertNotIn("retrying", result.stdout)
def test_only_an_explicit_false_relaxes_a_vulnerability(self) -> None:
for require in ["", "False", "0", "no", "true"]:
with self.subTest(require=require):
result, calls = self.run_check("vulnerability", "all", require=require)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertEqual(calls, ["audit --json"], result.stdout + result.stderr)
self.assertIn("::error::", result.stdout)
self.assertNotIn("::warning::", result.stdout)
def test_vulnerability_verdict_precedes_a_transport_error(self) -> None:
result, calls = self.run_check("vulnerability-with-error", "all")
self.assertEqual(result.returncode, 1)
self.assertEqual(calls, ["audit --json"])
self.assertIn("vulnerabilities present", result.stdout)
self.assertNotIn("retrying", result.stdout)
def test_reports_the_original_advisory_without_an_unbounded_requery(self) -> None:
nodes = [
"node_modules/@eslint/config-array/node_modules/brace-expansion",
"node_modules/@eslint/eslintrc/node_modules/brace-expansion",
"node_modules/brace-expansion",
"node_modules/eslint/node_modules/brace-expansion",
]
report = {
"metadata": {"vulnerabilities": {"high": 1, "total": 1}},
"vulnerabilities": {
"brace-expansion": {
"name": "brace-expansion",
"severity": "high",
"isDirect": False,
"range": "<=1.1.20 || 4.0.0 - 5.0.11",
"nodes": nodes,
"fixAvailable": True,
"via": [
{
"source": 123456,
"name": "brace-expansion",
"title": "Quadratic-time expansion causes CPU denial of service",
"url": "https://github.com/advisories/GHSA-q2hr-2g5m-vwhr",
"severity": "high",
"range": "<=1.1.20",
"unrecognised": "must not be printed",
},
"transitive-dependency",
],
}
},
"error": {"detail": "must not be printed"},
}
# An inherited finding only warns, but it must be named exactly as a
# blocking one would be.
result, calls = self.run_check(
"captured-vulnerability", "all", require="false", report=report
)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn("::warning::", result.stdout)
self.assertEqual(calls, ["audit --json"])
finding_lines = [
line.removeprefix("audit finding ")
for line in result.stdout.splitlines()
if line.startswith("audit finding ")
]
self.assertEqual(len(finding_lines), 1, result.stdout)
finding = json.loads(finding_lines[0])
self.assertEqual(finding["nodes"], nodes)
self.assertIs(finding["fixAvailable"], True)
self.assertEqual(finding["name"], "brace-expansion")
self.assertEqual(finding["range"], "<=1.1.20 || 4.0.0 - 5.0.11")
expected_advisory = report["vulnerabilities"]["brace-expansion"]["via"][0]
self.assertEqual(
finding["via"][0],
{key: value for key, value in expected_advisory.items() if key != "unrecognised"},
)
self.assertEqual(finding["via"][1], "transitive-dependency")
self.assertNotIn("must not be printed", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
def test_missing_advisory_detail_cannot_trigger_a_requery_or_clear_the_failure(self) -> None:
result, calls = self.run_check("vulnerability", "all")
self.assertEqual(result.returncode, 1)
self.assertEqual(calls, ["audit --json"])
self.assertIn("package-level detail unavailable", result.stdout)
def test_escapes_advisory_text_instead_of_emitting_workflow_commands(self) -> None:
title = "unsafe title\n::error::injected annotation\x1b[31m"
report = {
"metadata": {"vulnerabilities": {"high": 1, "total": 1}},
"vulnerabilities": {
"dependency": {"name": "dependency", "via": [{"title": title}]}
},
}
result, calls = self.run_check(
"captured-vulnerability", "production", "--package-lock-only", report=report
)
self.assertEqual(result.returncode, 1)
self.assertEqual(calls, ["audit --json --package-lock-only --omit=dev"])
finding_line = next(
(line for line in result.stdout.splitlines() if line.startswith("audit finding ")),
"audit finding {}",
)
finding = json.loads(finding_line.removeprefix("audit finding "))
self.assertEqual(finding.get("via"), [{"title": title}], result.stdout)
self.assertNotIn("\n::error::injected", result.stdout)
self.assertNotIn("\x1b", result.stdout)
def test_package_findings_count_even_when_summary_is_missing_or_zero(self) -> None:
for metadata in [None, {}, {"vulnerabilities": {"total": 0}}]:
for require in ["true", "false"]:
with self.subTest(metadata=metadata, require=require):
report = {
"vulnerabilities": {
"dependency": {"name": "dependency", "severity": "high"}
}
}
if metadata is not None:
report["metadata"] = metadata
result, calls = self.run_check(
"captured-vulnerability", "all", require=require, report=report
)
self.assertEqual(
result.returncode, 1 if require == "true" else 0, result.stdout
)
self.assertEqual(calls, ["audit --json"], result.stdout)
self.assertIn("vulnerabilities present", result.stdout)
self.assertIn("package_records=1", result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
def test_positive_severity_counts_count_without_a_consistent_total(self) -> None:
for severity in ["info", "low", "moderate", "high", "critical"]:
for total in [None, 0]:
for require in ["true", "false"]:
with self.subTest(severity=severity, total=total, require=require):
counts = {severity: 1}
if total is not None:
counts["total"] = total
report = {
"metadata": {"vulnerabilities": counts},
"error": {"code": "ETIMEDOUT"},
}
result, calls = self.run_check(
"captured-vulnerability", "production",
require=require, report=report,
)
self.assertEqual(
result.returncode, 1 if require == "true" else 0, result.stdout
)
self.assertEqual(calls, ["audit --json --omit=dev"], result.stdout)
self.assertIn("vulnerabilities present", result.stdout)
def test_malformed_zero_verdicts_cannot_pass_as_clean(self) -> None:
zero_counts = dict.fromkeys(
["info", "low", "moderate", "high", "critical", "total"], 0
)
reports = [
{"metadata": {"vulnerabilities": {**zero_counts, "total": value}}}
for value in [None, False, -1, 0.0, "0", [], {}]
]
reports.extend([
{"metadata": {"vulnerabilities": {**zero_counts, "high": "0"}}},
{"metadata": {"vulnerabilities": {**zero_counts, "high": None}}},
{"metadata": {"vulnerabilities": {"total": 0}}},
{"metadata": {"vulnerabilities": zero_counts}, "vulnerabilities": []},
{"metadata": {"vulnerabilities": zero_counts}, "vulnerabilities": None},
{
"metadata": {"vulnerabilities": zero_counts},
"error": {"code": "ENOAUDIT"},
},
])
for report in reports:
for require in ["true", "false"]:
with self.subTest(report=report, require=require):
result, calls = self.run_check(
"captured-report", "all", require=require, report=report
)
self.assertEqual(
result.returncode, 1 if require == "true" else 0, result.stdout
)
self.assertEqual(calls, ["audit --json"] * 3, result.stdout)
self.assertNotIn("no vulnerabilities", result.stdout)
self.assertIn("could not reach", result.stdout)
if require == "false":
self.assertIn("::warning::", result.stdout)
def test_summary_cannot_emit_untrusted_metadata_as_workflow_commands(self) -> None:
unsafe = "0\n::error::injected metadata\x1b[31m"
report = {"metadata": {"vulnerabilities": {"total": 1, "high": unsafe}}}
result, calls = self.run_check("captured-vulnerability", "all", report=report)
self.assertEqual(result.returncode, 1, result.stdout)
self.assertEqual(calls, ["audit --json"])
self.assertIn("vulnerabilities present", result.stdout)
self.assertIn("high=unknown", result.stdout)
self.assertNotIn("injected metadata", result.stdout)
self.assertNotIn("\x1b", result.stdout)
def test_passes_a_complete_zero_summary_and_empty_package_map(self) -> None:
report = {
"metadata": {
"vulnerabilities": dict.fromkeys(
["info", "low", "moderate", "high", "critical", "total"], 0
)
},
"vulnerabilities": {},
}
result, calls = self.run_check("captured-clean", "all", report=report)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertEqual(calls, ["audit --json"])
self.assertIn("no vulnerabilities", result.stdout)
def test_persistent_outage_fails_when_a_result_is_required(self) -> None:
for require in ["true", "", "False"]:
with self.subTest(require=require):
result, calls = self.run_check("transient-failure", "all", require=require)
self.assertEqual(result.returncode, 1)
self.assertEqual(calls, ["audit --json"] * 3)
self.assertIn("could not reach", result.stdout)
def test_persistent_outage_warns_for_an_unchanged_dependency_graph(self) -> None:
result, calls = self.run_check(
"transient-failure", "all", require="false"
)
self.assertEqual(result.returncode, 0)
self.assertEqual(calls, ["audit --json"] * 3)
self.assertIn("::warning::", result.stdout)
def test_unparseable_output_never_passes_as_clean(self) -> None:
result, calls = self.run_check("garbage", "all")
self.assertEqual(result.returncode, 1)
self.assertEqual(calls, ["audit --json"] * 3)
def test_rejects_an_unknown_scope(self) -> None:
result, calls = self.run_check("success", "unknown")
self.assertEqual(result.returncode, 2)
self.assertEqual(calls, [])
def test_all_workflow_audits_use_the_retry_boundary(self) -> None:
expected = {
".github/workflows/build-and-test.yml": {
"job": "frontend",
"runs": [
'bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" all',
],
"require_env": True,
"verdict_env": {
"COMPLETE_AUDIT_RESULT": "${{ steps.audit-complete.outcome }}",
},
},
".github/workflows/security-scan.yml": {
"job": "npm-audit",
"runs": [
'bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" all --package-lock-only',
'bash "$GITHUB_WORKSPACE/scripts/npm-audit-retry.sh" production --package-lock-only',
],
"require_env": False,
"verdict_env": {
"COMPLETE_AUDIT_RESULT": "${{ steps.audit-complete.outcome }}",
"PRODUCTION_AUDIT_RESULT": "${{ steps.audit-production.outcome }}",
},
},
}
for relative, contract in expected.items():
with self.subTest(workflow=relative):
workflow = yaml.safe_load(
(ROOT / relative).read_text(encoding="utf-8")
)
steps = workflow["jobs"][contract["job"]]["steps"]
audits = [
step
for step in steps
if step.get("id") in {"audit-complete", "audit-production"}
]
self.assertEqual([step["run"] for step in audits], contract["runs"])
self.assertTrue(
all(step.get("continue-on-error") is True for step in audits)
)
for step in audits:
env = step.get("env", {})
self.assertEqual(
"NPM_AUDIT_REQUIRE_RESULT" in env,
contract["require_env"],
)
verdict = steps[-1]
self.assertIn("Require", verdict["name"])
self.assertEqual(verdict["if"], "${{ !cancelled() }}")
self.assertEqual(
verdict["env"],
contract["verdict_env"],
)
if __name__ == "__main__":
unittest.main()