This commit is contained in:
Khang 2026-11-05 17:01:23 -03:00 • committed by GitHub
commit 9a36fa9708
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 489 additions and 39 deletions

View file

@ -170,6 +170,10 @@ class RuntimeSettings(BaseSettings):
backend: str = Field(default="docker", alias="STRIX_RUNTIME_BACKEND")
# Max screenshot/image tool outputs kept live per agent context (0 = none).
max_context_images: int = Field(default=3, ge=0, alias="STRIX_MAX_CONTEXT_IMAGES")
# Emit a self-contained HTML findings report (report.html) on scan
# completion, alongside the markdown/CSV/JSON/SARIF outputs. Set to false
# (STRIX_HTML_REPORT=0) to disable.
html_report: bool = Field(default=True, alias="STRIX_HTML_REPORT")
class TelemetrySettings(BaseSettings):

View file

@ -389,6 +389,19 @@ class GoTuiRuntime:
packaged = get_strix_resource_path("bin", tui_executable())
if packaged.is_file():
return [str(packaged)]
# Source checkout without Go on PATH: fall back to the sidecar that
# ``make build`` / ``scripts/build.sh`` compile into build/sidecar/.
# The path is pinned to this checkout, never looked up on PATH.
# tui_source_dir() is <root>/strix/interface/tui.
checkout_build = source.parents[2] / "build" / "sidecar" / tui_executable()
if (source / "go.mod").is_file() and checkout_build.is_file():
return [str(checkout_build)]
if (source / "go.mod").is_file():
raise RuntimeError(
"Bubble Tea TUI binary not found. This is a source checkout: install Go 1.24+ "
"and make sure `go` is on PATH, or build the sidecar with "
f"`go build -o {checkout_build} ./cmd/strix-tui` from {source}."
)
raise RuntimeError(
"Bubble Tea TUI binary not found. Reinstall Strix from an official platform wheel."
)

View file

@ -78,15 +78,17 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]:
}
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
"""The /api/runs payload. Gates the run list behind email verification.
def build_runs_payload(base_dir: Path, *, unlocked: bool) -> dict[str, Any]:
"""The /api/runs payload. Gates the run list behind the caller's authorization.
The count is always advertised so the UI can tease the history, but the
entries only appear once the viewer is verified.
``unlocked`` is the caller's access decision (today: holds this process's
session capability). When locked, the count is still advertised so the UI can
tease the history, but the entries only appear once unlocked. Email
verification is no longer part of this decision.
"""
run_dirs = _iter_run_dirs(base_dir)
count = len(run_dirs)
if not verified:
if not unlocked:
return {"locked": True, "count": count, "runs": []}
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
@ -235,14 +237,18 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.end_headers()
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The cross-run history list (/api/runs) unlocks its entries only for
# a caller that holds this process's session capability *and* is
# email verified, so merely reaching an exposed --host port never
# leaks the run list (the payload still advertises the count as a
# teaser).
# The launched run is always viewable with no verification. The
# cross-run history list (/api/runs) unlocks its entries for any
# caller that holds this process's session capability, so merely
# reaching an exposed --host port never leaks the run list (the
# cookie is handed only to the browser this process bootstrapped).
# Email verification is intentionally NOT required here: browsing
# local run history is a local convenience, and gating it behind an
# OTP was unnecessary friction. Verification still guards emailing an
# encrypted report (see _handle_report_send).
if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked)
unlocked = self._has_session()
payload = build_runs_payload(state.base_dir, unlocked=unlocked)
self._send_json(HTTPStatus.OK, payload)
return
if path == "/api/capabilities":
@ -268,11 +274,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return
# Any run other than the one used to launch the viewer is part of the
# email-gated history. The session check above applies to both paths;
# verification adds a second gate for historical run data.
if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
# The launched run is always viewable. Any *other* run's data is part
# of the history: it needs this process's session capability (so
# merely reaching an exposed --host port is not enough) -- knowing a
# run name is not sufficient to leak its metadata, vulnerabilities,
# report, or transcript. Email verification is intentionally NOT
# required, matching the /api/runs list: once the list is browsable,
# its entries must be openable without an OTP step.
if run_dir.resolve() != state.run_dir.resolve() and not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if path == "/api/run":

247
strix/report/html_report.py Normal file
View file

@ -0,0 +1,247 @@
"""Self-contained HTML findings report renderer.
Renders a Strix scan's findings (the same ``run_record`` + ``vulnerability_reports``
data used by :mod:`strix.report.writer`) into a single portable HTML document with
inlined CSS — no external assets, opens offline.
Named ``html_report`` (not ``html``) to avoid shadowing the stdlib :mod:`html`
module used here for escaping. All finding-derived content is escaped before
embedding, so attacker-influenced values (titles, PoC code, snippets) cannot
inject markup or script into the report.
"""
from __future__ import annotations
import html
from datetime import UTC, datetime
from typing import Any
# Mirrors ``strix.report.writer._SEVERITY_ORDER`` (kept local to avoid a cross-
# module import cycle; both derive from the same fixed severity vocabulary).
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
# Mirrors ``strix.interface.utils.get_severity_color`` for visual consistency.
_SEVERITY_COLOR = {
"critical": "#dc2626",
"high": "#ea580c",
"medium": "#d97706",
"low": "#65a30d",
"info": "#0284c7",
}
_DEFAULT_COLOR = "#6b7280"
_SEVERITIES = ("critical", "high", "medium", "low", "info")
def _esc(value: Any) -> str:
"""HTML-escape any value (attribute-safe). ``None`` becomes an empty string."""
if value is None:
return ""
return html.escape(str(value), quote=True)
def _severity_color(severity: str) -> str:
return _SEVERITY_COLOR.get(severity.lower(), _DEFAULT_COLOR)
def _sort_key(report: dict[str, Any]) -> tuple[int, str]:
severity = str(report.get("severity", "")).lower()
return (_SEVERITY_ORDER.get(severity, 5), str(report.get("timestamp", "")))
def _target_label(run_record: dict[str, Any]) -> str:
targets = run_record.get("targets_info") or []
if isinstance(targets, list) and targets:
first = targets[0] if isinstance(targets[0], dict) else {}
original = first.get("original")
if isinstance(original, str) and original:
if len(targets) > 1:
return f"{original} (+{len(targets) - 1} more)"
return original
return "unknown"
def _severity_counts(reports: list[dict[str, Any]]) -> dict[str, int]:
counts = dict.fromkeys(_SEVERITIES, 0)
for report in reports:
severity = str(report.get("severity", "")).lower()
if severity in counts:
counts[severity] += 1
return counts
def _render_summary(run_record: dict[str, Any], reports: list[dict[str, Any]]) -> str:
run_name = _esc(run_record.get("run_name") or run_record.get("run_id") or "scan")
generated = _esc(datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC"))
parts = [
'<header class="report-header">',
"<h1>Strix &middot; Security Penetration Test Report</h1>",
f'<p class="meta">Target: {_esc(_target_label(run_record))} '
f"&middot; Run: {run_name} &middot; Generated: {generated}</p>",
"</header>",
'<section class="summary" aria-label="Summary">',
]
if reports:
counts = _severity_counts(reports)
chips = "".join(
f'<li class="chip {sev}" style="border-color:{_severity_color(sev)}">'
f'<span class="chip-label" style="color:{_severity_color(sev)}">'
f'{sev.upper()}</span> <span class="chip-count">{counts[sev]}</span></li>'
for sev in _SEVERITIES
)
parts.append(f'<ul class="severity-chips">{chips}</ul>')
parts.append(f'<p class="total">Total findings: {len(reports)}</p>')
else:
parts.append(
'<p class="empty">No exploitable vulnerabilities detected.</p>'
)
parts.append("</section>")
return "".join(parts)
def _render_code_block(label: str, code: str) -> str:
return (
f"<h3>{_esc(label)}</h3>"
f'<pre class="code"><code>{_esc(code)}</code></pre>'
)
def _render_meta_row(report: dict[str, Any]) -> str:
dep = report.get("dependency_metadata") or {}
pairs: list[tuple[str, Any]] = [
("ID", report.get("id")),
("Target", report.get("target")),
("Package", dep.get("package_name") if isinstance(dep, dict) else None),
("Installed", dep.get("installed_version") if isinstance(dep, dict) else None),
("Fixed", dep.get("fixed_version") if isinstance(dep, dict) else None),
("Endpoint", report.get("endpoint")),
("Method", report.get("method")),
("CVE", report.get("cve")),
("CWE", report.get("cwe")),
]
items = "".join(
f"<dt>{_esc(label)}</dt><dd>{_esc(value)}</dd>" for label, value in pairs if value
)
return f'<dl class="finding-meta">{items}</dl>' if items else ""
def _render_finding(report: dict[str, Any]) -> str:
severity = str(report.get("severity", "unknown")).lower()
color = _severity_color(severity)
title = _esc(report.get("title") or "Untitled Vulnerability")
cvss = report.get("cvss")
cvss_html = f'<span class="cvss">CVSS {_esc(cvss)}</span>' if cvss is not None else ""
parts = [
f'<article class="finding sev-{_esc(severity)}" id="vuln-{_esc(report.get("id"))}">',
f'<h2><span class="badge" style="background:{color}">{_esc(severity.upper())}</span> '
f"{title} {cvss_html}</h2>",
_render_meta_row(report),
]
for label, key in (
("Description", "description"),
("Impact", "impact"),
("Technical Analysis", "technical_analysis"),
):
value = report.get(key)
if value:
parts.append(f"<section><h3>{label}</h3><p>{_esc(value)}</p></section>")
if report.get("poc_description") or report.get("poc_script_code"):
parts.append("<section>")
if report.get("poc_description"):
parts.append(f"<h3>Proof of Concept</h3><p>{_esc(report['poc_description'])}</p>")
if report.get("poc_script_code"):
parts.append(_render_code_block("PoC Script", str(report["poc_script_code"])))
parts.append("</section>")
code_locations = report.get("code_locations")
if isinstance(code_locations, list) and code_locations:
parts.append("<section><h3>Code Locations</h3>")
for loc in code_locations:
if not isinstance(loc, dict):
continue
file_ref = _esc(loc.get("file") or "unknown")
line = loc.get("start_line")
line_ref = f":{_esc(line)}" if line is not None else ""
parts.append(f'<p class="loc"><code>{file_ref}{line_ref}</code></p>')
snippet = loc.get("snippet")
if snippet:
parts.append(f'<pre class="code"><code>{_esc(snippet)}</code></pre>')
parts.append("</section>")
if report.get("remediation_steps"):
parts.append(
f"<section><h3>Remediation</h3><p>{_esc(report['remediation_steps'])}</p></section>"
)
parts.append("</article>")
return "".join(parts)
_STYLE = """
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; padding: 0 1rem 3rem;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1f2937; background: #f9fafb; line-height: 1.5; }
.report-header, .summary, .findings, footer { max-width: 960px; margin: 0 auto; }
.report-header { padding: 2rem 0 1rem; border-bottom: 2px solid #e5e7eb; }
h1 { font-size: 1.5rem; margin: 0; }
.meta { color: #6b7280; font-size: .9rem; margin: .4rem 0 0; }
.summary { padding: 1.25rem 0; }
.severity-chips { list-style: none; display: flex; flex-wrap: wrap; gap: .5rem;
padding: 0; margin: 0 0 .75rem; }
.chip { border: 1px solid #e5e7eb; border-radius: 999px; padding: .25rem .75rem;
font-size: .85rem; background: #fff; }
.chip-label { font-weight: 700; }
.total { font-weight: 600; margin: 0; }
.empty { font-size: 1.1rem; color: #15803d; }
.finding { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 1rem 1.25rem; margin: 1rem auto; }
.finding h2 { font-size: 1.15rem; display: flex; align-items: center; gap: .5rem;
flex-wrap: wrap; margin: 0 0 .5rem; }
.badge { color: #fff; font-size: .72rem; font-weight: 700; padding: .15rem .5rem;
border-radius: 4px; letter-spacing: .03em; }
.cvss { color: #6b7280; font-size: .85rem; font-weight: 600; margin-left: auto; }
.finding-meta { display: grid; grid-template-columns: max-content 1fr; gap: .1rem .75rem;
font-size: .85rem; margin: .5rem 0; }
.finding-meta dt { color: #6b7280; }
.finding-meta dd { margin: 0; }
h3 { font-size: .95rem; margin: .75rem 0 .25rem; }
.code { background: #0f172a; color: #e2e8f0; padding: .75rem; border-radius: 6px;
overflow-x: auto; font-size: .8rem; }
.loc code { background: #f1f5f9; padding: .1rem .3rem; border-radius: 4px; }
footer { color: #9ca3af; font-size: .8rem; text-align: center; padding-top: 2rem; }
@media print { body { background: #fff; } .finding { break-inside: avoid; } }
"""
def _document(body: str, title: str) -> str:
return (
"<!doctype html>\n"
'<html lang="en">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f"<title>{_esc(title)}</title>\n"
f"<style>{_STYLE}</style>\n"
"</head>\n<body>\n"
f"{body}\n"
"</body>\n</html>\n"
)
def render_html_report(
run_record: dict[str, Any],
vulnerability_reports: list[dict[str, Any]],
) -> str:
"""Return a complete, self-contained HTML document for the scan's findings."""
reports = sorted(vulnerability_reports, key=_sort_key)
body_parts = [_render_summary(run_record, reports)]
if reports:
findings = "".join(_render_finding(r) for r in reports)
body_parts.append(f'<main class="findings">{findings}</main>')
body_parts.append(
"<footer>Strix &middot; generated locally &middot; open this file in a browser</footer>"
)
target = _target_label(run_record)
return _document("".join(body_parts), f"Strix Report — {target}")

View file

@ -19,6 +19,7 @@ from strix.report.sarif import write_sarif
from strix.report.writer import (
read_run_record,
write_executive_report,
write_html_report,
write_run_record,
write_vulnerabilities,
)
@ -892,6 +893,15 @@ class ReportState:
except Exception:
logger.exception("SARIF emit failed (non-fatal; CSV/MD unaffected)")
# Self-contained HTML findings report (report.html). Best-effort and
# isolated: a render/write error must NEVER break the CSV/MD/SARIF/
# run-record path or fail the scan. Disable via STRIX_HTML_REPORT=0.
try:
if load_settings().runtime.html_report:
write_html_report(run_dir, self.run_record, self.vulnerability_reports)
except Exception:
logger.exception("HTML report emit failed (non-fatal; other outputs unaffected)")
write_run_record(run_dir, self.run_record)
logger.info("Essential scan data saved to: %s", run_dir)

View file

@ -17,6 +17,7 @@ from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
from strix.core.paths import run_record_path
from strix.report.html_report import render_html_report
if TYPE_CHECKING:
@ -136,6 +137,17 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None:
)
def write_html_report(
run_dir: Path,
run_record: dict[str, Any],
vulnerability_reports: list[dict[str, Any]],
) -> None:
"""Render and atomically write a self-contained ``report.html`` into ``run_dir``."""
html = render_html_report(run_record, vulnerability_reports)
atomic_write_text(run_dir / "report.html", html)
logger.info("Saved HTML findings report to: %s", run_dir / "report.html")
def write_executive_report(run_dir: Path, final_scan_result: str) -> None:
path = run_dir / "penetration_test_report.md"
with path.open("w", encoding="utf-8") as f:

View file

@ -84,6 +84,58 @@ def test_binary_command_reports_missing_sidecar(
GoTuiRuntime.binary_command()
def _checkout(tmp_path: Path) -> Path:
source = tmp_path / "strix" / "interface" / "tui"
source.mkdir(parents=True)
(source / "go.mod").write_text("module test\n")
return source
def test_binary_command_uses_checkout_build_without_go(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = _checkout(tmp_path)
built = tmp_path / "build" / "sidecar" / sidecar.tui_executable()
built.parent.mkdir(parents=True)
built.write_text("binary")
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source)
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
monkeypatch.setattr(shutil, "which", lambda _name: None)
assert GoTuiRuntime.binary_command() == [str(built)]
def test_binary_command_checkout_without_go_or_build_explains_fix(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = _checkout(tmp_path)
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source)
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
monkeypatch.setattr(shutil, "which", lambda _name: None)
with pytest.raises(RuntimeError, match="source checkout: install Go"):
GoTuiRuntime.binary_command()
def test_binary_command_ignores_checkout_build_outside_a_checkout(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source = tmp_path / "strix" / "interface" / "tui" # no go.mod: not a checkout
source.mkdir(parents=True)
built = tmp_path / "build" / "sidecar" / sidecar.tui_executable()
built.parent.mkdir(parents=True)
built.write_text("binary")
monkeypatch.setattr(go_tui, "tui_source_dir", lambda: source)
monkeypatch.setattr(go_tui, "get_strix_resource_path", lambda *_parts: tmp_path / "missing")
monkeypatch.setattr(shutil, "which", lambda _name: None)
with pytest.raises(RuntimeError, match="official platform wheel"):
GoTuiRuntime.binary_command()
def test_binary_command_ignores_unconstrained_path_sidecar(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Any,

104
tests/test_html_report.py Normal file
View file

@ -0,0 +1,104 @@
"""Tests for the self-contained HTML findings report renderer."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from strix.report.html_report import render_html_report
from strix.report.writer import write_html_report
if TYPE_CHECKING:
from pathlib import Path
def _run_record() -> dict[str, Any]:
return {
"run_name": "demo_ab12",
"targets_info": [{"type": "web_application", "original": "https://example.com"}],
"start_time": "2026-07-22T00:00:00Z",
}
def _finding(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": "vuln-1",
"title": "SQL Injection in login",
"severity": "critical",
"timestamp": "2026-07-22T00:01:00Z",
"cvss": 9.8,
"description": "User input concatenated into a SQL query.",
"impact": "Full database read.",
"poc_script_code": "' OR '1'='1",
"remediation_steps": "Use parameterized queries.",
"code_locations": [{"file": "app/auth.py", "start_line": 42, "snippet": "query = ..."}],
}
base.update(overrides)
return base
def test_report_is_a_full_html_document() -> None:
html = render_html_report(_run_record(), [_finding()])
assert html.startswith("<!doctype html>")
assert '<html lang="en">' in html
assert "</html>" in html
# Self-contained: inline style, no external asset references.
assert "<style>" in html
assert "http://" not in html.split("<footer>")[0].replace("https://example.com", "")
assert "src=" not in html
assert "<link" not in html
def test_report_renders_findings_and_summary() -> None:
html = render_html_report(_run_record(), [_finding()])
assert "SQL Injection in login" in html
assert "CRITICAL" in html
assert "CVSS 9.8" in html
assert "Total findings: 1" in html
assert "Remediation" in html
assert "app/auth.py" in html
def test_report_escapes_malicious_finding_content() -> None:
# Bug/abuse guard (FR4/SEC1): attacker-influenced content must be escaped.
payload = "<script>alert(1)</script>"
html = render_html_report(
_run_record(),
[_finding(title=payload, poc_script_code=payload, description=payload)],
)
# The raw executable tag must NOT appear; only its escaped form.
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
def test_report_empty_state() -> None:
html = render_html_report(_run_record(), [])
assert "No exploitable vulnerabilities detected." in html
assert html.startswith("<!doctype html>")
assert "<article" not in html # no finding cards
def test_report_orders_by_severity() -> None:
reports = [
_finding(id="a", title="low one", severity="low", timestamp="2026-07-22T00:00:00Z"),
_finding(id="b", title="crit one", severity="critical", timestamp="2026-07-22T00:00:01Z"),
]
html = render_html_report(_run_record(), reports)
assert html.index("crit one") < html.index("low one")
def test_report_omits_absent_fields() -> None:
minimal = {"id": "x", "title": "Minimal", "severity": "low", "timestamp": "t"}
html = render_html_report(_run_record(), [minimal])
assert "Minimal" in html
assert "Remediation" not in html
assert "Proof of Concept" not in html
def test_write_html_report_creates_file(tmp_path: Path) -> None:
write_html_report(tmp_path, _run_record(), [_finding()])
out = tmp_path / "report.html"
assert out.exists()
content = out.read_text(encoding="utf-8")
assert content.startswith("<!doctype html>")
assert "SQL Injection in login" in content

View file

@ -619,15 +619,16 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
httpd.server_close()
def test_historical_run_data_requires_verification(
def test_historical_run_data_requires_session_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
verified = {"value": False}
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
# Email verification is pinned False for the whole test to prove it is no
# longer consulted for browsing history: only the session capability is.
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
httpd, url, token = serve(launched, open_browser=False)
try:
@ -636,31 +637,27 @@ def test_historical_run_data_requires_verification(
cookie = _session_cookie(url, token)
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
# A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified.
verified["value"] = True
# A different run still needs the session capability first: a cookie-less
# caller is forbidden.
assert _get_status(f"{url}/api/run?run=other") == 403
# With the cookie but not verified, the history gate returns 401.
verified["value"] = False
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
# With both the cookie and verification, the historical run resolves.
verified["value"] = True
# With the cookie, the historical run resolves even though the machine is
# NOT email-verified -- the email gate has been removed.
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
finally:
httpd.shutdown()
httpd.server_close()
def test_runs_list_requires_session_and_verification(
def test_runs_list_requires_session_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
# Pinned unverified to prove the list unlocks on the session capability alone.
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
def _runs(cookie: str | None) -> dict[str, object]:
headers = {"Cookie": cookie} if cookie else {}
@ -670,14 +667,13 @@ def test_runs_list_requires_session_and_verification(
httpd, url, token = serve(launched, open_browser=False)
try:
# A cookie-less caller (even with the machine verified) only sees the
# teaser count, never the run entries.
# A cookie-less caller only sees the teaser count, never the run entries.
payload = _runs(None)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
# With the session cookie and verification, the entries unlock.
# With the session cookie the entries unlock -- no email verification.
payload = _runs(_session_cookie(url, token))
assert payload["locked"] is False
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]

View file

@ -30,23 +30,25 @@ def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
return run_dir
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
def test_runs_payload_locked_when_no_session(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha")
_make_run(tmp_path, "beta")
payload = build_runs_payload(base, verified=False)
# No session capability -> locked, but the count is still advertised.
payload = build_runs_payload(base, unlocked=False)
assert payload["locked"] is True
assert payload["count"] == 2
assert payload["runs"] == []
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
def test_runs_payload_lists_when_unlocked(tmp_path: Path) -> None:
base = tmp_path / "strix_runs"
_make_run(tmp_path, "alpha", severity="critical")
_make_run(tmp_path, "beta", severity="info")
payload = build_runs_payload(base, verified=True)
# A session-holding caller unlocks the list -- no email verification needed.
payload = build_runs_payload(base, unlocked=True)
assert payload["locked"] is False
assert payload["count"] == 2
assert len(payload["runs"]) == 2
@ -59,7 +61,7 @@ def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
def test_runs_payload_empty_base(tmp_path: Path) -> None:
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
payload = build_runs_payload(tmp_path / "strix_runs", unlocked=True)
assert payload == {"locked": False, "count": 0, "runs": []}