"""Reports: a console table, JUnit XML for CI, and Markdown — failures shown as request → response.
Everything rendered passes through `redact`: bearer tokens, the gateway's identity JWT, passwords and client
secrets, and the token in a public link (/p/<token>) never reach a report, whatever a scenario put in a body.
"""
import json
import re
from collections import Counter
from typing import Any, Iterable
from xml.sax.saxutils import escape, quoteattr
SECRET_KEYS = re.compile(r"(authorization|x-dodil-user-jwt|password|secret|token|cookie|api[-_]?key)", re.I)
_JWT = re.compile(r"eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*|[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*")
_LINK = re.compile(r"(/[poc]/)[A-Za-z0-9_-]{43}")
_BEARER = re.compile(r"(Bearer\s+)\S+", re.I)
REDACTED = "[redacted]"
def redact(value: Any) -> Any:
if isinstance(value, dict):
return {k: (REDACTED if SECRET_KEYS.search(str(k)) and k.lower() not in ("token_url",) else redact(v))
for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [redact(v) for v in value]
if isinstance(value, str):
return _LINK.sub(r"\1[token]", _BEARER.sub(r"\1" + REDACTED, _JWT.sub("[jwt]", value)))
return value
def _dump(value: Any, limit: int = 1500) -> str:
text = json.dumps(redact(value), indent=2, default=str, ensure_ascii=False)
return text if len(text) <= limit else text[:limit] + "\n… (truncated)"
def summary(results: Iterable) -> Counter:
return Counter(r.outcome for r in results)
def console(results: list) -> str:
rows = [("ID", "TITLE", "MODE", "RESULT", "TIME")]
for r in results:
title = r.title if len(r.title) <= 60 else r.title[:57] + "…"
rows.append((r.id, title, r.mode, r.outcome.upper(), f"{r.duration:.2f}s"))
widths = [max(len(row[i]) for row in rows) for i in range(5)]
lines = [" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip() for row in rows]
for r in results:
if r.outcome != "passed" and r.message:
lines.append(f" {r.id} {r.outcome}: {redact(r.message)}")
c = summary(results)
lines.append(f"{len(results)} scenarios: {c['passed']} passed, {c['failed']} failed, {c['error']} errors, "
f"{c['skipped']} skipped")
return "\n".join(lines)
def junit(results: list, name: str = "scenarios") -> str:
c = summary(results)
total = sum(r.duration for r in results)
out = ['<?xml version="1.0" encoding="UTF-8"?>',
f'<testsuite name={quoteattr(name)} tests="{len(results)}" failures="{c["failed"]}" errors="{c["error"]}" '
f'skipped="{c["skipped"]}" time="{total:.3f}">']
for r in results:
out.append(f' <testcase classname={quoteattr(f"{name}.{r.mode}")} name={quoteattr(f"{r.id} {r.title}")} '
f'time="{r.duration:.3f}">')
msg = redact(r.message)
if r.outcome == "failed":
out.append(f' <failure message={quoteattr(msg[:500])}>{escape(_exchange_text(r))}</failure>')
elif r.outcome == "error":
out.append(f' <error message={quoteattr(msg[:500])}>{escape(_exchange_text(r))}</error>')
elif r.outcome == "skipped":
out.append(f' <skipped message={quoteattr(msg[:500])}/>')
out.append(" </testcase>")
out.append("</testsuite>")
return "\n".join(out) + "\n"
def _exchange_text(r) -> str:
x = r.failing
if x is None:
return redact(r.message)
return (f"{redact(r.message)}\n\n{x.persona} {x.method} {redact(x.target)} ({x.door})\n"
f"request: {_dump(x.request)}\nresponse {x.status}: {_dump(x.body)}")
def markdown(results: list, title: str = "Scenario report") -> str:
c = summary(results)
lines = [f"# {title}", "",
f"{len(results)} scenarios — **{c['passed']} passed**, {c['failed']} failed, {c['error']} errors, "
f"{c['skipped']} skipped.", "",
"| ID | Scenario | Stories | Mode | Result | Time |", "|---|---|---|---|---|---|"]
for r in results:
lines.append(f"| {r.id} | {r.title.replace('|', '/')} | {', '.join(r.stories)} | {r.mode} | "
f"{r.outcome.upper()} | {r.duration:.2f}s |")
skipped = [r for r in results if r.outcome == "skipped"]
if skipped:
lines += ["", "## Skipped — and why", ""]
lines += [f"- **{r.id}** ({r.mode}): {redact(r.message)}" for r in skipped]
noted = [r for r in results if r.outcome == "passed" and r.message]
if noted:
lines += ["", "## Notes", ""]
lines += [f"- **{r.id}**: {redact(r.message)}" for r in noted]
bad = [r for r in results if r.outcome in ("failed", "error")]
for r in bad:
lines += ["", f"## {r.outcome.upper()} {r.id} — {r.title}", "", f"_{r.mode}_", "", "```",
redact(r.message), "```", "", "Steps:", "", "```"]
lines += [redact(s) for s in r.steps] + ["```"]
x = r.failing
if x is not None:
lines += ["", f"Request → response ({x.door}, as {x.persona}):", "", "```",
f"{x.method} {redact(x.target)}", _dump(x.request), "", f"→ {x.status}", _dump(x.body), "```"]
return "\n".join(lines) + "\n"