#!/usr/bin/env bash # Regression test for copyable installation examples and secret-path validation. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")" verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")" negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")" trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM "$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output" for fixture in \ "local installation guide contract" \ "source update fail-closed semantics" \ "Windows line-ending recovery guide contract" \ "Pi management guide contract" \ "server installation guide contract" \ "Nginx reverse-proxy guide contract" \ "Caddy reverse-proxy guide contract" \ "local installation example rendered from path with spaces" \ "server installation example rendered from path with spaces" \ "server pinned migration image fixture" \ "server backup checksum root-only fixture" \ "local manual canonical base+override references" \ "server manual canonical base+override references" \ "canonical local base+override fixture" \ "canonical server base+override fixture" \ "relative secret-source fixture rejected" \ "CRLF recovery rewrites bytes and preserves mode-120000 symlinks"; do grep -Fqx "$fixture passed" "$output" >/dev/null || { echo "missing fixture verification: $fixture" >&2 cat "$output" >&2 exit 1 } done server_guide="$root/docs/install/server.md" for required in \ 'thothii-ops' \ 'THT_BACKUP_ROOT=/srv/thothii-backups' \ 'sessions migrate --yes' \ '"pending":[]' \ '"drifted":[]' \ 'remove --yes' \ 'sha256sum --check SHA256SUMS' \ 'DOCKER-USER' \ 'iptables -I INPUT' \ 'com.docker.network.bridge.name'; do grep -Fq -- "$required" "$server_guide" || { echo "server operations guide lacks executable contract: $required" >&2 exit 1 } done grep -Fq '"$THTCTL" --help' "$server_guide" || { echo "server guide lacks plain thothctl --help" >&2 exit 1 } if grep -Fq '"$THTCTL" --installation "$INSTALLATION" --help' "$server_guide"; then echo "server guide still uses installation-scoped --help" >&2 exit 1 fi for manual in "$root/docs/install/local-workspace-registry.md"; do grep -Fq 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' "$manual" || { echo "installation manual does not publish a self-contained THT_SOURCE_ROOT export: $manual" >&2 exit 1 } grep -Fq 'export THT_WORKSPACE_BINDINGS_ENV_FILE=' "$manual" || { echo "installation manual does not publish a self-contained bindings export: $manual" >&2 exit 1 } if rg -n 'source[[:space:]]+\.env' "$manual"; then echo "installation manual unsafely imports operator .env: $manual" >&2 exit 1 fi done grep -Fq 'THTCTL=/srv/thothii/operator/thothctl' \ "$root/docs/install/server-workspace-registry.md" || { echo "server installation manual does not use the installation-aware operator CLI" >&2 exit 1 } grep -Fq 'INSTALLATION=/srv/thothii/operator/thothii-installation.yaml' \ "$root/docs/install/server-workspace-registry.md" || { echo "server installation manual does not identify the server installation descriptor" >&2 exit 1 } if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|connector-secrets\.workspace-registry|docker compose' \ "$root/docs/install/local-workspace-registry.md" \ "$root/docs/install/server-workspace-registry.md"; then echo "installation manuals still document a bypassed Compose or copied connector override path" >&2 exit 1 fi # Load only the verifier's function definitions so each deliberately unsafe guide can be checked # in isolation without invoking Docker-backed Compose fixtures. sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions" # shellcheck source=/dev/null source "$verifier_functions" negative_failures=0 expect_guide_rejected() { local label="$1" validator="$2" source_guide="$3" relative_path="$4" local mutation="$5" expected_error="$6" local fixture_root="$negative_root/${label// /-}" local fixture_output="$fixture_root/output" mkdir -p "$fixture_root/$(dirname "$relative_path")" cp "$source_guide" "$fixture_root/$relative_path" if [[ "$validator" == verify_windows_line_endings_guide ]]; then mkdir -p "$fixture_root/scripts" cp "$root/scripts/verify-line-endings.sh" "$fixture_root/scripts/verify-line-endings.sh" fi node - "$fixture_root/$relative_path" "$mutation" <<'NODE' const fs = require("fs"); const [path, mutation] = process.argv.slice(2); const original = fs.readFileSync(path, "utf8"); let changed = original; switch (mutation) { case "durable-selector": changed = original.replaceAll("--source build", "--source stale-build"); break; case "dangerous-volumes": changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`"); break; case "incomplete-powershell": changed = original.replaceAll("icacls.exe", "Write-Output"); break; case "broken-crlf": changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # "); break; case "raw-pi": changed += "\n```sh\ndocker compose exec core pi --version\n```\n"; break; case "server-secret-env": changed += "\n```dotenv\nTHT_MODEL_API_KEY=unsafe-secret-value\n```\n"; break; case "server-docker-socket": changed += "\nMount /var/run/docker.sock into core for management.\n"; break; case "server-coupling": changed += "\nAttach core to the omics_portal application network.\n"; break; case "server-host-loopback": changed += "\nFor host-gateway, keep the external service listening on 127.0.0.1.\n"; break; case "server-raw-remove": changed += "\n```sh\ndocker rm thothii-core thothii-frontend\n```\n"; break; case "server-pinned-migrator-mismatch": changed += "\n```yaml\nservices:\n core:\n image: registry.invalid/core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n session-migrate:\n image: thothii-core:local\n```\n"; break; case "server-pinned-frontend-missing": changed = original.replace(' frontend:\n build: !reset null\n image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>\n', ''); break; case "nginx-no-auth": changed = original.replace(" auth_request /_authenticate;", " # authentication omitted"); break; case "nginx-core-upstream": changed = original.replaceAll("http://127.0.0.1:8080", "http://127.0.0.1:8787"); break; case "nginx-no-sse": changed = original.replace(" proxy_buffering off;", " proxy_buffering on;"); break; case "nginx-no-issuer-clear": changed = original.replaceAll('proxy_set_header X-Thoth-Principal-Issuer "";', 'proxy_set_header X-Thoth-Principal-Issuer $http_x_thoth_principal_issuer;'); break; case "nginx-no-subject-capture": changed = original.replace("auth_request_set $thoth_principal_subject", "# missing auth capture $thoth_principal_subject"); break; case "nginx-no-display-map": changed = original.replace("proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;", "proxy_set_header X-Thoth-Trusted-Principal-Display-Name \"\";"); break; case "nginx-no-admin-map": changed = original.replace("proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;", "proxy_set_header X-Thoth-Trusted-Is-Admin \"\";"); break; case "caddy-no-auth": changed = original.replace("forward_auth auth-gateway:4180 {", "# forward authentication omitted"); break; case "caddy-client-identity": changed = original.replace("X-Thoth-Principal-Subject>X-Thoth-Trusted-Principal-Subject", "X-Thoth-Principal-Subject"); break; case "caddy-core-upstream": changed = original.replaceAll("127.0.0.1:8080", "127.0.0.1:8787"); break; case "caddy-no-issuer-public-clear": changed = original.replace("request_header -X-Thoth-Principal-Issuer", "request_header X-Thoth-Principal-Issuer {header.X-Thoth-Principal-Issuer}"); break; case "caddy-no-subject-trusted-clear": changed = original.replace("request_header -X-Thoth-Trusted-Principal-Subject", "request_header X-Thoth-Trusted-Principal-Subject {header.X-Thoth-Trusted-Principal-Subject}"); break; case "caddy-no-display-map": changed = original.replace("X-Thoth-Principal-Display-Name>X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Principal-Display-Name"); break; case "caddy-no-admin-map": changed = original.replace("X-Thoth-Is-Admin>X-Thoth-Trusted-Is-Admin", "X-Thoth-Is-Admin"); break; case "dirty-source": changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short"); break; case "failed-pull": changed = original.replace("if ! git pull --ff-only; then abort_update", "if git pull --ff-only; then abort_update"); break; case "failed-status": changed = original.replace("if ! RUNNING_PI_VERSION=", "if RUNNING_PI_VERSION="); break; case "failed-build": changed = original.replace("if ! bash scripts/build-local.sh; then", "if bash scripts/build-local.sh; then"); break; case "same-version-no-selector": changed = original.replace("TRANSACTIONAL_PI_UPDATE=false", "TRANSACTIONAL_PI_UPDATE=true # unsafe same-version no-op"); break; case "powershell-source-failure": changed = original.replace("Assert-NativeSuccess 'Pi status'", "Write-Output 'Pi status unchecked'"); break; case "failed-export": changed = original.replace("if ! git checkout-index --all --force", "if git checkout-index --all --force"); break; case "partial-export": changed = original.replace("if ! validate_index_export; then", "if validate_index_export; then"); break; case "mode-120000": changed = original.replaceAll("120000", "100644-no-symlink-mode"); break; case "powershell-crlf-failure": changed = original.replace("Assert-NativeSuccess 'index export'", "Write-Output 'index export unchecked'"); break; default: throw new Error(`unknown negative-fixture mutation: ${mutation}`); } if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`); fs.writeFileSync(path, changed); NODE set +e (root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1 local status=$? set -e if [[ $status -eq 0 ]]; then echo "negative fixture accepted: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) elif ! grep -Fq -- "$expected_error" "$fixture_output"; then echo "negative fixture failed for the wrong reason: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) fi } expect_guide_rejected \ "durable selector keeps old core" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md durable-selector \ "installation-aware source update lacks structural token: --source build" expect_guide_rejected \ "dangerous down volumes instruction" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md dangerous-volumes \ "docker compose down --volumes must appear only in an explicit prose prohibition" expect_guide_rejected \ "incomplete native PowerShell path" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md incomplete-powershell \ "native PowerShell setup lacks structural token: icacls.exe" expect_guide_rejected \ "renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \ "Windows line-ending guide lacks required instruction: git checkout-index --all --force" expect_guide_rejected \ "raw non-installation-aware Pi access" verify_pi_management_guide \ "$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \ "raw non-installation-aware Compose Pi access is forbidden" expect_guide_rejected \ "server secret in environment" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-secret-env \ "server installation guide embeds a secret value" expect_guide_rejected \ "server Docker socket mount" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-docker-socket \ "server installation guide introduces a Docker socket dependency" expect_guide_rejected \ "server application coupling" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-coupling \ "server installation guide introduces forbidden application coupling" expect_guide_rejected \ "server host-gateway loopback listener" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-host-loopback \ "server host-gateway guidance assumes a host loopback listener" expect_guide_rejected \ "server raw container removal" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-raw-remove \ "server uninstall bypasses installation-aware removal" expect_guide_rejected \ "server pinned migrator differs from core" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-pinned-migrator-mismatch \ "server pinned migration image must equal the pinned core image" expect_guide_rejected \ "server pinned frontend is missing" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-pinned-frontend-missing \ "server pinned image override must pin core, session-migrate, and frontend without builds" expect_guide_rejected \ "Nginx identity without authentication" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-auth \ "Nginx proxy lacks structural token: auth_request /_authenticate;" expect_guide_rejected \ "Nginx direct core exposure" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-core-upstream \ "Nginx proxy must forward only to frontend on 127.0.0.1:8080" expect_guide_rejected \ "Nginx buffered SSE" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-sse \ "Nginx proxy lacks structural token: proxy_buffering off;" expect_guide_rejected \ "Nginx issuer inbound claim not cleared" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-issuer-clear \ "Nginx proxy does not clear inbound issuer identity" expect_guide_rejected \ "Nginx subject auth response not captured" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-subject-capture \ "Nginx proxy does not capture authenticated subject identity" expect_guide_rejected \ "Nginx display identity not mapped to private hop" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-display-map \ "Nginx proxy does not map authenticated display identity" expect_guide_rejected \ "Nginx admin identity not mapped to private hop" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-admin-map \ "Nginx proxy does not map authenticated admin identity" expect_guide_rejected \ "Caddy identity without authentication" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \ "Caddy proxy lacks structural token: forward_auth auth-gateway:4180 {" expect_guide_rejected \ "Caddy untrusted identity forwarding" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-client-identity \ "Caddy proxy does not map authenticated subject identity" expect_guide_rejected \ "Caddy direct core exposure" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-core-upstream \ "Caddy proxy must forward only to frontend on 127.0.0.1:8080" expect_guide_rejected \ "Caddy issuer inbound claim not cleared" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-issuer-public-clear \ "Caddy proxy does not clear inbound issuer identity" expect_guide_rejected \ "Caddy subject private-hop claim not cleared" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-subject-trusted-clear \ "Caddy proxy does not clear inbound trusted subject identity" expect_guide_rejected \ "Caddy display identity not mapped to private hop" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-display-map \ "Caddy proxy does not map authenticated display identity" expect_guide_rejected \ "Caddy admin identity not mapped to private hop" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-admin-map \ "Caddy proxy does not map authenticated admin identity" expect_guide_rejected \ "dirty or untracked source tree" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md dirty-source \ "installation-aware source update lacks structural token: git status --porcelain --untracked-files=all" expect_guide_rejected \ "failed source pull" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-pull \ "POSIX source update does not fail closed: source pull" expect_guide_rejected \ "failed thothctl Pi status" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-status \ "POSIX source update does not fail closed: Pi status" expect_guide_rejected \ "failed local build" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-build \ "POSIX source update does not fail closed: local build" expect_guide_rejected \ "same Pi version without durable selector" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md same-version-no-selector \ "POSIX source update lacks the same-version/no-selector path" expect_guide_rejected \ "PowerShell source command failure propagation" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md powershell-source-failure \ "PowerShell source update does not propagate failure: Pi status" expect_guide_rejected \ "failed index export" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md failed-export \ "POSIX CRLF repair lacks fail-closed semantic: if ! git checkout-index" expect_guide_rejected \ "partial index export" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md partial-export \ "POSIX CRLF repair does not prove a complete export before destructive rewrite" expect_guide_rejected \ "mode 120000 symlink preservation" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md mode-120000 \ "POSIX CRLF repair lacks fail-closed semantic: 120000" expect_guide_rejected \ "PowerShell CRLF command failure propagation" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md powershell-crlf-failure \ "PowerShell CRLF repair lacks failure propagation: Assert-NativeSuccess 'index export'" if (( negative_failures != 0 )); then echo "$negative_failures unsafe installation-document fixtures were accepted" >&2 exit 1 fi echo "unsafe installation-document fixtures rejected passed"