New gate check:poster-fetch-guard (in gates:pre)
Two halves, Node only. The helper, against fake responses: a real WebP is accepted by type or by its bytes; a host's 200 fallback page is refused as a raster and as a source; HTML labelled octet-stream is refused; a 404 and a network error are misses for the raster; a network error on the source is thrown as itself. The callers: neither usePosterPrints nor PosterWall may fetch posters/ directly again, since each once carried its own copy and a guard in one did not reach the other. Poison-tested: restoring the old accept-anything fetch fails 3 checks, and PosterWall's direct fetches failed the caller check before its switch. gates:pre: PASS 67, FAIL 0.
This commit is contained in:
parent
33299253b0
commit
6c76d42881
2 changed files with 57 additions and 1 deletions
54
internal/check-poster-fetch-guard.mjs
Normal file
54
internal/check-poster-fetch-guard.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
// check-poster-fetch-guard — a host's fallback page is never taken for a poster.
|
||||
//
|
||||
// ★ THE DEFECT (2026-09-29). On a drop-in host's dev server (Nuxt) a missing posters/<id>.webp
|
||||
// answered 200 text/html (the app's own page), and export accepted it as the poster: a
|
||||
// broken image in the print dialog. The .html source path had the same hole, where the
|
||||
// rasteriser lifts whatever <svg> the page holds. library/packet/poster-fetch.js now takes a
|
||||
// raster only if it is an image, and a source only if it is a poster document.
|
||||
//
|
||||
// Two halves:
|
||||
// 1. the helper, against fake responses: every accept and every refusal;
|
||||
// 2. the callers: usePosterPrints and PosterWall each carried their own copy of this fetch,
|
||||
// and a guard in one did not reach the other. Neither may fetch posters/ directly again.
|
||||
// Node only: starts nothing. Exit 0 pass, 1 fail.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { fetchPosterRaster, fetchPosterSource } from '../library/packet/poster-fetch.js'
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const fails = []
|
||||
const check = (ok, what) => { if (!ok) fails.push(what) }
|
||||
|
||||
const WEBP = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50])
|
||||
const APP_PAGE = '<!doctype html><html><body><svg><path d="M0 0"/></svg>app</body></html>'
|
||||
const SOURCE = '<!doctype html><html><body><div class="stage"><svg></svg></div></body></html>'
|
||||
const answer = (body, type, status = 200) => async () => new Response(body, { status, headers: type ? { 'content-type': type } : {} })
|
||||
|
||||
check(await fetchPosterRaster('x.webp', answer(WEBP, 'image/webp')) instanceof Blob, 'a real WebP is accepted')
|
||||
check(await fetchPosterRaster('x.webp', answer(APP_PAGE, 'text/html; charset=utf-8')) === null, "a host's 200 fallback page is refused as a raster")
|
||||
check(await fetchPosterRaster('x.webp', answer(WEBP, 'application/octet-stream')) instanceof Blob, 'a WebP labelled octet-stream is accepted by its bytes')
|
||||
check(await fetchPosterRaster('x.webp', answer(APP_PAGE, 'application/octet-stream')) === null, 'HTML labelled octet-stream is refused')
|
||||
check(await fetchPosterRaster('x.webp', answer('', 'text/plain', 404)) === null, 'a 404 is a miss')
|
||||
check(await fetchPosterRaster('x.webp', async () => { throw new Error('offline') }) === null, 'a network error is a miss')
|
||||
check((await fetchPosterSource('x.html', answer(SOURCE, 'text/html'))).html === SOURCE, 'a poster source is accepted')
|
||||
const fallback = await fetchPosterSource('x.html', answer(APP_PAGE, 'text/html'))
|
||||
check(fallback.html === null && fallback.status === 404, "a host's 200 fallback page is refused as a source, as 404")
|
||||
check((await fetchPosterSource('x.html', answer('', 'text/plain', 404))).status === 404, 'a missing source reports 404')
|
||||
check(await fetchPosterSource('x.html', async () => { throw new Error('offline') }).then(() => false, (e) => e.message === 'offline'),
|
||||
'a network error on the source is thrown as itself, not reported as "not found"')
|
||||
|
||||
// No caller fetches posters/ directly: every poster fetch goes through the helper.
|
||||
for (const rel of ['library/composables/usePosterPrints.js', 'library/components/poster/PosterWall.vue']) {
|
||||
const src = readFileSync(join(REPO, rel), 'utf8')
|
||||
const direct = src.split('\n').filter((l) => /fetch\([^)]*posters\/[^)]*\.(webp|html)/.test(l) && !l.trim().startsWith('//'))
|
||||
check(direct.length === 0, `${rel} fetches a poster directly instead of through poster-fetch.js: ${direct.map((l) => l.trim()).join(' | ')}`)
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`POSTER-FETCH-GUARD: FAIL — ${fails.length} check(s)`)
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('POSTER-FETCH-GUARD: PASS — rasters must be images, sources must be poster documents, and every caller goes through poster-fetch.js')
|
||||
|
|
@ -231,6 +231,7 @@
|
|||
"check:poster-attribution": "node internal/check-poster-attribution.mjs",
|
||||
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
|
||||
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
|
||||
"check:poster-fetch-guard": "node internal/check-poster-fetch-guard.mjs",
|
||||
"check:poster-layout-optin": "node internal/check-poster-layout-optin.mjs",
|
||||
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
|
||||
"check:posters-exportable": "node internal/check-posters-exportable.mjs",
|
||||
|
|
@ -318,7 +319,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -510,6 +511,7 @@
|
|||
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
|
||||
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
|
||||
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
|
||||
"check:poster-fetch-guard": "a host's 200 fallback page is never taken for a poster. On a drop-in dev host a missing posters/<id>.webp answered 200 text/html, and export handed that page to the print dialog as the image. Asserts library/packet/poster-fetch.js accepts a raster only if it is an image (by type or by its first bytes) and a source only if it is a poster document (class=\"stage\"), and that neither usePosterPrints nor PosterWall fetches posters/ directly. Node only, no server.",
|
||||
"check:poster-layout-optin": "the composed poster sheet (header, map, legend beside it, footer) is OPT-IN: spec.layout === 'poster' asks for it, and every caller that does not gets the full-page map exactly as before, asserted by object identity, not just by value. Drives the real layout decisions with real spec objects. Headless, no browser.",
|
||||
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",
|
||||
"check:prayer-walk-planner": "the Prayer Walk Planner is a full-page tool that prints a real walking map: the map canvas fills the page below the toolbar in its own leaf AND inside the Research Gallery at 1280x800 and 390x844 (the gallery once collapsed it to 76px / 0px); a real zone (Balat, Istanbul) renders a >=2000px print map that is not blank, saved to OUT=<dir> so a human can read it; ?lang=es gives a Spanish toolbar; 0 page errors. Run inside the shared browser locks. Exit 2 = SKIP.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue