"""Console authorization, redaction, and audit model tests (#633).

Covers each acceptance criterion and each required test named in the issue:

* AC1 — RBAC matrix and privileged-action list.
* AC2 — redaction rules, unit-tested against sample payloads.
* AC3 — audit event schema with required fields and retention defaults.
* AC4 — Phase 2 integration points.
* AC5 — local-dev mode with explicit insecurity warnings.

Required tests: redaction units (token, keychain, password patterns),
default-deny for unauthenticated write stubs, and audit record creation for a
simulated privileged preview.
"""

from __future__ import annotations

import datetime
import json
import os
import pathlib
import sys
import tempfile
import unittest

from starlette.testclient import TestClient

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))

from task_capability_map import TASK_CAPABILITY_MAP  # noqa: E402
from webui import console_audit, console_authz  # noqa: E402
from webui.app import create_app  # noqa: E402
from webui.console_redaction import (  # noqa: E402
    REDACTED,
    redact_payload,
    redact_text,
    redaction_policy,
    scan_for_secrets,
)

DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs"
AUTHZ_DOC = DOCS / "webui-authz-audit.md"


def _principal(role: str) -> console_authz.Principal:
    return console_authz.Principal(
        subject=f"{role}@example.com",
        role=role,
        identity_source=console_authz.IDENTITY_ACCESS_PROXY,
        authenticated=True,
    )


class TestRoleMatrix(unittest.TestCase):
    """AC1 — the written RBAC matrix and privileged-action list."""

    def test_roles_are_ordered_least_to_most_authority(self):
        self.assertEqual(
            console_authz.ROLE_ORDER,
            ("viewer", "operator", "controller", "admin"),
        )

    def test_every_role_has_a_description(self):
        for role in console_authz.ROLE_ORDER:
            with self.subTest(role=role):
                self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip())

    def test_higher_roles_inherit_lower_role_actions(self):
        matrix = {
            entry["role"]: set(entry["permitted_actions"])
            for entry in console_authz.rbac_matrix()["roles"]
        }
        for lower, higher in zip(
            console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:]
        ):
            with self.subTest(lower=lower, higher=higher):
                self.assertTrue(matrix[lower].issubset(matrix[higher]))

    def test_viewer_holds_no_write_action(self):
        matrix = {
            entry["role"]: set(entry["permitted_actions"])
            for entry in console_authz.rbac_matrix()["roles"]
        }
        self.assertEqual(matrix["viewer"], set())

    def test_privileged_action_list_is_non_empty_and_classified(self):
        privileged = console_authz.privileged_actions()
        self.assertTrue(privileged)
        ids = {action.action_id for action in privileged}
        # Merge and branch deletion are the canonical privileged pair.
        self.assertIn("merge_pr", ids)
        self.assertIn("delete_branch", ids)

    def test_merge_and_delete_require_dual_control_and_break_glass(self):
        for action_id in ("merge_pr", "delete_branch"):
            with self.subTest(action=action_id):
                action = console_authz.get_action(action_id)
                self.assertTrue(action.dual_control)
                self.assertTrue(action.break_glass)
                self.assertTrue(action.requires_confirmation)

    def test_every_write_action_requires_confirmation(self):
        for action in console_authz.ACTIONS.values():
            with self.subTest(action=action.action_id):
                self.assertTrue(action.requires_confirmation)

    def test_delete_branch_is_admin_only(self):
        self.assertEqual(
            console_authz.get_action("delete_branch").minimum_role,
            console_authz.ADMIN,
        )

    def test_actions_map_to_real_mcp_capability_vocabulary(self):
        """The console must not invent an authority the MCP layer lacks."""
        for action in console_authz.ACTIONS.values():
            with self.subTest(action=action.action_id):
                self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
                self.assertEqual(
                    action.mcp_permission,
                    TASK_CAPABILITY_MAP[action.task_key]["permission"],
                )
                self.assertEqual(
                    action.mcp_role,
                    TASK_CAPABILITY_MAP[action.task_key]["role"],
                )

    def test_matrix_declares_deny_by_default_and_execution_disabled(self):
        matrix = console_authz.rbac_matrix()
        self.assertEqual(matrix["default_decision"], "deny")
        self.assertFalse(matrix["execution_enabled"])


