#!/usr/bin/env bash # Phase 12.2 fail-closed gate (admin datepicker and fileupload fields, # relation child CRUD, deferred binding). # # Every stage exits non-zero on a failing command, a go test run that fails, # skips, matches zero tests or does not build, a named security test that is # missing, renamed or skipped, OpenAPI or dist drift, a docs checker problem # or a hygiene violation. --self-test proves the go test detector fails # closed on planted inputs. --all runs every stage, prints one PASS or FAIL # line per stage and stops at the first failure. # # Framework commands run in summercms.go; the application stage runs in the # sibling repository named by PHASE122_APP (default ../fonoteka.go). # Run with FORCE_COLOR unset: bonfire's colour tests read it. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="${PHASE122_APP:-$ROOT/../fonoteka.go}" # The named tests of the security stage, by prefix. Each prefix must match # at least one top-level test that passes; any skip refuses. SECURITY_CABANA=(TestRelationChildScope TestProtectedFile TestDeferredCommit TestFileupload TestRelationChild) SECURITY_LAGOON=(TestPurgeDeferred TestDeferredStore TestDeferredConcurrentFirstBind) SECURITY_ATTACH=(TestIsAllowedImage TestStore) # Files this phase added; the hygiene stage refuses a consuming-application # name in them and in the framework docs. PHASE_FILES=( modules/cabana/testdata/deferred modules/cabana/phase122_fixture_test.go modules/cabana/relation_child_scope_test.go modules/cabana/protected_file_test.go modules/cabana/relation_child_test.go modules/cabana/fileupload_test.go modules/cabana/deferred_commit_test.go modules/cabana/datepicker_test.go modules/cabana/field_file.go modules/cabana/field_date.go modules/cabana/deferred.go modules/cabana/relation_child.go modules/cabana/relation_form.go modules/lagoon/date.go modules/lagoon/deferred.go modules/lagoon/purge.go modules/lagoon/schedule.go modules/lagoon/purge_test.go modules/lagoon/attach/store.go modules/lagoon/attach/guard.go modules/lagoon/attach/guard_test.go admin/src/app/sessionKey.ts admin/src/app/dateFormat.ts admin/src/api/files.ts admin/tests/app/sessionKey.test.ts admin/tests/app/dateFormat.test.ts admin/tests/form/DatepickerField.test.ts admin/tests/form/FileuploadField.test.ts admin/tests/relation/RelationChildModal.test.ts admin/tests/relation/RelationPivotModal.test.ts admin/tests/fixtures/deferred.files.json admin/tests/fixtures/deferred.relation-schema.json ) HYGIENE_DOCS=(modules/cabana/README.md modules/lagoon/README.md modules/conga/README.md modules/pact/README.md docs admin/src) APP_NAMES='fonoteka|p[lł]ytarium' usage() { cat >&2 <<'EOF' usage: check-phase12.2.sh --self-test check-phase12.2.sh --go check-phase12.2.sh --security check-phase12.2.sh --spa check-phase12.2.sh --openapi check-phase12.2.sh --dist check-phase12.2.sh --docs check-phase12.2.sh --hygiene check-phase12.2.sh --app check-phase12.2.sh --all (default) EOF exit 2 } # detect reads go test -json. Exit 1 fail or build failure, 2 skip, 3 zero # tests, 4 non-JSON, 5 a required prefix has no passing top-level test. # REQUIRE_PREFIXES lists the prefixes. detect() { python3 - "$1" <<'PY' import json, os, sys path = sys.argv[1] prefixes = os.environ.get("REQUIRE_PREFIXES", "").split() 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) if action == "output" and "no tests to run" in (ev.get("Output") or ""): print(f"refuse: no tests to run in {pkg}", file=sys.stderr) sys.exit(3) 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 = [p for p in prefixes if not any(name.startswith(p) for name in top)] if missing: print("refuse: missing named test: no passing test for " + ", ".join(missing), file=sys.stderr) sys.exit(5) PY } # go_json DIR [go test args...] runs go test -json -count=1 through detect. go_json() { local dir="$1" shift local log err rc=0 dc=0 log="$(mktemp)" err="$(mktemp)" (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" || rc=$? detect "$log" || dc=$? if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then cat "$err" >&2 || true grep -v '^{' "$log" | tail -n 20 >&2 || true rm -f "$log" "$err" echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 return 1 fi rm -f "$log" "$err" } # named DIR PKG PREFIX... runs the tests matching the prefixes verbosely and # requires a passing top-level test for each one. named() { local dir="$1" pkg="$2" shift 2 local regex regex="^($(IFS='|'; echo "$*"))" REQUIRE_PREFIXES="$*" go_json "$dir" "$pkg" -v -run "$regex" } 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_self_test() { bash -n "${BASH_SOURCE[0]}" expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestRelationChildScope"}' expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"} {"Action":"fail","Package":"p","Test":"TestProtectedFileScope"}' 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":"TestRelationChildScope"}' 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",' REQUIRE_PREFIXES="TestRelationChildScope TestProtectedFile" expect_detect missing-named 5 \ '{"Action":"pass","Package":"p","Test":"TestRelationChildScope"}' REQUIRE_PREFIXES="TestProtectedFile" expect_detect subtest-only 5 \ '{"Action":"pass","Package":"p","Test":"TestOther/TestProtectedFile"}' REQUIRE_PREFIXES="TestRelationChildScope TestProtectedFile" expect_detect named 0 \ '{"Action":"pass","Package":"p","Test":"TestRelationChildScopeToolbar"} {"Action":"pass","Package":"p","Test":"TestProtectedFileHeaders"}' local flag for flag in --self-test --go --security --spa --openapi --dist --docs --hygiene --app --all; do grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || { echo "refuse: missing mode $flag" >&2 return 1 } done echo "phase12.2 self-test passed" } run_go() { (cd "$ROOT" && go vet ./...) go_json "$ROOT" ./... echo "phase12.2 go passed" } run_security() { named "$ROOT" ./modules/cabana "${SECURITY_CABANA[@]}" named "$ROOT" ./modules/lagoon "${SECURITY_LAGOON[@]}" named "$ROOT" ./modules/lagoon/attach "${SECURITY_ATTACH[@]}" echo "phase12.2 security passed" } run_spa() { npm --prefix "$ROOT/admin" run typecheck local log rc=0 log="$(mktemp)" npm --prefix "$ROOT/admin" test >"$log" 2>&1 || rc=$? if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then tail -n 60 "$log" >&2 rm -f "$log" echo "refuse: admin Vitest run failed (exit $rc)" >&2 return 1 fi grep -E 'Test Files|Tests ' "$log" || true rm -f "$log" echo "phase12.2 spa passed" } run_openapi() { "$ROOT/scripts/check-admin-openapi.sh" --check named "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase09ContractInventory TestPhase09PermissionMatrix echo "phase12.2 openapi passed" } run_dist() { "$ROOT/scripts/check-admin-dist.sh" echo "phase12.2 dist passed" } run_docs() { go_json "$ROOT" ./cmd/summer -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 } go_json "$ROOT" ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' echo "phase12.2 docs passed" } run_hygiene() { local bad=0 hits hits="$(cd "$ROOT" && grep -rniIE "$APP_NAMES" "${HYGIENE_DOCS[@]}" "${PHASE_FILES[@]}" 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: consuming-application names in the framework: $hits" >&2 bad=1 fi # The acme.deferred fixture lives only in _test.go files and testdata. hits="$(cd "$ROOT" && grep -rlnE 'acme\.deferred|dfPlugin' --include='*.go' . 2>/dev/null | grep -vE '_test\.go$' || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: the test fixture plugin is referenced by production code: $hits" >&2 bad=1 fi # Session keys travel only in headers, never in a URL. hits="$(cd "$ROOT" && grep -rnE '[?&](session_key|sessionKey|child_session_key)=' admin/src modules/cabana --include='*.ts' --include='*.vue' --include='*.go' 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: a session key in a URL: $hits" >&2 bad=1 fi hits="$(cd "$ROOT" && grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: raw-HTML sink in admin/src: $hits" >&2 bad=1 fi hits="$(cd "$ROOT" && gofmt -l modules/cabana modules/lagoon modules/conga modules/pact 2>/dev/null || true)" if [[ -n "$hits" ]]; then echo "refuse: hygiene: gofmt: $hits" >&2 bad=1 fi [[ "$bad" -eq 0 ]] || return 1 echo "phase12.2 hygiene passed" } run_app() { [[ -d "$APP" ]] || { echo "refuse: application repository $APP not found (set PHASE122_APP)" >&2 return 1 } go -C "$APP" build ./... go -C "$APP" vet ./... go_json "$APP" ./plugins/golem15/fonoteka -run Admin REQUIRE_PREFIXES="TestSchemaMatchesPHPSnapshot TestMigrateSeedsCanonicalGenres" \ go_json "$APP" ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' if [[ -n "$(git -C "$APP" status --porcelain)" ]]; then git -C "$APP" status --short >&2 echo "refuse: the application repository has uncommitted changes" >&2 return 1 fi echo "phase12.2 app passed" } run_all() { local stage for stage in self-test go security spa openapi dist docs hygiene app; do # Each stage runs in its own process, so errexit stays in force # inside it (bash ignores set -e in a function called from an if). if bash "${BASH_SOURCE[0]}" "--$stage"; then echo "PASS $stage" else echo "FAIL $stage" exit 1 fi done echo "phase12.2 all stages passed" } case "${1:---all}" in --self-test) run_self_test ;; --go) run_go ;; --security) run_security ;; --spa) run_spa ;; --openapi) run_openapi ;; --dist) run_dist ;; --docs) run_docs ;; --hygiene) run_hygiene ;; --app) run_app ;; --all) run_all ;; *) usage ;; esac