diff --git a/scripts/check-phase10.sh b/scripts/check-phase10.sh index 3ce8732..6259690 100755 --- a/scripts/check-phase10.sh +++ b/scripts/check-phase10.sh @@ -4,6 +4,13 @@ # named test that did not run, OpenAPI or dist drift, a hygiene violation or # an evidence gap. --self-test proves each detector fails closed. # +# Hygiene exceptions are line-exact and bound to their file: the MarkdownField +# preview (server-rendered), and since Phase 17 (T-17-10) the RichEditorField +# read-only view (DOMPurify displayHtml, T-10-16) and the inert template parse +# in richeditor/lossy.ts. The dist-origin rule allows only the literals that +# are never requested (namespace URIs, docs links in error text, the +# reserved-TLD parse base of isAllowedLink). +# # Allow-list: none. The fonoteka.go parity failures TestMigrateSeedsCanonicalGenres # and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go # 21c0f12 (fix(09): update parity expectations for the backend admin schema), @@ -224,9 +231,10 @@ run_self_test() { done # The hygiene stage passes on a scratch copy of the tree and fails once a - # raw-HTML directive, a direct fetch, an XMLHttpRequest outside the files.ts - # upload, an app name, a browser-storage call or an extra admin/src/api file - # is planted in it. + # raw-HTML directive (also the RichEditorField display line or the lossy.ts + # template parse moved to another file), a direct fetch, an XMLHttpRequest + # outside the files.ts upload, an app name, a browser-storage call, an extra + # admin/src/api file or another origin in the dist JS is planted in it. local scratch scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' RETURN @@ -242,8 +250,9 @@ run_self_test() { # Each plant is imported by a scratch test, so only its own rule can fire; # the refusal must name that rule. local plant want out - for plant in vhtml fetch appname storage xhr apifile; do - rm -rf "$scratch/admin/src/__plant" "$scratch/admin/tests/__plant.test.ts" "$scratch/admin/src/api/__plant.ts" + for plant in vhtml vhtml-elsewhere template-parse fetch appname storage xhr apifile origin; do + rm -rf "$scratch/admin/src/__plant" "$scratch/admin/tests/__plant.test.ts" "$scratch/admin/src/api/__plant.ts" \ + "$scratch/modules/boardwalk/dist/assets/__plant.js" mkdir -p "$scratch/admin/src/__plant" case "$plant" in vhtml) @@ -251,6 +260,20 @@ run_self_test() { printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts" want="raw-HTML directive" ;; + vhtml-elsewhere) + # The RichEditorField exception is bound to its path. + printf '\n' "
" >"$scratch/admin/src/__plant/Plant.vue" + printf "import Plant from '../src/__plant/Plant.vue'\n" >"$scratch/admin/tests/__plant.test.ts" + want="raw-HTML directive" + ;; + template-parse) + printf "export function parse(html: string) {\n const template = document.createElement('template')\n template.innerHTML = html\n return template.content\n}\n" >"$scratch/admin/src/__plant/plant.ts" + want="raw-HTML directive" + ;; + origin) + printf 'const u="https://evil.example/x";export{u};\n' >"$scratch/modules/boardwalk/dist/assets/__plant.js" + want="JS names another origin" + ;; fetch) printf 'export const load = () => fetch("/x")\n' >"$scratch/admin/src/__plant/plant.ts" want="direct fetch" @@ -330,6 +353,19 @@ run_spa() { exit 1 fi grep -E 'Test Files|Tests ' "$log" || true + # The richeditor display needs a real engine (T-10-16): Vitest browser mode + # in Playwright Chromium, refused the same fail-closed way. + set +e + npm --prefix "$ROOT/admin" run test:browser >"$log" 2>&1 + rc=$? + set -e + if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled|FAIL | skipped| todo' "$log"; then + tail -n 60 "$log" >&2 + rm -f "$log" + echo "refuse: admin Vitest browser run failed (exit $rc)" >&2 + exit 1 + fi + grep -E 'Test Files|Tests ' "$log" || true rm -f "$log" echo "phase10 spa passed" } @@ -364,9 +400,16 @@ hygiene_checks() { admin/src admin/tests admin/openapi modules/boardwalk modules/cabana modules/phrasebook 2>/dev/null || true)" [[ -z "$hits" ]] || fail "application names in the framework: $hits" # Plugin and server strings are rendered as text only. - # The markdown preview binds only the HTML POST /markdown/preview answers (cabana.RenderMarkdown); every other raw-HTML sink is still refused. + # The markdown preview binds only the HTML POST /markdown/preview answers (cabana.RenderMarkdown). + # T-17-10: the RichEditorField read-only view binds only displayHtml output (DOMPurify, T-10-16), never the stored value. + # T-17-10: lossy.ts parses into a template, whose contents are inert and are read for tag names only, never bound. + # Each exception is the exact line in its own file; every other raw-HTML sink is still refused. + local richeditor_display + richeditor_display="^admin/src/components/form/fields/RichEditorField\\.vue:[0-9]+:[[:space:]]*
[[:space:]]*\$" hits="$(cd "$tree" && grep -rnE 'v-html|innerHTML|outerHTML|insertAdjacentHTML' admin/src 2>/dev/null | - grep -vE '^admin/src/components/form/fields/MarkdownField\.vue:[0-9]+:[[:space:]]*v-html="sanitizedHtml"[[:space:]]*$' || true)" + grep -vE -e '^admin/src/components/form/fields/MarkdownField\.vue:[0-9]+:[[:space:]]*v-html="sanitizedHtml"[[:space:]]*$' \ + -e "$richeditor_display" \ + -e '^admin/src/components/form/richeditor/lossy\.ts:[0-9]+:[[:space:]]*template\.innerHTML = html[[:space:]]*$' || true)" [[ -z "$hits" ]] || fail "raw-HTML directive in admin/src: $hits" # All HTTP goes through the typed openapi-fetch client. # Uploads in admin/src/api/files.ts use XMLHttpRequest because only XHR reports upload progress (12.2-04); that XHR sends X-Requested-With (T-12.2-32) and refreshes through client.ts; every other XHR, fetch or axios call is still refused. @@ -387,11 +430,13 @@ hygiene_checks() { hits="$(cd "$tree" && grep -rnE 'localStorage|sessionStorage|indexedDB|document\.cookie' admin/src 2>/dev/null | grep -v -e '^admin/src/state/useSidebar.ts:' -e '^admin/src/state/listState.ts:' || true)" [[ -z "$hits" ]] || fail "browser storage outside admin/src/state/useSidebar.ts and admin/src/state/listState.ts: $hits" - # The embedded build loads nothing from another origin. + # The embedded build loads nothing from another origin. Allowed literals are + # never requested: https://prosemirror.net is ProseMirror error text and + # https://admin.invalid the reserved-TLD parse base in isAllowedLink. hits="$(cd "$tree" && grep -noE '(src|href)="(https?:)?//[^"]*"|url\((["'"'"']?)(https?:)?//' modules/boardwalk/dist/index.html modules/boardwalk/dist/assets/*.css 2>/dev/null || true)" [[ -z "$hits" ]] || fail "modules/boardwalk/dist references another origin: $hits" hits="$(cd "$tree" && grep -ohE 'https?://[A-Za-z0-9.-]+' modules/boardwalk/dist/assets/*.js 2>/dev/null | sort -u | - grep -vxE 'http://www\.w3\.org|https://vuejs\.org|http://local' || true)" + grep -vxE 'http://www\.w3\.org|https://vuejs\.org|http://local|https://prosemirror\.net|https://admin\.invalid' || true)" [[ -z "$hits" ]] || fail "modules/boardwalk/dist JS names another origin: $hits" # Icons: named imports only, and never the deprecated lucide package. hits="$(cd "$tree" && grep -rnE "import \* as [A-Za-z_]+ from '@lucide/vue'|lucide-vue-next" admin/src admin/package.json 2>/dev/null || true)" diff --git a/scripts/check-phase17.sh b/scripts/check-phase17.sh index 03b6cae..d333c52 100755 --- a/scripts/check-phase17.sh +++ b/scripts/check-phase17.sh @@ -11,12 +11,15 @@ # name (-short). # plugins go vet ./... in Journal, sitemap, menu, FAQ and the host; their # Docker-free suites by required name (-short). -# admin the admin SPA typecheck and the vitest JSON report with required -# spec files and test names. +# admin the admin SPA typecheck and the vitest JSON reports with +# required spec files and test names: the happy-dom suite and the +# browser suite (vitest.browser.config.ts, Playwright Chromium; +# the richeditor read-only display, T-17-10). # layers pnpm test (node --test, TAP) in both layers with required test # files and names. # docs TestDocsTree, summer docs:build --check, check-admin-openapi.sh -# --check, check-admin-dist.sh. +# --check, check-admin-dist.sh, and check-phase10.sh --hygiene, so +# admin changes made here cannot silently break Phase 10's gate. # postgres Docker host only: the testcontainers suites by required name in # every Go repository. Without a reachable Docker daemon this # stage fails. @@ -116,7 +119,6 @@ ADMIN_REQUIRE=( "RichEditorField renders read-only values through the display policy, never as stored" "richeditor policy helpers accepts policy links only" "richeditor policy helpers compares tag names case-insensitively for the lossy note" - "richeditor policy helpers unwraps unknown elements and drops scripts for display" "RichEditorField value contract an empty paragraph is the empty string, any content its HTML" "RichEditorField value contract a value from outside replaces the document and leaves the field untouched" "RichEditorField value contract shows the lossy note only for markup outside the editor schema" @@ -142,6 +144,18 @@ ADMIN_REQUIRE=( "reorder tree rules refuses a drop into the node itself or its own subtree and leaves the tree as it was" "reorder tree rules moves only where a sibling or parent exists, and flattens pre-order from 1" ) +# admin stage, browser suite (vitest.browser.config.ts): spec files and full +# test names, copied from a --reporter=json run. +ADMIN_BROWSER_FILES=(tests/browser/richeditor-display.test.ts) +ADMIN_BROWSER_REQUIRE=( + "richeditor display in a real browser engine runs where form controls clobber form properties" + "richeditor display in a real browser engine drops clobbered forms with everything inside them" + "richeditor display in a real browser engine keeps a form control named localName from breaking the view" + "richeditor display in a real browser engine drops an off-origin form, iframes, meta refresh and disallowed links" + "richeditor display in a real browser engine parses into an inert document, so nothing loads before the value is bound" + "richeditor policy helpers unwraps unknown elements and drops scripts for display" + "RichEditorField renders read-only values exactly through the display policy" +) # layers stage: test files (must exist and match the test script's glob) and # TAP test names. @@ -389,11 +403,13 @@ go_named() { REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" $pkgs } +# vitest_json DIR [VITEST ARGS...]: extra arguments (such as --config) go to vitest. vitest_json() { local dir="$1" report out rc=0 dc=0 + shift report="$(mktemp)" out="$(mktemp)" - (cd "$dir" && ./node_modules/.bin/vitest run --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$? + (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 @@ -529,6 +545,11 @@ ok 1 - a REQUIRE_FILES='tests/a.ts' expect_files outside-glob 5 "$tmp" || { rm -rf "$tmp"; return 1; } rm -rf "$tmp" + grep -qF 'bash "$ROOT/scripts/check-phase10.sh" --hygiene' "${BASH_SOURCE[0]}" || { + echo "refuse: the docs stage does not run check-phase10.sh --hygiene" >&2 + return 1 + } + local mode for mode in self-test go plugins admin layers docs postgres all; do grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || { @@ -583,6 +604,8 @@ run_plugins() { 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" + REQUIRE_FILES="$(printf '%s\n' "${ADMIN_BROWSER_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_BROWSER_REQUIRE[@]}")" \ + vitest_json "$ROOT/admin" --config vitest.browser.config.ts echo "phase17 admin passed" } @@ -599,6 +622,9 @@ run_docs() { (cd "$ROOT" && go run ./cmd/summer docs:build --check) bash "$ROOT/scripts/check-admin-openapi.sh" --check bash "$ROOT/scripts/check-admin-dist.sh" + # Cross-phase: Phase 10's SPA hygiene (raw-HTML sinks, dist origins, every + # module imported by a test) must stay green after Phase 17 admin changes. + bash "$ROOT/scripts/check-phase10.sh" --hygiene echo "phase17 docs passed" }