class TestAuthorizeDefaultDeny(unittest.TestCase):
    """Fail-closed behaviour of the authorization decision."""

    def test_anonymous_is_denied_every_action(self):
        for action_id in console_authz.ACTIONS:
            with self.subTest(action=action_id):
                decision = console_authz.authorize(action_id)
                self.assertFalse(decision.allowed)
                self.assertEqual(
                    decision.reason_code, console_authz.DENY_UNAUTHENTICATED
                )

    def test_unknown_action_is_denied(self):
        decision = console_authz.authorize(
            "not_a_real_action", _principal("admin")
        )
        self.assertFalse(decision.allowed)
        self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION)

    def test_unknown_role_is_denied(self):
        rogue = console_authz.Principal(
            subject="x@example.com",
            role="superuser",
            identity_source=console_authz.IDENTITY_ACCESS_PROXY,
            authenticated=True,
        )
        decision = console_authz.authorize("comment_issue", rogue)
        self.assertFalse(decision.allowed)
        self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE)

    def test_insufficient_role_is_denied(self):
        decision = console_authz.authorize("merge_pr", _principal("operator"))
        self.assertFalse(decision.allowed)
        self.assertEqual(
            decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE
        )

    def test_sufficient_role_allows_preview_only(self):
        decision = console_authz.authorize("merge_pr", _principal("controller"))
        self.assertTrue(decision.allowed)
        self.assertFalse(decision.execution_enabled)

    def test_execution_is_refused_while_phase_is_not_active(self):
        decision = console_authz.authorize(
            "merge_pr", _principal("controller"), for_execution=True
        )
        self.assertFalse(decision.allowed)
        self.assertEqual(
            decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE
        )

    def test_allowed_decision_never_reports_execution_enabled(self):
        for action_id in console_authz.ACTIONS:
            with self.subTest(action=action_id):
                decision = console_authz.authorize(
                    action_id, _principal("admin")
                )
                self.assertFalse(decision.execution_enabled)


