#!/usr/bin/env bash # Phase 16.2 fail-closed gate: plugin-provided admin field types (summercms.go # pact and cabana, the admin SPA) and the Journal Blog Editor # (sm-journal-plugin Go and editor element, vue-journal-layer embeds). # # Stages: # go go vet in both Go repositories; the framework's field type tests # and the Docker-free admin route conformance inventory # (TestPhase10ConformanceInventory: every inventoried admin route # has a conformance case); the Journal classes and controllers # tests (including the editor-corpus policy cross-check and the # site cache purger, TestSiteCachePurgeConfig and # TestSiteCachePurgeRequest); the 16.1 policy test files must be # unchanged since c60abc6. # admin the admin SPA typecheck and full vitest suite. # editor the Blog Editor typecheck and full vitest suite (smoke, corpus, # preview, element, schema, serializer, embed). # layer the vue-journal-layer node --test suite (including the site # cache purge tests) and the editor corpus copy, which must equal # the plugin's byte for byte (cmp). # cache builds the layer playground (routeRules swr) and runs # testing/purge-check.mjs on it (post hello-acme at # /blog/hello-acme: a cached page shows a change on the first # request after an authorized purge, guards refuse the rest), then # the acme SSR smoke against the same build. # docs TestDocsTree, the admin OpenAPI and dist drift gates and the # plugin's assets/dist drift gate. # postgres Docker host only: the testcontainers suites (cabana, including # TestFieldTypeSavePostgres and TestPhase10OpenAPIConformance; the # Journal admin harness, including TestPostsAdminResaveKeepsHTML, # TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, # TestCategoriesAdminBlogEditor, TestJournalSVGEmbed and the site # cache purge suites TestCategoriesAdminPurgesSiteCache and # TestPostsAdminPurgesSiteCache). Without a reachable Docker daemon # this stage fails. # # Every stage exits non-zero on a failing command; a go test -json run that # fails, skips, matches zero tests, prints "no tests to run" or misses a named # required test; a vitest JSON report with a failed, skipped or todo test, a # failed file, no passing test or a missing required file or test; or a TAP # run with "not ok", a non-zero "# fail", a skip, no pass or a missing named # test. --self-test proves the detectors and the corpus comparison fail on # planted output. # # --all runs self-test, go, admin, editor, layer, cache and docs, then postgres when # a Docker daemon answers. When none does, --all says so loudly (NOT RUN # postgres) and the Docker suites stay a manual step for a Docker host: # bash scripts/check-phase16.2.sh postgres # Set PHASE162_REQUIRE_POSTGRES=1 to make --all fail instead. # # Environment overrides: PHASE162_ROOT (this repository), JOURNAL (default # ../sm-journal-plugin), LAYER (default ../vue-journal-layer). set -euo pipefail unset FORCE_COLOR export NO_COLOR=1 ROOT="${PHASE162_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" JOURNAL="${JOURNAL:-$ROOT/../sm-journal-plugin}" LAYER="${LAYER:-$ROOT/../vue-journal-layer}" POLICY_BASE="c60abc6" # Framework tests (modules/pact, modules/cabana) that must pass by name. FRAMEWORK_RUN='^(TestFormFieldErrorMessage|TestFormFieldTypeContract|TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase10CSRF|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestPhase10ConformanceInventory|ExampleCompileFormWithTypes)$' FRAMEWORK_REQUIRE=( TestFormFieldErrorMessage TestFormFieldTypeContract TestFieldTypeRegistry TestFieldTypeOptions TestFieldTypeCompile TestFieldTypeDecode TestFieldTypeNotices TestFieldTypeUntouched TestFieldTypeLoad TestFieldTypeListValue TestFieldTypeWrites TestFieldTypesRoute TestFieldUpload TestFieldTypeTracerSmoke TestFieldTypeExpansionSmoke TestPhase10CSRF TestPhase09ContractInventory TestPhase09PermissionMatrix TestPhase10ConformanceInventory ExampleCompileFormWithTypes ) # Journal classes and controllers tests that must pass by name (the packages # run whole; these are the phase's and the 16.1 policy's). JOURNAL_REQUIRE=( TestBlogEditorDecode TestBlogEditorNotices TestBlogEditorLoad TestLooksLikeHTML TestBlogEditorOptions TestBlogEditorListValue TestBlogEditorUpload TestStoreMedia TestEditorOutputPassesPolicy TestSanitizeHTMLReport TestFormatHTMLSanitizesUnsafeHTML TestFormatHTMLDuplicateAttributes TestCheckHTML TestSanitizeHTML TestMediaObjectPath TestMediaFolderPatternRejectsDotDot TestPostsEditorHooks TestBlogEditorTracerSmoke TestBlogEditorUploadSmoke TestWriteMediaStoreError TestSiteCachePurgeConfig TestSiteCachePurgeRequest ) # Docker-host suites. PG_FRAMEWORK_REQUIRE=(TestFieldTypeSavePostgres TestListRelationSearchPostgres TestPhase10OpenAPIConformance) PG_JOURNAL_REQUIRE=( TestPostsFormCompiles TestPostsAdminCreateSmoke TestPostsAdminResaveKeepsHTML TestPostsAdminSaveSanitizesHTML TestCategoriesAdminContent TestCategoriesAdminBlogEditor TestJournalSVGEmbed TestCategoriesAdminPurgesSiteCache TestPostsAdminPurgesSiteCache ) ADMIN_FILES=( tests/form/PluginField.test.ts tests/form/FormField.test.ts tests/form/registry.test.ts tests/form/formState.test.ts tests/app/fieldTypes.test.ts tests/app/pluginAssets.test.ts tests/list/ListView.test.ts tests/list/CellValue.test.ts tests/shell/AppShell.test.ts ) ADMIN_REQUIRE=( "untouched value (D-03) keeps the served HTML identical when the element echoes it on connect" "PluginField does not write back the value the element just emitted" "loadFieldTypes loads each script URL once, even across two runs and two types" "loadFieldTypes keeps field type styles enabled on every screen" "View site link (Phase 16.2 quick follow-up) opens the site root in a new tab without an opener or referrer" "plugin field type columns (Phase 16.2) renders the row value of a column type it does not know as plain text" ) EDITOR_FILES=( tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/preview.test.ts tests/blogeditor/element.test.ts tests/blogeditor/schema.test.ts tests/blogeditor/serializer.test.ts tests/blogeditor/embed.test.ts ) EDITOR_REQUIRE=( "untouched value (D-03) returns the imported HTML post unchanged after a Source tab round trip" "block reuse keeps every other block, separator and comment byte for byte after one edit" "editor corpus (D-05) matches the committed editor-corpus.json" "isPolicyUrl refuses \"java\\tscript:alert(1)\"" ) LAYER_REQUIRE=( "the sink turns a stored placeholder into the iframe" "a raw iframe in stored HTML is stripped; only the placeholder becomes an iframe" "the sinks expand after sanitizing and the payload transforms never expand" "the Go policy negatives are stripped the same way here" "the editor corpus has the entries the mirror rules are for" "purge auth: a missing or short token disables the route" "purge auth: a proxied request is refused before the token is checked" "purge auth: a missing or wrong bearer token is unauthorized" "purge removes every route cache entry and only those" ) usage() { cat >&2 <<'EOF' usage: check-phase16.2.sh --self-test check-phase16.2.sh go | admin | editor | layer | cache | docs | postgres check-phase16.2.sh --go | --admin | --editor | --layer | --cache | --docs | --postgres check-phase16.2.sh --all EOF exit 2 } # detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests, # 4 non-JSON, 5 missing required name. detect_go() { 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" and "no tests to run" in text: 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) missing = [n for n in require if n not in passed] if missing: print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) sys.exit(5) PY } # detect_tap reads node --test --test-reporter=tap output. Exit 1 "not ok" or # a fail/cancelled count, 2 skip/todo, 3 no pass, 5 missing required name. # REQUIRE_NAMES holds one test name per line. detect_tap() { python3 - "$1" <<'PY' import os, re, sys path = sys.argv[1] require = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()] passed = set() counts = {} with open(path, encoding="utf-8", errors="replace") as fh: for raw in fh: line = raw.rstrip("\n") body = line.lstrip() if body.startswith("not ok"): print(f"refuse: {body}", file=sys.stderr) sys.exit(1) m = re.match(r"ok \d+ - (.*?)(\s+#\s*(SKIP|TODO)\b.*)?$", body, re.I) if m: if m.group(2): print(f"refuse: {body}", file=sys.stderr) sys.exit(2) passed.add(m.group(1)) continue m = re.match(r"# (tests|suites|pass|fail|cancelled|skipped|todo) (\d+)$", line) if m: counts[m.group(1)] = int(m.group(2)) for key in ("fail", "cancelled"): if counts.get(key, 0) > 0: print(f"refuse: {counts[key]} {key}", file=sys.stderr) sys.exit(1) for key in ("skipped", "todo"): if counts.get(key, 0) > 0: print(f"refuse: {counts[key]} {key}", file=sys.stderr) sys.exit(2) if counts.get("pass", 0) < 1 or not passed: print("refuse: no passing tests", file=sys.stderr) sys.exit(3) missing = [n for n in require if n not in passed] if missing: print("refuse: required tests did not pass: " + "; ".join(missing), file=sys.stderr) sys.exit(5) PY } # detect_vitest reads a vitest --reporter=json report. Exit 1 a failed test # or file or an unsuccessful run, 2 a skipped or todo test, 3 no passing # test, 4 an unreadable report, 5 a missing required file or test. Required # files (REQUIRE_FILES, one path suffix per line) must each have a passing # test; required names (REQUIRE_NAMES, one full test name per line) must # pass. detect_vitest() { python3 - "$1" <<'PY' import json, os, sys path = sys.argv[1] files = [n for n in os.environ.get("REQUIRE_FILES", "").split("\n") if n.strip()] names = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()] try: with open(path, encoding="utf-8") as fh: report = json.load(fh) except (OSError, ValueError) as err: print(f"refuse: unreadable vitest report: {err}", file=sys.stderr) sys.exit(4) results = report.get("testResults") or [] bad = [r.get("name", "?") for r in results if r.get("status") != "passed"] if bad or report.get("numFailedTests", 0) or report.get("numFailedTestSuites", 0) or report.get("success") is not True: print("refuse: failed " + ", ".join(bad or ["run"]), file=sys.stderr) sys.exit(1) if report.get("numPendingTests", 0) or report.get("numTodoTests", 0): print(f"refuse: {report.get('numPendingTests', 0)} skipped, {report.get('numTodoTests', 0)} todo", file=sys.stderr) sys.exit(2) passed = set() by_file = {} for r in results: for a in r.get("assertionResults") or []: status = a.get("status") if status != "passed": print(f"refuse: {status} {a.get('fullName')}", file=sys.stderr) sys.exit(2 if status in ("skipped", "pending", "todo") else 1) passed.add(a.get("fullName", "")) by_file[r.get("name", "")] = by_file.get(r.get("name", ""), 0) + 1 if report.get("numPassedTests", 0) < 1 or not passed: print("refuse: no passing tests", file=sys.stderr) sys.exit(3) missing = [f for f in files if not any(name.endswith("/" + f) and count > 0 for name, count in by_file.items())] missing += [n for n in names if n not in passed] if missing: print("refuse: required files or tests did not pass: " + "; ".join(missing), file=sys.stderr) sys.exit(5) print(f"vitest: {report.get('numPassedTests')} passed in {len(results)} files") PY } 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_go "$log" || dc=$? if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then cat "$err" >&2 || true grep -v '^{' "$log" | tail -n 40 >&2 || true python3 -c 'import json,sys for raw in open(sys.argv[1], errors="replace"): try: ev = json.loads(raw) except ValueError: continue if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")): sys.stderr.write(ev["Output"])' "$log" 2>&1 | tail -n 40 >&2 || true rm -f "$log" "$err" echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 return 1 fi echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)" rm -f "$log" "$err" } # vitest_json runs a repository's vitest with the JSON reporter and checks it. vitest_json() { local dir="$1" report out rc=0 dc=0 report="$(mktemp)" out="$(mktemp)" (cd "$dir" && ./node_modules/.bin/vitest run --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$? detect_vitest "$report" || dc=$? if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then grep -E 'FAIL|Error|×' "$out" | tail -n 40 >&2 || true rm -f "$report" "$out" echo "refuse: vitest in $dir (test=$rc detect=$dc)" >&2 return 1 fi rm -f "$report" "$out" } # node_tap runs `npm --prefix
a
"}]\n' >"$a" cp "$a" "$b" same_corpus "$a" "$b" || { echo "refuse: self-test corpus: identical copies refused" >&2 rm -f "$a" "$b" return 1 } printf '[{"name":"x","html":"b
"}]\n' >"$b" if same_corpus "$a" "$b" 2>/dev/null; then echo "refuse: self-test corpus: a planted difference passed" >&2 rm -f "$a" "$b" return 1 fi : >"$b" if same_corpus "$a" "$b" 2>/dev/null; then echo "refuse: self-test corpus: an empty copy passed" >&2 rm -f "$a" "$b" return 1 fi rm -f "$a" "$b" local mode for mode in self-test go admin editor layer cache docs postgres all; do grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { echo "refuse: missing mode --$mode" >&2 return 1 } done echo "phase16.2 self-test passed" } require_dir() { [[ -d "$1" ]] || { echo "refuse: $2 $1 not found" >&2 return 1 } } run_go() { require_dir "$JOURNAL" "Journal plugin" (cd "$ROOT" && go vet ./...) REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" -count=1 -run "$FRAMEWORK_RUN" ./modules/pact ./modules/cabana go -C "$JOURNAL" vet ./... REQUIRE_TESTS="${JOURNAL_REQUIRE[*]}" go_json "$JOURNAL" -count=1 ./classes/ ./controllers/... if ! git -C "$JOURNAL" diff --quiet "$POLICY_BASE" -- classes/format_html_test.go classes/check_html_test.go classes/format_translation_test.go; then echo "refuse: the 16.1 policy tests changed since $POLICY_BASE" >&2 return 1 fi echo "phase16.2 go passed" } run_admin() { (cd "$ROOT/admin" && ./node_modules/.bin/vue-tsc --noEmit) REQUIRE_FILES="$(printf '%s\n' "${ADMIN_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_REQUIRE[@]}")" vitest_json "$ROOT/admin" echo "phase16.2 admin passed" } run_editor() { require_dir "$JOURNAL" "Journal plugin" (cd "$JOURNAL" && ./node_modules/.bin/tsc --noEmit) REQUIRE_FILES="$(printf '%s\n' "${EDITOR_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${EDITOR_REQUIRE[@]}")" vitest_json "$JOURNAL" echo "phase16.2 editor passed" } run_layer() { require_dir "$LAYER" "layer repository" REQUIRE_NAMES="$(printf '%s\n' "${LAYER_REQUIRE[@]}")" node_tap "$LAYER" same_corpus "$JOURNAL/tests/blogeditor/fixtures/editor-corpus.json" "$LAYER/tests/fixtures/editor-corpus.json" echo "corpus: the layer copy equals the plugin's" echo "phase16.2 layer passed" } # run_cache proves the site cache purge end to end on the layer playground, # whose routeRules cache every page, then runs the acme SSR smoke against the # same build. run_cache() { require_dir "$LAYER" "layer repository" local log rc=0 pnpm -C "$LAYER" run build:playground >/dev/null log="$(mktemp)" node "$LAYER/testing/purge-check.mjs" --output "$LAYER/.playground/.output" --fixtures "$LAYER/testing/fixtures/acme" \ --kind post --slug hello-acme --path /blog/hello-acme >"$log" 2>&1 || rc=$? if [[ "$rc" -ne 0 ]] || ! tail -n 1 "$log" | grep -q '^purge-check: ok'; then cat "$log" >&2 rm -f "$log" echo "refuse: purge-check on the layer playground (exit $rc)" >&2 return 1 fi tail -n 1 "$log" rm -f "$log" node "$LAYER/testing/smoke-ssr.mjs" --output "$LAYER/.playground/.output" --fixtures "$LAYER/testing/fixtures/acme" --expect "$LAYER/testing/expect-acme.txt" echo "phase16.2 cache passed" } run_docs() { require_dir "$JOURNAL" "Journal plugin" REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" -count=1 -run '^TestDocsTree$' ./cmd/summer bash "$ROOT/scripts/check-admin-openapi.sh" --check bash "$ROOT/scripts/check-admin-dist.sh" bash "$JOURNAL/scripts/check-dist.sh" echo "phase16.2 docs passed" } docker_up() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 } run_postgres() { require_dir "$JOURNAL" "Journal plugin" if ! docker_up; then echo "refuse: the postgres stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2 return 1 fi REQUIRE_TESTS="${PG_FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" -count=1 -timeout 15m ./modules/cabana REQUIRE_TESTS="${PG_JOURNAL_REQUIRE[*]}" go_json "$JOURNAL" -count=1 -timeout 20m ./... echo "phase16.2 postgres passed" } run_all() { local stage for stage in self-test go admin editor layer cache docs; do if bash "${BASH_SOURCE[0]}" "--$stage"; then echo "PASS $stage" else echo "FAIL $stage" exit 1 fi done if docker_up; then if bash "${BASH_SOURCE[0]}" --postgres; then echo "PASS postgres" else echo "FAIL postgres" exit 1 fi elif [[ "${PHASE162_REQUIRE_POSTGRES:-0}" == "1" ]]; then echo "FAIL postgres: no reachable Docker daemon and PHASE162_REQUIRE_POSTGRES=1" exit 1 else cat >&2 <<'EOF' ************************************************************************ NOT RUN postgres: no reachable Docker daemon on this host. The testcontainers suites (TestFieldTypeSavePostgres, TestPhase10OpenAPIConformance, the Journal admin harness incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed and the site cache purge suites) did NOT run. Run on a Docker host before /msd-verify-work: bash scripts/check-phase16.2.sh postgres ************************************************************************ EOF echo "NOT RUN postgres (Docker-host only; see the notice above)" fi echo "Phase 16.2 gate passed" } case "${1:---all}" in --self-test | self-test) run_self_test ;; --go | go) run_go ;; --admin | admin) run_admin ;; --editor | editor) run_editor ;; --layer | layer) run_layer ;; --cache | cache) run_cache ;; --docs | docs) run_docs ;; --postgres | postgres) run_postgres ;; --all | all) run_all ;; *) usage ;; esac