dodil/erp-platform

src/erp_platform/scenarios/report.py

"""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"