class TestIdentityResolution(unittest.TestCase):
    """AC5 — identity sources, including the insecure local-dev mode."""

    def test_no_auth_mode_yields_anonymous_viewer(self):
        principal = console_authz.resolve_principal(env={})
        self.assertFalse(principal.authenticated)
        self.assertEqual(principal.role, console_authz.VIEWER)
        self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE)

    def test_local_dev_mode_warns_that_identity_is_unverified(self):
        principal = console_authz.resolve_principal(
            env={
                console_authz.AUTH_MODE_ENV: "local-dev",
                console_authz.DEV_SUBJECT_ENV: "dev@example.com",
                console_authz.DEV_ROLE_ENV: "admin",
            }
        )
        self.assertTrue(principal.authenticated)
        self.assertEqual(principal.role, "admin")
        self.assertTrue(principal.warnings)
        self.assertIn("asserted", " ".join(principal.warnings).lower())

    def test_local_dev_without_subject_falls_back_to_anonymous(self):
        principal = console_authz.resolve_principal(
            env={console_authz.AUTH_MODE_ENV: "local-dev"}
        )
        self.assertFalse(principal.authenticated)

    def test_local_dev_unknown_role_degrades_to_viewer(self):
        principal = console_authz.resolve_principal(
            env={
                console_authz.AUTH_MODE_ENV: "local_dev",
                console_authz.DEV_SUBJECT_ENV: "dev@example.com",
                console_authz.DEV_ROLE_ENV: "root",
            }
        )
        self.assertEqual(principal.role, console_authz.VIEWER)

    def test_access_proxy_without_header_fails_closed(self):
        """A proxy-mode request that did not traverse the proxy is anonymous."""
        principal = console_authz.resolve_principal(
            headers={},
            env={console_authz.AUTH_MODE_ENV: "access_proxy"},
        )
        self.assertFalse(principal.authenticated)

    def test_access_proxy_role_comes_from_server_config_not_client(self):
        env = {
            console_authz.AUTH_MODE_ENV: "access_proxy",
            console_authz.ROLE_MAP_ENV: json.dumps(
                {"ops@example.com": "controller"}
            ),
        }
        principal = console_authz.resolve_principal(
            headers={
                console_authz.ACCESS_SUBJECT_HEADER: "ops@example.com",
                "x-role": "admin",  # client-supplied role must be ignored
            },
            env=env,
        )
        self.assertEqual(principal.role, "controller")

    def test_access_proxy_unmapped_subject_defaults_to_viewer(self):
        principal = console_authz.resolve_principal(
            headers={
                console_authz.ACCESS_SUBJECT_HEADER: "stranger@example.com"
            },
            env={console_authz.AUTH_MODE_ENV: "access_proxy"},
        )
        self.assertEqual(principal.role, console_authz.VIEWER)

    def test_malformed_role_map_does_not_raise_and_denies(self):
        principal = console_authz.resolve_principal(
            headers={console_authz.ACCESS_SUBJECT_HEADER: "ops@example.com"},
            env={
                console_authz.AUTH_MODE_ENV: "access_proxy",
                console_authz.ROLE_MAP_ENV: "{not json",
            },
        )
        self.assertEqual(principal.role, console_authz.VIEWER)

    def test_probe_auth_is_opt_in(self):
        self.assertFalse(console_authz.probe_auth_required(env={}))
        self.assertTrue(
            console_authz.probe_auth_required(
                env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"}
            )
        )

    def test_probe_auth_is_declared_but_not_yet_enforced(self):
        """Phase 1 declares the probe-auth policy; no route enforces it yet.

        The flag exists so the Phase 2 action framework has a declared policy
        to honour instead of inventing a second one. Pinning the current
        not-enforced status here means wiring it later is a deliberate change
        that updates this test and the documentation together, rather than a
        silent behaviour shift. The documentation must say so plainly, because
        an operator who sets the variable believing it protects a probe is
        worse off than one who knows it does not.
        """
        import inspect

        from webui import app as webui_app

        source = inspect.getsource(webui_app)
        self.assertNotIn(
            "probe_auth_required",
            source,
            msg=(
                "webui.app now consults probe_auth_required, so probe auth is "
                "no longer merely declared. Update the 'Probe authentication' "
                "section of docs/webui-authz-audit.md, which states it "
                "enforces nothing, and replace this test with real "
                "enforcement coverage."
            ),
        )
        self.assertIn(
            "enforces nothing today",
            AUTHZ_DOC.read_text(encoding="utf-8"),
        )


class TestRedaction(unittest.TestCase):
    """AC2 — required redaction units: token, keychain, password patterns."""

    def test_token_assignment_is_redacted(self):
        out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl")
        self.assertIn(REDACTED, out)
        self.assertNotIn("abcd1234efgh5678ijkl", out)

    def test_password_assignment_is_redacted(self):
        out = redact_text("password: hunter2supersecret")
        self.assertIn(REDACTED, out)
        self.assertNotIn("hunter2supersecret", out)

    def test_keychain_reference_is_redacted(self):
        out = redact_text("keychain:gitea-prgs-token")
        self.assertIn(REDACTED, out)
        self.assertNotIn("gitea-prgs-token", out)

    def test_keychain_command_is_redacted(self):
        out = redact_text("security find-generic-password -s gitea -w")
        self.assertIn(REDACTED, out)
        self.assertNotIn("find-generic-password -s gitea", out)

    def test_bearer_credential_is_redacted(self):
        out = redact_text("Authorization: Bearer abcdef1234567890abcdef")
        self.assertNotIn("abcdef1234567890abcdef", out)

    def test_jwt_is_redacted(self):
        token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop"
        out = redact_text(f"session={token}")
        self.assertNotIn(token, out)

    def test_private_key_block_is_redacted(self):
        pem = (
            "-----BEGIN RSA PRIVATE KEY-----\n"
            "MIIEowIBAAKCAQEAsecretmaterial\n"
            "-----END RSA PRIVATE KEY-----"
        )
        out = redact_text(pem)
        self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out)

    def test_api_key_assignment_is_redacted(self):
        out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"')
        self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out)

    def test_nested_payload_is_redacted_recursively(self):
        payload = {
            "token": "abc123456789",
            "nested": {"note": "password=letmein12345"},
            "list": ["keychain:some-entry"],
            "safe": "plain text",
        }
        out = redact_payload(payload)
        self.assertEqual(out["token"], REDACTED)
        self.assertNotIn("letmein12345", json.dumps(out))
        self.assertNotIn("some-entry", json.dumps(out))
        self.assertEqual(out["safe"], "plain text")

    def test_scan_reports_findings_before_and_none_after(self):
        dirty = "password: hunter2supersecret"
        self.assertTrue(scan_for_secrets(dirty))
        self.assertEqual(scan_for_secrets(redact_text(dirty)), [])

    def test_non_strings_pass_through_untouched(self):
        self.assertEqual(redact_text(42), 42)
        self.assertEqual(
            redact_payload({"n": 1, "b": True}), {"n": 1, "b": True}
        )

    def test_policy_is_documented_and_declares_redact_before_persist(self):
        policy = redaction_policy()
        self.assertTrue(policy["redact_before_persist"])
        self.assertIn("audit_records", policy["applies_to"])
        self.assertTrue(policy["console_rules"])

    def test_policy_statement_contains_no_secret_material(self):
        self.assertEqual(scan_for_secrets(redaction_policy()), [])


