#!/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 } # grep reads the whole tail (-c, not -q): an early exit would end tail # with SIGPIPE, which pipefail turns into a false refusal. if [[ "$(tail -c "+$((offset + 1))" "$file" | grep -c 'msg="http request"' || true)" == 0 ]]; 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")" # A long log after an early record: the search for it must not fail when # it stops reading early (SIGPIPE under pipefail). cp "$dir/clean.log" "$dir/long.log" python3 -c 'import sys; sys.stdout.write("[go] level=INFO msg=\"http request\" method=GET pattern=\"GET /a\" status=200 duration_ms=1\n" * 20000)' >>"$dir/long.log" expect_status go-log-long 0 scan_go_log "$dir/long.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