#!/usr/bin/env bash set -euo pipefail script_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) root=$script_root if [[ $# -gt 0 ]]; then [[ $# -eq 2 && $1 == "--root" && -d $2 ]] || { echo "usage: auth-docs-smoke.sh [--root DIRECTORY]" >&2 exit 2 } root=$(cd "$2" && pwd -P) fi docs=( "$root/docs/architecture/authentication.md" "$root/docs/install/authentication-local.md" "$root/docs/install/authentication-oidc.md" "$root/docs/install/authentik.md" "$root/docs/testing/authentication-manual-acceptance.md" "$root/docs/architecture/overview.md" "$root/docs/install/local.md" "$root/docs/install/server.md" "$root/docs/install/psd-workspace-setup.md" "$root/docs/install/reverse-proxy-caddy.md" "$root/docs/install/reverse-proxy-nginx.md" "$root/docs/guida-utente.md" "$root/docs/index.md" "$root/README.md" "$root/PROJECT_STATE.md" "$root/mkdocs.yml" ) for path in "${docs[@]}"; do [[ -f "$path" ]] || { echo "auth docs smoke: missing $path" >&2; exit 1; } done corpus=$(mktemp) trap 'rm -f "$corpus"' EXIT cat "${docs[@]}" >"$corpus" required=( "tht auth" "groups" "TOT Admin" "THT_OIDC_CLIENT_SECRET" "THT_AUTHENTIK_API_TOKEN" "Remember me" "oidc_mapped_group_missing" "oidc_callback_failed" "session.read_all" "workspace.secrets.manage" "auth.diagnostics.read" ) for term in "${required[@]}"; do rg -Fq "$term" "$corpus" || { echo "auth docs smoke: missing required term: $term" >&2; exit 1; } done canonical_compose='docker compose --env-file deploy/env/local.env -f compose.yaml -f deploy/compose.local.yaml up --build -d' rg -Fxq "$canonical_compose" "$root/docs/install/local.md" || { echo "auth docs smoke: missing canonical local Compose command" >&2 exit 1 } if rg -n -F 'docker compose --env-file deploy/env/local.env +' "$corpus"; then echo "auth docs smoke: noncanonical local Compose command" >&2 exit 1 fi nav_count=$(awk 'index($0, "architecture/authentication.md") { count++ } END { print count + 0 }' "$root/mkdocs.yml") [[ $nav_count == 1 ]] || { echo "auth docs smoke: authentication navigation must appear exactly once" >&2 exit 1 } python3 - "$root" <<'PY' import pathlib import re import sys root = pathlib.Path(sys.argv[1]) architecture = (root / "docs/architecture/authentication.md").read_text() user_row = "| `user` | `session.use` |" admin_row = ( "| `admin` | `session.use`, `session.read_all`, `session.manage_all`, `settings.manage`, " "`workspace.manage`, `workspace.secrets.manage`, `pi.manage`, `auth.diagnostics.read` |" ) if user_row not in architecture or admin_row not in architecture: raise SystemExit("auth docs smoke: role-to-permission map is not exact") diagnostic_heading = "## Diagnostics and ordering" diagnostic_start = architecture.find(diagnostic_heading) diagnostic_end = architecture.find("\n## ", diagnostic_start + len(diagnostic_heading)) diagnostic_section = architecture[diagnostic_start:diagnostic_end if diagnostic_end >= 0 else None] match = re.search(r"```text\n([\s\S]*?)```", diagnostic_section) expected_codes = [ "auth_ready", "auth_config_incomplete", "auth_config_invalid", "auth_session_store_invalid", "local_user_registry_invalid", "local_admin_missing", "oidc_secret_missing", "oidc_discovery_unreachable", "oidc_issuer_mismatch", "oidc_jwks_unreachable", "oidc_group_catalog_unreachable", "oidc_group_catalog_unauthorized", "oidc_mapped_group_missing", "oidc_mapped_group_ambiguous", "oidc_groups_claim_invalid", "oidc_device_flow_unavailable", ] actual_codes = [] if match is None else [line for line in match.group(1).splitlines() if line] if actual_codes != expected_codes: raise SystemExit("auth docs smoke: diagnostic code union is not exact") for relative, language, forbidden, required in [ ("docs/install/reverse-proxy-caddy.md", "caddyfile", "forward_auth", "forward_auth"), ("docs/install/reverse-proxy-nginx.md", "nginx", "auth_request", "auth_request"), ]: source = (root / relative).read_text() direct_start = source.find("## Direct ThothII-managed OIDC") deprecated_start = source.find("## Deprecated upstream migration mode") if direct_start < 0 or deprecated_start <= direct_start: raise SystemExit(f"auth docs smoke: {relative} does not split direct and deprecated modes") direct = source[direct_start:deprecated_start] deprecated_end = source.find("\n## ", deprecated_start + 4) deprecated = source[deprecated_start:deprecated_end if deprecated_end >= 0 else None] blocks = re.findall(rf"```{language}\n([\s\S]*?)```", direct) direct_code = "\n".join(blocks) if "/api/auth/oidc/login" not in direct or "/api/auth/oidc/callback" not in direct: raise SystemExit(f"auth docs smoke: {relative} omits unchanged public OIDC paths") if re.search(rf"(?m)^\s*{forbidden}\b", direct_code): raise SystemExit(f"auth docs smoke: {relative} applies external auth in direct OIDC mode") deprecated_code = "\n".join( re.findall(rf"```{language}\n([\s\S]*?)```", deprecated) ) if not re.search(rf"(?m)^\s*{required}\b", deprecated_code): raise SystemExit(f"auth docs smoke: {relative} omits scoped deprecated upstream auth") PY if rg -n -i --pcre2 '\bthothii-admin\b|\bthothctl\b[^\r\n]{0,256}\bauth\b' "$corpus"; then echo "auth docs smoke: forbidden authentication CLI wording" >&2 exit 1 fi if rg -n -i --pcre2 -- '--password(?!-file)\b(?:[[:space:]]+|=)\S+' "$corpus"; then echo "auth docs smoke: plaintext password option" >&2 exit 1 fi if rg -n -i --pcre2 '(?:^|[,{[:space:]])password[[:space:]]*:[[:space:]]*\S+|"password"[[:space:]]*:[[:space:]]*(?:"[^"]+"|[^,}[:space:]]+)' "$corpus"; then echo "auth docs smoke: plaintext password field" >&2 exit 1 fi if rg -n -i --pcre2 '(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)|(?:warnings?|alerts?|advisory|advisories|notices?|notifications?|noise)[^.\r\n]{0,160}(?:generate|produce|emit|cause|trigger|raise|create|result)[^.\r\n]{0,160}(?:unmapped|additional|extra)[^.\r\n]{0,160}groups?' "$corpus"; then echo "auth docs smoke: misleading noise claim for unmapped groups" >&2 exit 1 fi echo "auth docs smoke: required terms and forbidden wording checks passed"