class TestAuditSchema(unittest.TestCase):
    """AC3 — audit event schema, required fields, and retention defaults."""

    def _event(self, action_id="merge_pr", **kwargs):
        return console_audit.build_event(
            action_id=action_id,
            result=console_audit.RESULT_DENIED,
            decision=console_authz.authorize(action_id, _principal("operator")),
            target={"kind": "pr", "ref": "#123"},
            request_id="req-test",
            **kwargs,
        )

    def test_every_required_field_is_present(self):
        event = self._event()
        for field in console_audit.REQUIRED_FIELDS:
            with self.subTest(field=field):
                self.assertIn(field, event)

    def test_actor_carries_who_and_how_they_were_identified(self):
        event = self._event()
        for field in console_audit.REQUIRED_ACTOR_FIELDS:
            with self.subTest(field=field):
                self.assertIn(field, event["actor"])

    def test_correlation_ids_are_present(self):
        event = self._event()
        for field in console_audit.REQUIRED_CORRELATION_FIELDS:
            with self.subTest(field=field):
                self.assertIn(field, event["correlation"])
        self.assertEqual(event["correlation"]["request_id"], "req-test")
        self.assertEqual(event["correlation"]["mcp_task"], "merge_pr")

    def test_timestamp_is_timezone_aware_utc_iso8601(self):
        now = datetime.datetime(
            2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc
        )
        event = self._event(now=now)
        self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00")
        parsed = datetime.datetime.fromisoformat(event["timestamp"])
        self.assertIsNotNone(parsed.tzinfo)

    def test_retention_defaults_by_class(self):
        self.assertEqual(
            console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90
        )
        self.assertEqual(
            console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED],
            365,
        )
        self.assertEqual(
            console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS],
            730,
        )

    def test_break_glass_action_retains_longest(self):
        event = self._event("merge_pr")
        self.assertEqual(
            event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS
        )

    def test_routine_write_uses_standard_retention(self):
        event = self._event("comment_issue")
        self.assertEqual(
            event["retention"]["class"], console_audit.RETENTION_STANDARD
        )

    def test_unknown_action_retains_as_privileged_not_standard(self):
        """Conservative direction: keep an unclassifiable record longer."""
        self.assertEqual(
            console_audit.retention_class_for(None),
            console_audit.RETENTION_PRIVILEGED,
        )

    def test_retention_expiry_matches_declared_days(self):
        now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc)
        event = self._event("comment_issue", now=now)
        expires = datetime.datetime.fromisoformat(
            event["retention"]["expires_at"]
        )
        self.assertEqual((expires - now).days, 90)

    def test_invalid_result_degrades_to_failed(self):
        event = console_audit.build_event(action_id="merge_pr", result="banana")
        self.assertEqual(event["result"], console_audit.RESULT_FAILED)

    def test_denied_result_is_representable(self):
        """An authorization denial has no MCP-side mutation record."""
        self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS)

    def test_event_is_redacted_before_it_is_returned(self):
        event = console_audit.build_event(
            action_id="merge_pr",
            result=console_audit.RESULT_DENIED,
            detail="failed with token=abcdef1234567890",
            metadata={"password": "hunter2supersecret"},
        )
        serialized = json.dumps(event)
        self.assertNotIn("abcdef1234567890", serialized)
        self.assertNotIn("hunter2supersecret", serialized)
        self.assertTrue(event["redacted"])

    def test_audit_policy_reports_schema_and_retention(self):
        policy = console_audit.audit_policy()
        self.assertTrue(policy["append_only"])
        self.assertTrue(policy["redact_before_persist"])
        self.assertEqual(
            policy["retention_defaults_days"], console_audit.RETENTION_DAYS
        )


