#!/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 Journal classes and controllers tests (including the # editor-corpus policy cross-check); 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 and the editor corpus # copy, which must equal the plugin's byte for byte (cmp). # 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; the Journal admin harness, including # TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, # TestCategoriesAdminContent and TestCategoriesAdminBlogEditor). # 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 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|ExampleCompileFormWithTypes)$' FRAMEWORK_REQUIRE=( TestFormFieldErrorMessage TestFormFieldTypeContract TestFieldTypeRegistry TestFieldTypeOptions TestFieldTypeCompile TestFieldTypeDecode TestFieldTypeNotices TestFieldTypeUntouched TestFieldTypeLoad TestFieldTypeListValue TestFieldTypeWrites TestFieldTypesRoute TestFieldUpload TestFieldTypeTracerSmoke TestFieldTypeExpansionSmoke TestPhase10CSRF TestPhase09ContractInventory TestPhase09PermissionMatrix 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 ) # Docker-host suites. PG_FRAMEWORK_REQUIRE=(TestFieldTypeSavePostgres TestListRelationSearchPostgres) PG_JOURNAL_REQUIRE=( TestPostsFormCompiles TestPostsAdminCreateSmoke TestPostsAdminResaveKeepsHTML TestPostsAdminSaveSanitizesHTML TestCategoriesAdminContent TestCategoriesAdminBlogEditor TestJournalSVGEmbed ) 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" ) usage() { cat >&2 <<'EOF' usage: check-phase16.2.sh --self-test check-phase16.2.sh go | admin | editor | layer | docs | postgres check-phase16.2.sh --go | --admin | --editor | --layer | --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 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_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 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, the Journal admin harness incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) 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 ;; --docs | docs) run_docs ;; --postgres | postgres) run_postgres ;; --all | all) run_all ;; *) usage ;; esac