Files
summercms/scripts/check-phase12.2.sh
Jakub Zych d5494faa81 test(12.2-05): SPA unit tests for the phase and the check-phase12.2 gate
- sessionKey, dateFormat, FileuploadField (protected thumbnails and
  keyboard reorder backstops), RelationChildModal, RelationPivotModal,
  RelationManager row actions and create-screen deferral, date and time
  list cells; typed deferred relation-schema and file-list fixtures
- scripts/check-phase12.2.sh: go, security (named tests, refuses missing
  or skipped), spa, openapi, dist, docs, hygiene and app stages, a
  detector self-test, one PASS or FAIL line per stage under --all
2026-10-02 20:51:36 +02:00

334 lines
11 KiB
Bash
Executable File

#!/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