class TestAuditSink(unittest.TestCase):
    """Append-only persistence behaviour."""

    def test_write_is_a_noop_when_sink_is_unconfigured(self):
        saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
        try:
            self.assertFalse(console_audit.audit_enabled())
            self.assertFalse(console_audit.write_event({"schema_version": 1}))
        finally:
            if saved is not None:
                os.environ[console_audit.AUDIT_LOG_ENV] = saved

    def test_records_append_one_json_line_each(self):
        with tempfile.TemporaryDirectory() as tmp:
            sink = os.path.join(tmp, "console-audit.jsonl")
            for _ in range(3):
                event = console_audit.build_event(
                    action_id="merge_pr", result=console_audit.RESULT_DENIED
                )
                self.assertTrue(console_audit.write_event(event, path=sink))
            with open(sink, encoding="utf-8") as handle:
                lines = [json.loads(line) for line in handle if line.strip()]
            self.assertEqual(len(lines), 3)
            self.assertEqual(len({line["event_id"] for line in lines}), 3)

    def test_a_record_that_still_carries_a_secret_is_not_persisted(self):
        with tempfile.TemporaryDirectory() as tmp:
            sink = os.path.join(tmp, "console-audit.jsonl")
            leaky = {
                "schema_version": 1,
                "detail": "password: hunter2supersecret",
            }
            self.assertFalse(console_audit.write_event(leaky, path=sink))
            self.assertFalse(os.path.exists(sink))

    def test_write_never_raises_on_a_bad_path(self):
        self.assertFalse(
            console_audit.write_event(
                {"schema_version": 1}, path="/nonexistent-dir/audit.jsonl"
            )
        )

    def test_simulated_privileged_preview_creates_an_audit_record(self):
        """Required test: audit record creation for a privileged preview."""
        with tempfile.TemporaryDirectory() as tmp:
            sink = os.path.join(tmp, "console-audit.jsonl")
            os.environ[console_audit.AUDIT_LOG_ENV] = sink
            try:
                decision = console_authz.authorize(
                    "merge_pr", _principal("controller")
                )
                outcome = console_audit.record_event(
                    action_id="merge_pr",
                    result=console_audit.RESULT_PREVIEWED,
                    decision=decision,
                    target={"kind": "pr", "ref": "#123"},
                    request_id="req-preview",
                )
            finally:
                os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
            self.assertTrue(outcome["written"])
            with open(sink, encoding="utf-8") as handle:
                record = json.loads(handle.read().strip())
            self.assertEqual(record["action"], "merge_pr")
            self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED)
            self.assertEqual(record["action_class"], "privileged")
            self.assertTrue(record["decision"]["allowed"])
            self.assertFalse(record["decision"]["execution_enabled"])
            self.assertEqual(record["actor"]["role"], "controller")

    def test_decision_block_survives_redaction(self):
        """Regression: naming it 'authorization' collided with a secret hint.

        ``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the
        HTTP header), so a block under that key was replaced wholesale by the
        placeholder and the record lost its decision entirely.
        """
        event = console_audit.build_event(
            action_id="merge_pr",
            result=console_audit.RESULT_DENIED,
            decision=console_authz.authorize("merge_pr", _principal("admin")),
        )
        self.assertIsInstance(event["decision"], dict)
        self.assertIn("allowed", event["decision"])


