Files
summercms/scripts/check-phase14.2.sh
Jakub Zych f70a0a59d6 fix(14.2): WR-08 gate self-test: dev-stack refuses the request log off
Planted env files through dev-stack.sh exec: unset SUMMER_HTTP__ACCESS_LOG
and SUMMER_STORAGE__UPLOADS__SERVE default to true; false or 0 is refused.
2026-10-12 11:28:22 +02:00

667 lines
27 KiB
Bash
Executable File

#!/usr/bin/env bash
# Phase 14.2 fail-closed gate: the local vue-fonoteka-app on the local
# fonoteka.go SummerCMS backend. Every stage exits non-zero on a failing
# command, a go test run that fails, skips, matches zero tests, prints "no
# tests to run", a named required test that did not pass or a data race.
#
# Modes:
# --self-test the go test detector, the Playwright verdict
# (dev-stack.sh e2e-verdict), the go.log scan and the summary
# checks fail closed on planted inputs; every mode exists
# --go go vet and go test ./... in summercms.go, in the fonoteka.go
# workspace root module and in the fonoteka plugin module,
# then the phase's named tests through the detector
# --docs TestDocsTree and summer docs:build --check
# --parity TestParityCorpus with the 175/175 summary
# --contracts the phase's Nuxt contract test (blocking) and the whole
# pnpm test:contracts suite (counts reported, not blocking:
# missing-catalog.test.mjs fails on HEAD before this phase,
# see the phase's deferred-items.md)
# --guard planted env files fed to dev-stack.sh guard: every case
# must print "refuse:" and exit non-zero; a fake SummerCMS
# marker must pass (control case)
# --e2e the running local stack (started with up --detach when it
# is not up, restarted when dev-stack.sh fresh finds a source
# newer than its binary, and refused unless fresh passes
# after up), the guard on the real env file, the Go-gate
# Playwright specs through dev-stack.sh e2e (any skip,
# unexpected, flaky or zero expected fails), the D-15
# report-only specs (counts printed, never blocking) and the
# Go log scan (search engine failure or a 5xx record fails,
# and so does a run that wrote no http request record)
# --all every stage in that order; the last line is
# "Phase 14.2 gate passed"
#
# Paths: PHASE142_ROOT (this checkout), PHASE142_APP (default
# $ROOT/../fonoteka.go), PHASE142_NUXT (the vue-fonoteka-app checkout).
# Postgres integration runs through testcontainers: an unavailable Docker
# daemon fails the gate, and -short is never final evidence. A missing host
# service (Postgres, Typesense, Centrifugo) fails --e2e through the
# dev-stack.sh refusal; nothing is downgraded to a skip.
#
# TMPDIR: the gate leaves it as set by the caller (PHASE142_TMPDIR
# overrides). It must not point inside a go.work tree such as fonoteka.go:
# the scaffold tests in internal/build then edit that workspace's go.work,
# and `go test ./...` there picks up the build directories as packages.
# dev-stack.sh gives the Go processes and Playwright their own TMPDIR on the
# app's device (the file bucket renames across devices otherwise).
set -euo pipefail
unset FORCE_COLOR
ROOT="${PHASE142_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
APP="${PHASE142_APP:-$ROOT/../fonoteka.go}"
NUXT="${PHASE142_NUXT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}"
DEV_STACK="$APP/scripts/dev-stack.sh"
PLUGIN_DIR="$APP/plugins/golem15/fonoteka"
GO_LOG="$APP/tmp/dev-stack/go.log"
if [[ -n "${PHASE142_TMPDIR:-}" ]]; then
mkdir -p "$PHASE142_TMPDIR"
export TMPDIR="$PHASE142_TMPDIR"
fi
CONSOLE_REQUIRE=(TestDevSeedRefusesProduction TestDevSeedRefusesForeignRows TestDevSeedShape TestDevSeedIdempotent TestReindexCommand)
APP_REQUIRE=(TestDevStackSmoke TestDevSeedOpensDatabase TestReindexOpensDatabase TestCommandsOpenDatabase TestDevMarkerRoute)
FRAMEWORK_REQUIRE=(TestUploadsMount TestAccessLog TestAccessLogSmoke)
GATE_SPECS=(
auth.setup.ts login.spec.ts smoke.spec.ts fonoteka-item.spec.ts public-share.spec.ts
realtime.spec.ts i18n.spec.ts album-edit-rate-cover.spec.ts album-search.spec.ts wishlist.spec.ts
)
REPORT_SPECS=(
fonoteka-ai-assist.spec.ts fonoteka-bulk-add.spec.ts offline.spec.ts
registration-invitation.spec.ts responsive.spec.ts landing.spec.ts
)
PHASE_CONTRACTS=(tests/contracts/e2e-backend-switch.test.mjs)
PARITY_SUMMARY="recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0"
usage() {
cat >&2 <<'EOF'
usage:
check-phase14.2.sh --self-test
check-phase14.2.sh --go
check-phase14.2.sh --docs
check-phase14.2.sh --parity
check-phase14.2.sh --contracts
check-phase14.2.sh --guard
check-phase14.2.sh --e2e
check-phase14.2.sh --all
EOF
exit 2
}
# detect reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name, 6 data race.
detect() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
passed = set()
failed = []
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
print("refuse: non-json test output", file=sys.stderr)
sys.exit(4)
action = ev.get("Action")
test = ev.get("Test") or ""
pkg = ev.get("Package") or ev.get("ImportPath") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed {pkg}", file=sys.stderr)
sys.exit(1)
text = ev.get("Output") or ""
if action == "output":
if "no tests to run" in text:
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
if "WARNING: DATA RACE" in text:
print(f"refuse: data race in {pkg} {test}", file=sys.stderr)
sys.exit(6)
if action == "skip" and test:
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
sys.exit(2)
if action == "fail":
failed.append(f"{pkg} {test}".strip())
if action == "pass" and test:
passed.add(test)
if failed:
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
sys.exit(1)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
top = {name for name in passed if "/" not in name}
missing = [n for n in require if n not in top and not any(p.startswith(n + "/") or p == n for p in passed)]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# go_json DIR ARGS... runs go test -json in DIR through detect. With
# GO_JSON_KEEP set the raw log is kept at that path.
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
detect "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
grep -v '^{' "$log" | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
if [[ -n "${GO_JSON_KEEP:-}" ]]; then
mv "$log" "$GO_JSON_KEEP"
else
rm -f "$log"
fi
rm -f "$err"
}
expect_detect() {
local name="$1" want="$2" payload="$3" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
detect "$log" 2>/dev/null || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test $name: detector exit $dc, want $want" >&2
return 1
fi
}
# run_regex builds an anchored -run alternation from test names.
run_regex() {
local IFS='|'
printf '^(%s)$' "$*"
}
# scan_go_log FILE [OFFSET] refuses a silent search fallback or a 5xx
# request record anywhere in FILE (research G8, D-12). A 499 (the surf
# access log's status for a 5xx caused by the client going away) is not a
# 5xx; a 5xx with client_gone=true is. It also refuses unless the part of
# FILE after OFFSET bytes (the output of this run; default the whole file)
# has an "http request" record: with the access log off a clean scan would
# prove nothing (WR-08).
scan_go_log() {
local file="$1" offset="${2:-0}" hits
[[ -f "$file" ]] || {
echo "refuse: Go log $file not found" >&2
return 1
}
if ! tail -c "+$((offset + 1))" "$file" | grep -q 'msg="http request"'; then
echo "refuse: $file has no http request record from this run; set SUMMER_HTTP__ACCESS_LOG=true in the env file" >&2
return 1
fi
hits="$(grep -nE 'album search engine failed|status=5[0-9][0-9]([^0-9]|$)' "$file" || true)"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | tail -n 20 >&2
echo "refuse: $file has a search engine failure or a 5xx record (rotate the log only after reading it)" >&2
return 1
fi
}
# tap_clean FILE refuses a node --test TAP summary unless it has tests > 0
# and fail, cancelled, skipped and todo all 0.
tap_clean() {
python3 - "$1" <<'PY'
import re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
counts = {}
for key in ("tests", "pass", "fail", "cancelled", "skipped", "todo"):
found = re.findall(rf"^# {key} (\d+)$", text, re.M)
if not found:
print(f"refuse: TAP summary has no '# {key}' line", file=sys.stderr)
sys.exit(1)
counts[key] = int(found[-1])
if counts["tests"] == 0 or counts["pass"] != counts["tests"]:
print(f"refuse: TAP summary {counts}", file=sys.stderr)
sys.exit(1)
for key in ("fail", "cancelled", "skipped", "todo"):
if counts[key] != 0:
print(f"refuse: TAP summary {counts}", file=sys.stderr)
sys.exit(1)
print("tests={tests} pass={pass} fail={fail} skipped={skipped}".format(**counts))
PY
}
expect_status() {
local name="$1" want="$2" got=0
shift 2
"$@" >/dev/null 2>&1 || got=$?
if { [[ "$want" == 0 ]] && [[ "$got" -ne 0 ]]; } || { [[ "$want" != 0 ]] && [[ "$got" -eq 0 ]]; }; then
echo "refuse: self-test $name: exit $got, want $([[ "$want" == 0 ]] && echo 0 || echo non-zero)" >&2
return 1
fi
}
# expect_verdict NAME WANT JSON: dev-stack.sh e2e-verdict on a planted
# report must refuse (WANT=refuse, non-zero and a "refuse:" line) or pass.
expect_verdict() {
local name="$1" want="$2" payload="$3" dir out rc=0
dir="$(mktemp -d)"
printf '%s\n' "$payload" >"$dir/report.json"
out="$(env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" e2e-verdict "$dir/report.json" 2>&1)" || rc=$?
rm -rf "$dir"
if [[ "$want" == refuse ]]; then
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out"; then
echo "refuse: self-test verdict $name was not refused (exit $rc): $out" >&2
return 1
fi
elif [[ "$rc" -ne 0 ]] || ! grep -q '^e2e-verdict: passed' <<<"$out"; then
echo "refuse: self-test verdict $name was refused (exit $rc): $out" >&2
return 1
fi
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
[[ -f "$DEV_STACK" ]] || {
echo "refuse: $DEV_STACK not found" >&2
return 1
}
bash -n "$DEV_STACK"
expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestDevSeedRefusesProduction"}'
expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestDevSeedShape"}'
expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestUploadsMount"}'
expect_detect subtest-skip 2 '{"Action":"pass","Package":"p","Test":"TestUploadsMount/off"}
{"Action":"skip","Package":"p","Test":"TestUploadsMount/db"}
{"Action":"pass","Package":"p","Test":"TestUploadsMount"}'
expect_detect zero 3 '{"Action":"pass","Package":"p"}'
expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_detect nonjson 4 '{"Action":"pass",'
expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"}
{"Action":"pass","Package":"p","Test":"TestA"}'
REQUIRE_TESTS="TestDevSeedRefusesProduction TestDevMarkerRoute" expect_detect missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestDevSeedRefusesProduction"}'
# Playwright verdicts (T-14.2-17).
expect_verdict clean pass '{"stats":{"expected":17,"unexpected":0,"skipped":0,"flaky":0}}'
expect_verdict skipped refuse '{"stats":{"expected":16,"unexpected":0,"skipped":1,"flaky":0}}'
expect_verdict unexpected refuse '{"stats":{"expected":16,"unexpected":1,"skipped":0,"flaky":0}}'
expect_verdict flaky refuse '{"stats":{"expected":16,"unexpected":0,"skipped":0,"flaky":1}}'
expect_verdict zero-expected refuse '{"stats":{"expected":0,"unexpected":0,"skipped":0,"flaky":0}}'
expect_verdict malformed refuse '{"stats":{"expected":17,'
expect_verdict no-stats refuse '{"suites":[]}'
local out rc=0
out="$(env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" e2e-verdict "$(mktemp -u)" 2>&1)" || rc=$?
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out"; then
echo "refuse: self-test verdict missing-report was not refused" >&2
return 1
fi
# The seed's minted API token secret never reaches the terminal (WR-06).
local redacted
redacted="$(printf '%s\n' 'user: created' 'api token: created inv_selftestSecret-0123 (shown once)' |
env -i PATH="$PATH" HOME="$HOME" bash "$DEV_STACK" redact-seed)"
if grep -q 'inv_selftestSecret' <<<"$redacted" || ! grep -q '^api token: created' <<<"$redacted" || ! grep -q '^user: created$' <<<"$redacted"; then
echo "refuse: self-test redact-seed did not hide the token secret: $redacted" >&2
return 1
fi
# dev-stack.sh fresh on a planted app and framework tree (WR-07): a
# stack whose stamp is newer than every source passes; a newer source,
# or no stamp, is refused; docs, planning files and tests are ignored.
local tree
tree="$(mktemp -d)"
mkdir -p "$tree/app/scripts" "$tree/app/tmp/dev-stack" "$tree/app/.planning" "$tree/fw/modules/a" "$tree/fw/docs"
cp "$DEV_STACK" "$tree/app/scripts/dev-stack.sh"
touch -d '2 minutes ago' "$tree/app/scripts/dev-stack.sh" "$tree/app/main.go" "$tree/fw/modules/a/a.go"
touch -d '1 minute ago' "$tree/app/tmp/dev-stack/build.stamp"
fresh_case() {
env -i PATH="$PATH" HOME="$HOME" DEV_STACK_FRAMEWORK_DIR="$tree/fw" bash "$tree/app/scripts/dev-stack.sh" fresh
}
expect_status fresh-clean 0 fresh_case
touch "$tree/fw/docs/page.md" "$tree/app/.planning/STATE.md" "$tree/fw/modules/a/a_test.go"
expect_status fresh-ignored 0 fresh_case
touch "$tree/fw/modules/a/a.go"
expect_status fresh-stale-framework 1 fresh_case
touch -d '2 minutes ago' "$tree/fw/modules/a/a.go"
touch "$tree/app/main.go"
expect_status fresh-stale-app 1 fresh_case
touch -d '2 minutes ago' "$tree/app/main.go"
rm -f "$tree/app/tmp/dev-stack/build.stamp"
expect_status fresh-no-stamp 1 fresh_case
rm -rf "$tree"
# dev-stack.sh refuses an env file that turns the request log or the
# uploads route off (WR-08); unset, both default to true.
local envdir key
envdir="$(mktemp -d)"
mkdir -p "$envdir/nuxt"
: >"$envdir/nuxt/nuxt.config.ts"
{
printf 'DEV_STACK_NUXT_DIR=%s\n' "$envdir/nuxt"
for key in SUMMER_DATABASE__DSN SUMMER_APP__KEY SUMMER_GOLEM15__USER__JWT__SECRET SUMMER_ADMIN__JWT__SECRET \
SUMMER_SEARCH__TYPESENSE__API_KEY SUMMER_REALTIME__CENTRIFUGO__API_URL SUMMER_REALTIME__CENTRIFUGO__API_KEY \
SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET NUXT_PUBLIC_CENTRIFUGO_WS_URL; do
printf '%s=planted\n' "$key"
done
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8423\n'
} >"$envdir/base.conf"
keys_case() {
env -i PATH="$PATH" HOME="$HOME" DEV_STACK_ENV_FILE="$1" bash "$DEV_STACK" exec -- \
sh -c '[ "$SUMMER_HTTP__ACCESS_LOG" = true ] && [ "$SUMMER_STORAGE__UPLOADS__SERVE" = true ]'
}
expect_status keys-default-on 0 keys_case "$envdir/base.conf"
{ cat "$envdir/base.conf"; printf 'SUMMER_HTTP__ACCESS_LOG=false\n'; } >"$envdir/log-off.conf"
expect_status keys-access-log-off 1 keys_case "$envdir/log-off.conf"
{ cat "$envdir/base.conf"; printf 'SUMMER_STORAGE__UPLOADS__SERVE=0\n'; } >"$envdir/serve-off.conf"
expect_status keys-uploads-off 1 keys_case "$envdir/serve-off.conf"
rm -rf "$envdir"
# Go log scan (T-14.2-20) and summaries.
local dir
dir="$(mktemp -d)"
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=200 duration_ms=1' \
'[go] level=INFO msg="http request" method=GET pattern="GET /b" status=499 duration_ms=3 client_gone=true' >"$dir/clean.log"
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=503 duration_ms=1' >"$dir/5xx.log"
printf '%s\n' '[go] level=INFO msg="http request" method=GET pattern="GET /a" status=500 duration_ms=1 client_gone=true' >"$dir/5xx-gone.log"
printf '%s\n' '[go] level=WARN msg="album search engine failed" err=timeout' >"$dir/search.log"
printf '%s\n' '[go] level=INFO msg="serve: listening" addr=127.0.0.1:8423' >"$dir/no-records.log"
expect_status go-log-clean 0 scan_go_log "$dir/clean.log"
expect_status go-log-5xx 1 scan_go_log "$dir/5xx.log"
expect_status go-log-5xx-client-gone 1 scan_go_log "$dir/5xx-gone.log"
expect_status go-log-search 1 scan_go_log "$dir/search.log"
expect_status go-log-missing 1 scan_go_log "$dir/absent.log"
expect_status go-log-no-records 1 scan_go_log "$dir/no-records.log"
expect_status go-log-empty 1 scan_go_log /dev/null
# Request records only before this run's offset: the run logged none.
cat "$dir/clean.log" "$dir/no-records.log" >"$dir/old-records.log"
expect_status go-log-old-records 1 scan_go_log "$dir/old-records.log" "$(stat -c '%s' "$dir/clean.log")"
cat "$dir/no-records.log" "$dir/clean.log" >"$dir/new-records.log"
expect_status go-log-new-records 0 scan_go_log "$dir/new-records.log" "$(stat -c '%s' "$dir/no-records.log")"
printf '# tests 9\n# pass 9\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-clean"
printf '# tests 9\n# pass 8\n# fail 1\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-fail"
printf '# tests 9\n# pass 8\n# fail 0\n# cancelled 0\n# skipped 1\n# todo 0\n' >"$dir/tap-skip"
printf '# tests 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n# todo 0\n' >"$dir/tap-zero"
printf 'ok 1 - x\n' >"$dir/tap-none"
expect_status tap-clean 0 tap_clean "$dir/tap-clean"
expect_status tap-fail 1 tap_clean "$dir/tap-fail"
expect_status tap-skip 1 tap_clean "$dir/tap-skip"
expect_status tap-zero 1 tap_clean "$dir/tap-zero"
expect_status tap-none 1 tap_clean "$dir/tap-none"
rm -rf "$dir"
local flag
for flag in --self-test --go --docs --parity --contracts --guard --e2e --all; do
grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode $flag" >&2
return 1
}
done
echo "phase14.2 self-test passed"
}
run_go() {
[[ -d "$APP" && -d "$PLUGIN_DIR" ]] || {
echo "refuse: fonoteka.go workspace $APP not found" >&2
return 1
}
(cd "$ROOT" && go vet ./... && go test ./... -count=1 -timeout 60m)
go -C "$APP" vet ./...
go -C "$APP" test ./... -count=1 -timeout 60m
go -C "$PLUGIN_DIR" vet ./...
go -C "$PLUGIN_DIR" test ./... -count=1 -timeout 60m
REQUIRE_TESTS="${CONSOLE_REQUIRE[*]}" go_json "$APP" ./plugins/golem15/fonoteka/console \
-count=1 -timeout 20m -run "$(run_regex "${CONSOLE_REQUIRE[@]}")"
REQUIRE_TESTS="${APP_REQUIRE[*]}" go_json "$APP" ./plugins/golem15/fonoteka \
-count=1 -timeout 20m -run "$(run_regex "${APP_REQUIRE[@]}")"
REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" ./modules/surf \
-count=1 -timeout 20m -run "$(run_regex "${FRAMEWORK_REQUIRE[@]}")"
echo "phase14.2 go passed"
}
run_docs() {
REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" ./cmd/summer -count=1 -run '^TestDocsTree$'
local out
out="$(cd "$ROOT" && go run ./cmd/summer docs:build --check 2>&1)" || {
echo "$out" >&2
echo "refuse: docs:build --check failed" >&2
return 1
}
echo "phase14.2 docs passed"
}
run_parity() {
local log
log="$(mktemp)"
GO_JSON_KEEP="$log" REQUIRE_TESTS="TestParityCorpus" go_json "$APP" ./parity \
-count=1 -run '^TestParityCorpus$' -timeout 30m -v
if ! grep -qF "$PARITY_SUMMARY" "$log"; then
grep -oE 'recorded [0-9]+/[0-9]+[^"\\]*' "$log" | tail -n 3 >&2 || true
rm -f "$log"
echo "refuse: TestParityCorpus did not report \"$PARITY_SUMMARY\"" >&2
return 1
fi
rm -f "$log"
echo "phase14.2 parity passed ($PARITY_SUMMARY)"
}
run_contracts() {
[[ -f "$NUXT/package.json" ]] || {
echo "refuse: Nuxt checkout $NUXT not found" >&2
return 1
}
local log rc=0 counts
log="$(mktemp)"
(cd "$NUXT" && node --test "${PHASE_CONTRACTS[@]}") >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]] || ! counts="$(tap_clean "$log")"; then
grep -E '^not ok|^# ' "$log" >&2 || true
rm -f "$log"
echo "refuse: phase contract tests ${PHASE_CONTRACTS[*]} (exit $rc)" >&2
return 1
fi
echo "contracts (blocking) ${PHASE_CONTRACTS[*]}: $counts"
# The whole suite, reported only: missing-catalog.test.mjs fails on
# vue-fonoteka-app HEAD from before this phase (deferred-items.md).
rc=0
pnpm --dir "$NUXT" test:contracts >"$log" 2>&1 || rc=$?
echo "contracts (report only) pnpm test:contracts exit $rc: $(grep -E '^# (tests|pass|fail|skipped) ' "$log" | paste -sd ' ')"
grep -E '^not ok' "$log" | sed 's/^/contracts (report only) /' || true
rm -f "$log"
echo "phase14.2 contracts passed"
}
# guard_case NAME WANT FILE [ENV...]: dev-stack.sh guard on a planted env
# file in a clean environment. WANT is the reason the refusal must name.
guard_case() {
local name="$1" want="$2" file="$3" out rc=0
shift 3
out="$(env -i PATH="$PATH" HOME="$HOME" DEV_STACK_ENV_FILE="$file" "$@" bash "$DEV_STACK" guard 2>&1)" || rc=$?
if [[ "$rc" -eq 0 ]] || ! grep -q '^refuse:' <<<"$out" || ! grep -qF -- "$want" <<<"$out"; then
echo "$out" >&2
echo "refuse: guard case $name was not refused for \"$want\" (exit $rc)" >&2
return 1
fi
echo "guard $name: $(grep '^refuse:' <<<"$out" | head -n 1)"
}
GUARD_PIDS=()
stop_fakes() {
local pid
for pid in "${GUARD_PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
GUARD_PIDS=()
}
# fake_backend DIR STATUS BODY [HEADER]: a python3 http.server on a free
# loopback port, recorded in GUARD_PIDS; its port goes to FAKE_PORT.
FAKE_PORT=""
fake_backend() {
local dir="$1" status="$2" body="$3" header="${4:-}" portfile i
portfile="$dir/port-$RANDOM-$RANDOM"
python3 - "$portfile" "$status" "$body" "$header" >/dev/null 2>&1 <<'PY' &
import http.server, sys
portfile, status, body, header = sys.argv[1], int(sys.argv[2]), sys.argv[3].encode(), sys.argv[4]
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(status)
if header:
k, v = header.split(":", 1)
self.send_header(k.strip(), v.strip())
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
with open(portfile + ".tmp", "w") as fh:
fh.write(str(srv.server_address[1]))
import os
os.rename(portfile + ".tmp", portfile)
srv.serve_forever()
PY
GUARD_PIDS+=("$!")
for ((i = 0; i < 50; i++)); do
[[ -s "$portfile" ]] && break
sleep 0.1
done
[[ -s "$portfile" ]] || {
echo "refuse: fake backend did not start" >&2
return 1
}
FAKE_PORT="$(cat "$portfile")"
}
run_guard() {
[[ -f "$DEV_STACK" ]] || {
echo "refuse: $DEV_STACK not found" >&2
return 1
}
local dir port
dir="$(mktemp -d)"
GUARD_DIR="$dir"
trap 'stop_fakes; rm -rf "$GUARD_DIR"' EXIT
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8422\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:8422\n' >"$dir/guard-php-port.conf"
guard_case php-port "port 8422" "$dir/guard-php-port.conf"
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\n' >"$dir/guard-internal-unset.conf"
guard_case internal-unset "NUXT_INTERNAL_API_ORIGIN is unset" "$dir/guard-internal-unset.conf"
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:8423\nNUXT_INTERNAL_API_ORIGIN=http://localhost:8423\n' >"$dir/guard-mismatch.conf"
guard_case mismatch "differ" "$dir/guard-mismatch.conf"
printf 'NUXT_DEV_BACKEND_ORIGIN=https://plytarium.com\nNUXT_INTERNAL_API_ORIGIN=https://plytarium.com\n' >"$dir/guard-production.conf"
guard_case production "not loopback" "$dir/guard-production.conf"
fake_backend "$dir" 404 '{}' 'X-Powered-By: PHP/8.5.10'
port="$FAKE_PORT"
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-fake-php.conf"
guard_case fake-php "X-Powered-By: PHP" "$dir/guard-fake-php.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port"
fake_backend "$dir" 200 '{"backend":"winter","app":"fonoteka","env":"local"}'
port="$FAKE_PORT"
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-other-marker.conf"
guard_case other-marker 'did not answer "backend":"summercms"' "$dir/guard-other-marker.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port"
# Control: the same rules accept a SummerCMS marker, so the refusals
# above are the cases', not a guard that refuses everything.
fake_backend "$dir" 200 '{"backend":"summercms","app":"fonoteka","env":"local"}'
port="$FAKE_PORT"
printf 'NUXT_DEV_BACKEND_ORIGIN=http://127.0.0.1:%s\nNUXT_INTERNAL_API_ORIGIN=http://127.0.0.1:%s\n' "$port" "$port" >"$dir/guard-control.conf"
local out rc=0
out="$(env -i PATH="$PATH" HOME="$HOME" DEV_STACK_ENV_FILE="$dir/guard-control.conf" DEV_STACK_GO_ADDR="127.0.0.1:$port" bash "$DEV_STACK" guard 2>&1)" || rc=$?
if [[ "$rc" -ne 0 ]] || grep -q '^refuse:' <<<"$out"; then
echo "$out" >&2
echo "refuse: guard control case (a SummerCMS marker) was refused" >&2
return 1
fi
echo "guard control: $out"
echo "phase14.2 guard passed"
}
run_e2e() {
[[ -f "$DEV_STACK" ]] || {
echo "refuse: $DEV_STACK not found" >&2
return 1
}
local was_up=0 rc=0
if bash "$DEV_STACK" status >/dev/null 2>&1; then
was_up=1
echo "e2e: the local stack is already up; it is left running"
# A stack started before the latest source change would test old
# code (WR-07): restart it.
if ! bash "$DEV_STACK" fresh 2>&1 | sed 's/^/e2e: /'; then
echo "e2e: the running binary is older than its sources; restarting the stack"
bash "$DEV_STACK" down
fi
fi
if ((was_up == 0)); then
trap 'bash "$DEV_STACK" down || true' EXIT
fi
bash "$DEV_STACK" up --detach
bash "$DEV_STACK" fresh
seed_out_private
local log_start=0
[[ ! -f "$GO_LOG" ]] || log_start="$(stat -c '%s' "$GO_LOG")"
bash "$DEV_STACK" guard
bash "$DEV_STACK" e2e "${GATE_SPECS[@]}"
echo "e2e (report only, D-15): ${REPORT_SPECS[*]}"
bash "$DEV_STACK" e2e --report-only "${REPORT_SPECS[@]}" 2>&1 | sed 's/^/e2e (report only) /' || rc=$?
((rc == 0)) || echo "e2e (report only) run exited $rc (not blocking)"
scan_go_log "$GO_LOG" "$log_start"
echo "phase14.2 e2e passed"
}
# seed_out_private refuses unless tmp/dev-stack/seed.out, which can hold a
# minted API token secret, is readable by its owner only (WR-06).
seed_out_private() {
local file="$APP/tmp/dev-stack/seed.out" mode
[[ -f "$file" ]] || {
echo "refuse: $file not found" >&2
return 1
}
mode="$(stat -c '%a' "$file")"
[[ "$mode" == 600 ]] || {
echo "refuse: $file has mode $mode, want 600 (it can hold an API token secret)" >&2
return 1
}
}
run_all() {
local stage
for stage in self-test go docs parity contracts guard e2e; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
echo "Phase 14.2 gate passed"
}
case "${1:---all}" in
--self-test) run_self_test ;;
--go) run_go ;;
--docs) run_docs ;;
--parity) run_parity ;;
--contracts) run_contracts ;;
--guard) run_guard ;;
--e2e) run_e2e ;;
--all) run_all ;;
*) usage ;;
esac