789 lines
38 KiB
Bash
Executable File
789 lines
38 KiB
Bash
Executable File
#!/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 \
|
|
"internal semantic infrastructure documentation contract" \
|
|
"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
|
|
|
|
grep -Fq '## Internal Qdrant + Ollama semantic infrastructure' "$root/PROJECT_STATE.md" || {
|
|
echo "PROJECT_STATE.md does not record the internal Qdrant/Ollama snapshot" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'Qdrant and Ollama are internal Compose services' "$root/AGENTS.md" || {
|
|
echo "AGENTS.md does not record the stable internal semantic-service guidance" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'Do not add vector or embedding endpoint credentials to the bundle.' \
|
|
"$root/deploy/secrets/README.md" || {
|
|
echo "secret bundle guide still permits vector/embedding runtime secrets" >&2
|
|
exit 1
|
|
}
|
|
if rg -n 'engine: pgvector|provider: ollama_compatible|THT_WS_<NAMESPACE>_VECTOR_TRANSPORT|THT_WS_<NAMESPACE>_EMBEDDING_BASE_URL' \
|
|
"$root/docs/workspace-diagnostic-protocol.md"; then
|
|
echo "workspace diagnostic protocol still documents external vector or embedding contracts" >&2
|
|
exit 1
|
|
fi
|
|
|
|
server_guide="$root/docs/install/server.md"
|
|
grep -Fq 'scripts/prepare-server-pi-state.sh /srv/thothii/pi-state 10001 10001' "$server_guide" || {
|
|
echo "server guide does not initialize nested Pi-state targets before Compose" >&2
|
|
exit 1
|
|
}
|
|
grep -Fq 'prepare-server-pi-state.sh' "$root/docs/install/server-workspace-registry.md" || {
|
|
echo "server workspace-registry guide omits the Pi-state clean-install precondition" >&2
|
|
exit 1
|
|
}
|
|
grep -Eq '^sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii$' "$server_guide" || {
|
|
echo "server operations guide does not set the parent traversal boundary" >&2
|
|
exit 1
|
|
}
|
|
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"
|
|
|
|
project_state_fixture="$negative_root/project-state.md"
|
|
python3 - "$root/PROJECT_STATE.md" "$project_state_fixture" <<'PY'
|
|
import pathlib, sys
|
|
source = pathlib.Path(sys.argv[1]).read_text()
|
|
target = pathlib.Path(sys.argv[2])
|
|
marker = source.index("## Historical snapshots")
|
|
contradiction = """
|
|
## Contradictory release note — LIVE 2026-08-08
|
|
|
|
- Schema-v2 descriptors are operational again.
|
|
- The supported Compose stack is exactly `frontend` plus `core`.
|
|
- DWH, vector DB, embedding, LLM, and reverse-proxy services are external configurable endpoints.
|
|
|
|
"""
|
|
target.write_text(source[:marker] + contradiction + source[marker:])
|
|
PY
|
|
project_state_output="$negative_root/project-state-output"
|
|
set +e
|
|
verify_project_state_current_contract "$project_state_fixture" contradictory-project-state >"$project_state_output" 2>&1
|
|
project_state_status=$?
|
|
set -e
|
|
if [[ $project_state_status -eq 0 ]] || ! grep -Fq "contradictory active text" "$project_state_output"; then
|
|
echo "contradictory current-state fixture was not rejected correctly" >&2
|
|
cat "$project_state_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
for level in 1 2 3 4 5 6; do
|
|
project_state_live_heading="$negative_root/project-state-live-heading-h$level.md"
|
|
python3 - "$root/PROJECT_STATE.md" "$project_state_live_heading" "$level" <<'PY'
|
|
import pathlib, sys
|
|
source = pathlib.Path(sys.argv[1]).read_text()
|
|
target = pathlib.Path(sys.argv[2])
|
|
level = int(sys.argv[3])
|
|
marker = source.index("## Historical snapshots")
|
|
historical = source[marker:]
|
|
replacement = "#" * level + " Session summary redesign — LIVE 2026-07-23"
|
|
historical = historical.replace("### Historical snapshot — Session summary redesign (2026-07-23)", replacement, 1)
|
|
target.write_text(source[:marker] + historical)
|
|
PY
|
|
set +e
|
|
verify_project_state_current_contract "$project_state_live_heading" "historical-live-heading-h$level" >"$project_state_output" 2>&1
|
|
project_state_status=$?
|
|
set -e
|
|
if [[ $project_state_status -eq 0 ]] || ! grep -Fq "active/live heading markers" "$project_state_output"; then
|
|
echo "historical LIVE-heading fixture was not rejected correctly for heading level $level" >&2
|
|
cat "$project_state_output" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
workspace_fixture="$negative_root/workspace-invalid.yaml"
|
|
python3 - "$root/deploy/workspaces/example.yaml" "$workspace_fixture" <<'PY'
|
|
import pathlib, sys, yaml
|
|
doc = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text())
|
|
doc["semantic_index"]["embedding"]["dimensions"] = 768
|
|
pathlib.Path(sys.argv[2]).write_text(yaml.safe_dump(doc, sort_keys=False))
|
|
PY
|
|
workspace_output="$negative_root/workspace-output"
|
|
set +e
|
|
verify_workspace_descriptor_semantic_contract "$workspace_fixture" invalid-workspace >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]] || ! grep -Fq "embedding dimensions must be 1024" "$workspace_output"; then
|
|
echo "semantic workspace fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
project_state_positive="$negative_root/project-state-positive.md"
|
|
cat >"$project_state_positive" <<'EOF'
|
|
# ThothII — Project State
|
|
|
|
> Starting-point snapshot.
|
|
|
|
## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08
|
|
|
|
- Schema-v3 descriptors are operational and v1/v2 remain `migration_required`.
|
|
- One workspace owns one Qdrant collection.
|
|
- Only DWH and LLM remain external runtime application endpoints.
|
|
- The internal stack includes `qdrant`, `embedding`, and `embedding-model-init`.
|
|
|
|
## Historical snapshots — superseded context
|
|
|
|
### Historical snapshot — previous deployment
|
|
|
|
- Older notes intentionally live only here.
|
|
EOF
|
|
verify_project_state_current_contract "$project_state_positive" positive-project-state >/dev/null
|
|
|
|
local_manual_paraphrase="$negative_root/local-manual-paraphrase.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_paraphrase"
|
|
python3 - "$local_manual_paraphrase" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |",
|
|
"| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
semantic_index_spec="$(semantic_index_relationship_spec)"
|
|
verify_markdown_table_relationships "$local_manual_paraphrase" "local manual paraphrase" "Semantic index ownership contract" "$semantic_index_spec" >/dev/null
|
|
|
|
production_paraphrase_root="$negative_root/production-paraphrase-root"
|
|
mkdir -p "$production_paraphrase_root"
|
|
rsync -a \
|
|
--exclude '.git' \
|
|
--exclude '.pytest_cache' \
|
|
--exclude 'node_modules' \
|
|
--exclude 'backend/node_modules' \
|
|
--exclude 'frontend/node_modules' \
|
|
--exclude 'harness/.venv' \
|
|
"$root/" "$production_paraphrase_root/"
|
|
python3 - "$production_paraphrase_root/docs/install/local-workspace-registry.md" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |",
|
|
"| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
"$production_paraphrase_root/scripts/verify-workspace-install-docs.sh" --fixtures-only \
|
|
>"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -ne 0 ]]; then
|
|
echo "production verifier rejected the accepted semantic-index paraphrase" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local_manual_missing="$negative_root/local-manual-missing.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_missing"
|
|
python3 - "$local_manual_missing" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |\n",
|
|
"",
|
|
)
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$local_manual_missing" "local manual missing ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "ownership omission fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
local_manual_scattered="$negative_root/local-manual-scattered.md"
|
|
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_scattered"
|
|
python3 - "$local_manual_scattered" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace(
|
|
"| Workspace semantic index | Each workspace reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |\n",
|
|
"",
|
|
)
|
|
text += "\nWorkspace. Qdrant. Collection. Schema. Evidence. Memory. Payload kind.\n"
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$local_manual_scattered" "local manual scattered ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "scattered ownership tokens fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
compact_paraphrase="$negative_root/compact-paraphrase.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_paraphrase"
|
|
python3 - "$compact_paraphrase" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace("| DWH | Esterno | Endpoint esterno configurato dall'installazione. |", "| DWH | Esterno | Endpoint esterno deciso dall'installazione. |")
|
|
text = text.replace("| LLM | Esterno | Endpoint o policy esterna all'infrastruttura semantica interna. |", "| LLM | Esterno | Endpoint o policy che resta esterna all'infrastruttura semantica interna. |")
|
|
text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Interno | Servizio Compose interno obbligatorio con il volume persistente `qdrant-data`. |")
|
|
text = text.replace("| Ollama embedding | Interno | Servizio Compose interno obbligatorio per `qwen3-embedding:0.6b`. |", "| Ollama embedding | Interno | Servizio Compose interno obbligatorio dedicato a `qwen3-embedding:0.6b`. |")
|
|
path.write_text(text)
|
|
PY
|
|
compact_spec='{"rows":[
|
|
{"componente":"^DWH$","ownership":"^Esterno$","contratto operativo":"endpoint.*estern"},
|
|
{"componente":"^LLM$","ownership":"^Esterno$","contratto operativo":"esterna|esterno"},
|
|
{"componente":"^Qdrant$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qdrant-data"},
|
|
{"componente":"^Ollama embedding$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qwen3-embedding:0\\.6b"}
|
|
]}'
|
|
verify_markdown_table_relationships "$compact_paraphrase" "compact manual paraphrase" "Contratto sintetico di ownership" "$compact_spec" >/dev/null
|
|
|
|
compact_inversion="$negative_root/compact-inversion.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_inversion"
|
|
python3 - "$compact_inversion" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Esterno | Servizio esterno condiviso. |")
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$compact_inversion" "compact inversion" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "compact inversion fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
compact_scattered="$negative_root/compact-scattered.md"
|
|
cp "$root/docs/installazione-docker-4-contesti.md" "$compact_scattered"
|
|
python3 - "$compact_scattered" <<'PY'
|
|
import pathlib, sys
|
|
path = pathlib.Path(sys.argv[1])
|
|
text = path.read_text()
|
|
start = text.index("## Contratto sintetico di ownership")
|
|
end = text.index("## Comando standard locale")
|
|
text = text[:start] + "Qdrant Interno DWH Esterno LLM Esterno Ollama embedding Interno.\n\n" + text[end:]
|
|
path.write_text(text)
|
|
PY
|
|
set +e
|
|
verify_markdown_table_relationships "$compact_scattered" "compact scattered tokens" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1
|
|
workspace_status=$?
|
|
set -e
|
|
if [[ $workspace_status -eq 0 ]]; then
|
|
echo "compact scattered-token fixture was not rejected correctly" >&2
|
|
cat "$workspace_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
adapted_reorder="$negative_root/caddy-adapted-reorder.json"
|
|
adapted_bypass="$negative_root/caddy-adapted-bypass.json"
|
|
node - "$adapted_reorder" "$adapted_bypass" <<'NODE'
|
|
const fs = require("fs");
|
|
const publicHeaders = [
|
|
"X-Thoth-Principal-Issuer", "X-Thoth-Principal-Subject",
|
|
"X-Thoth-Principal-Display-Name", "X-Thoth-Is-Admin",
|
|
];
|
|
const trustedHeaders = [
|
|
"X-Thoth-Trusted-Principal-Issuer", "X-Thoth-Trusted-Principal-Subject",
|
|
"X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Trusted-Is-Admin",
|
|
];
|
|
const clear = (name) => ({handler: "headers", request: {delete: [name]}});
|
|
const auth = {
|
|
handler: "reverse_proxy", upstreams: [{dial: "auth-gateway:4180"}],
|
|
handle_response: [{match: {status_code: [2]}, routes: [{handle: trustedHeaders.map((name, index) => ({
|
|
handler: "headers", request: {set: {[name]: [`{http.reverse_proxy.header.${publicHeaders[index]}}`]}},
|
|
}))}]}],
|
|
};
|
|
const document = {routes: [{handle: [
|
|
...publicHeaders.map(clear), auth, ...trustedHeaders.map(clear),
|
|
{handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]},
|
|
]}]};
|
|
fs.writeFileSync(process.argv[2], JSON.stringify(document));
|
|
const frontend = {handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]};
|
|
const validChain = [...publicHeaders, ...trustedHeaders].map(clear).concat(auth, frontend);
|
|
fs.writeFileSync(process.argv[3], JSON.stringify({routes: [
|
|
{handle: validChain},
|
|
{handle: [frontend]},
|
|
]}));
|
|
NODE
|
|
adapted_output="$negative_root/caddy-adapted-output"
|
|
set +e
|
|
verify_caddy_adapted_identity_order "$adapted_reorder" >"$adapted_output" 2>&1
|
|
adapted_status=$?
|
|
set -e
|
|
if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted identity clears must execute before authentication" "$adapted_output"; then
|
|
echo "Caddy reordered adapted-handler fixture was not rejected correctly" >&2
|
|
cat "$adapted_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
set +e
|
|
verify_caddy_adapted_identity_order "$adapted_bypass" >"$adapted_output" 2>&1
|
|
adapted_status=$?
|
|
set -e
|
|
if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted frontend path bypasses complete authentication contract" "$adapted_output"; then
|
|
echo "Caddy additional direct frontend route fixture was not rejected correctly" >&2
|
|
cat "$adapted_output" >&2
|
|
exit 1
|
|
fi
|
|
|
|
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-parent-traversal":
|
|
changed = original.replace("sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii\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 "nginx-admin-clear-wrong-scope": {
|
|
const clear = ' proxy_set_header X-Thoth-Is-Admin "";';
|
|
const authAt = original.indexOf(clear);
|
|
changed = original.slice(0, authAt) + original.slice(authAt + clear.length + 1);
|
|
const frontendAt = changed.indexOf(clear);
|
|
changed = changed.slice(0, frontendAt) + clear + "\n" + clear + changed.slice(frontendAt + clear.length);
|
|
break;
|
|
}
|
|
case "nginx-additional-bypass":
|
|
changed = original.replace(" location / {", " location /bypass {\n proxy_pass http://127.0.0.1:8080;\n }\n\n location / {");
|
|
break;
|
|
case "nginx-comment-only-auth":
|
|
changed = original.replace(" location / {", ` location /comment-only-auth {
|
|
# auth_request /_authenticate;
|
|
# auth_request_set $thoth_principal_issuer $upstream_http_x_thoth_principal_issuer;
|
|
# auth_request_set $thoth_principal_subject $upstream_http_x_thoth_principal_subject;
|
|
# auth_request_set $thoth_principal_display_name $upstream_http_x_thoth_principal_display_name;
|
|
# auth_request_set $thoth_is_admin $upstream_http_x_thoth_is_admin;
|
|
# proxy_set_header X-Thoth-Principal-Issuer "";
|
|
# proxy_set_header X-Thoth-Principal-Subject "";
|
|
# proxy_set_header X-Thoth-Principal-Display-Name "";
|
|
# proxy_set_header X-Thoth-Is-Admin "";
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Issuer $thoth_principal_issuer;
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Subject $thoth_principal_subject;
|
|
# proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;
|
|
# proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;
|
|
proxy_set_header X-Comment-Literal "quoted#value"; # preserve the quoted hash
|
|
proxy_pass http://127.0.0.1:8080; # active frontend path
|
|
}
|
|
|
|
location / {`);
|
|
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 "caddy-clears-after-auth": {
|
|
const clearPattern = /(?:\t\trequest_header -X-(?:Authenticated-User|Thoth-[^\n]+)\n)+/;
|
|
const clears = original.match(clearPattern)?.[0] || "";
|
|
changed = original.replace(clearPattern, "");
|
|
changed = changed.replace("\n\t\treverse_proxy 127.0.0.1:8080 {", "\n" + clears + "\n\t\treverse_proxy 127.0.0.1:8080 {");
|
|
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 parent traversal boundary" verify_server_guide \
|
|
"$root/docs/install/server.md" docs/install/server.md server-parent-traversal \
|
|
"server installation guide does not set parent traversal boundary"
|
|
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 auth location 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 frontend location 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 frontend location 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 frontend location does not map authenticated admin identity"
|
|
expect_guide_rejected \
|
|
"Nginx admin clear moved out of auth scope" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-admin-clear-wrong-scope \
|
|
"Nginx auth location does not clear inbound admin identity"
|
|
expect_guide_rejected \
|
|
"Nginx additional frontend bypass location" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-additional-bypass \
|
|
"Nginx frontend upstream location bypasses complete authentication contract"
|
|
expect_guide_rejected \
|
|
"Nginx frontend auth directives only in comments" verify_reverse_proxy_nginx_guide \
|
|
"$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-comment-only-auth \
|
|
"Nginx frontend upstream location bypasses complete authentication contract"
|
|
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 \
|
|
"Caddy identity clears reordered after auth" verify_reverse_proxy_caddy_guide \
|
|
"$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-clears-after-auth \
|
|
"Caddy identity clears must precede forward_auth"
|
|
|
|
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"
|