class TestConsoleRoutes(unittest.TestCase):
    """AC4 — the wired Phase 2 integration points, still fail-closed."""

    def setUp(self):
        self.client = TestClient(create_app(bind_host="127.0.0.1"))

    def test_unauthenticated_write_stub_is_denied(self):
        """Required test: default-deny for unauthenticated write stubs."""
        response = self.client.post(
            "/api/actions/merge_pr/attempt", json={"pr_number": 99}
        )
        self.assertEqual(response.status_code, 403)
        body = response.json()
        self.assertFalse(body["success"])
        authorization = body["authorization"]
        self.assertFalse(authorization["allowed"])
        self.assertEqual(
            authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED
        )
        self.assertFalse(authorization["execution_enabled"])

    def test_preview_reports_an_authorization_decision(self):
        response = self.client.get("/api/actions/merge_pr/preview?pr_number=7")
        self.assertEqual(response.status_code, 200)
        authorization = response.json()["authorization"]
        self.assertFalse(authorization["allowed"])
        self.assertTrue(authorization["dual_control"])
        self.assertEqual(authorization["required_role"], "controller")

    def test_unknown_action_preview_still_404s(self):
        response = self.client.get("/api/actions/no_such_action/preview")
        self.assertEqual(response.status_code, 404)

    def test_security_model_endpoint_publishes_all_three_policies(self):
        response = self.client.get("/api/console/security-model")
        self.assertEqual(response.status_code, 200)
        body = response.json()
        self.assertIn("rbac", body)
        self.assertIn("redaction", body)
        self.assertIn("audit", body)
        self.assertEqual(body["rbac"]["default_decision"], "deny")

    def test_security_model_endpoint_leaks_no_secrets(self):
        response = self.client.get("/api/console/security-model")
        self.assertEqual(scan_for_secrets(response.json()), [])

    def test_security_model_rejects_writes(self):
        response = self.client.post("/api/console/security-model", json={})
        self.assertEqual(response.status_code, 405)

    def test_existing_read_routes_are_unaffected(self):
        for path in ("/", "/health", "/actions", "/api/actions"):
            with self.subTest(path=path):
                self.assertEqual(self.client.get(path).status_code, 200)


class TestAuthzAuditDoc(unittest.TestCase):
    """The model must be written down, not only coded."""

    @classmethod
    def setUpClass(cls):
        cls.text = (
            AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else ""
        )

    def test_doc_exists(self):
        self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}")

    def test_doc_covers_each_required_section(self):
        for heading in (
            "Identity sources",
            "Role matrix",
            "Privileged actions",
            "Secret redaction",
            "Audit event schema",
            "Retention",
            "Phase 2 integration",
            "Local-dev mode",
        ):
            with self.subTest(heading=heading):
                self.assertIn(heading, self.text)

    def test_doc_names_every_role(self):
        for role in console_authz.ROLE_ORDER:
            with self.subTest(role=role):
                self.assertIn(role, self.text)

    def test_doc_names_every_console_action(self):
        for action_id in console_authz.ACTIONS:
            with self.subTest(action=action_id):
                self.assertIn(action_id, self.text)

    def test_doc_states_retention_defaults(self):
        for days in console_audit.RETENTION_DAYS.values():
            with self.subTest(days=days):
                self.assertIn(str(days), self.text)

    def test_doc_warns_local_dev_is_insecure(self):
        self.assertIn("INSECURE", self.text.upper())

    def test_doc_states_default_deny(self):
        self.assertIn("deny", self.text.lower())

    def test_doc_contains_no_secret_material(self):
        self.assertEqual(scan_for_secrets(self.text), [])

    def test_deployment_doc_links_to_the_model(self):
        deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8")
        self.assertIn("webui-authz-audit", deployment)


if __name__ == "__main__":  # pragma: no cover
    unittest.main()
