diff options
| author | Yuriy Andamasov <yuriy@vyos.io> | 2026-09-25 17:11:19 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-25 16:11:19 +0100 |
| commit | a783e56b774795c1f4bd8a6088dfcce96a307c5f (patch) | |
| tree | dcd0c88ff02969351840c8222ea97c72d9258e18 /workers/picker-test | |
| parent | 4c8d1d1a0f23e2106a97f4b672607121ff03a091 (diff) | |
| download | vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.tar.gz vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.zip | |
ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208)
* ci: port Cloudflare Workers docs pipeline files to circinus (verbatim)
Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers
pipeline from `rolling` at 8cb568bf, byte-identical:
- .github/workflows/docs-build.yml
- workers/ (entire tree)
- scripts/docs_gates/
- docker/im-convert.sh
- docs/_static/js/version-picker.js, js/pagefind-wrapper.js,
css/version-picker.css (new files, no circinus counterpart)
- docs/_templates/breadcrumbs.html, searchbox.html (new files)
docs-build.yml already triggers on push to [rolling, circinus, sagitta]
and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from
workers/matrix.json; the files simply did not exist on this branch, so
slug 1.5 still serves the bootstrap placeholder.
workers/versions.json + workers/matrix.json are deliberately identical
across all three branches and must be kept in sync.
Advances: IS-572
* ci: wire circinus docs build into the Cloudflare Workers pipeline
Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile
and docs/conf.py rather than clobbering them with rolling's versions โ
circinus keeps its own content-driven history in both files.
docker/Dockerfile:
- imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and
poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count
completeness check), installed --no-install-recommends
- install docker/im-convert.sh as /usr/local/bin/im-convert
docs/conf.py:
- enable sphinx.ext.imgconverter + image_converter = 'im-convert' so
the LaTeX/PDF builder stops silently dropping .webp/.svg images
- register js/version-picker.js + css/version-picker.css
unconditionally (degrades silently on ReadTheDocs)
- _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF
builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build']
lets _templates/searchbox.html fall back to the stock Sphinx
searchbox via the "!" bang-include on RTD
The RTD path stays the default in both files: circinus continues
building on ReadTheDocs until RTD sunset, and every CF feature activates
only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched.
circinus keeps its own version/release/html_title/source_suffix and its
hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's
DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here
and was deliberately not ported).
Advances: IS-572
* ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling
The category-1 files in this port are byte-identical copies from `rolling`.
`rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209)
merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files.
Re-take all 14 category-1 paths from `origin/rolling` via
`git checkout origin/rolling -- <paths>`, so byte-identity holds by
construction rather than by hand-editing:
.github/workflows/docs-build.yml
scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py
workers/.gitignore
workers/apex/src/{index,special,uagate}.ts
workers/apex/test/{router,uagate}.test.ts
workers/apex/ua-policy.json
Thirteen of the fourteen carry
[vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209)
exactly โ the pre-change tree was byte-identical to `3a1c6c30^` for those
paths. `workers/.gitignore` additionally picks up the one-line `test-results/`
entry from
[vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212);
inert on circinus, since only the deliberately-unported `apex-deploy.yml`
writes that directory.
Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the
default branch only, so it is not ported even though
[vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209)
touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*`
workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its
ReadTheDocs fallback intact.
๐ค Generated by [robots](https://vyos.io)
Diffstat (limited to 'workers/picker-test')
| -rw-r--r-- | workers/picker-test/pagefind-wrapper.test.ts | 42 | ||||
| -rw-r--r-- | workers/picker-test/picker.test.ts | 156 |
2 files changed, 198 insertions, 0 deletions
diff --git a/workers/picker-test/pagefind-wrapper.test.ts b/workers/picker-test/pagefind-wrapper.test.ts new file mode 100644 index 00000000..75e8be50 --- /dev/null +++ b/workers/picker-test/pagefind-wrapper.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented +// stub โ see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed +// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint +// documented in picker.test.ts and apex/test/manifest.test.ts; import as Vite `?raw` asset +// instead so content is inlined at bundle time โ no runtime filesystem access needed. +// eslint-disable-next-line import/no-unresolved +import src from "../../docs/_static/js/pagefind-wrapper.js?raw"; + +const ns: Record<string, unknown> = {}; +new Function("window", src)(ns as never); +const W = (ns as never as { VyOSSearch: Record<string, CallableFunction> }).VyOSSearch; + +describe("basePathFor (ยง9)", () => { + it("production/canary version path", () => { + expect(W.basePathFor("/en/rolling/search.html")) + .toEqual({ base: "/en/rolling/", prefix: "" }); + }); + it("PR preview path keeps /pr-<n>/ prefix and reports it", () => { + expect(W.basePathFor("/pr-42/en/1.5/search.html")) + .toEqual({ base: "/pr-42/en/1.5/", prefix: "/pr-42" }); + }); + it("prefixes result URLs in previews", () => { + expect(W.prefixResultUrl("/en/1.5/cli/index.html", "/pr-42")).toBe("/pr-42/en/1.5/cli/index.html"); + expect(W.prefixResultUrl("/en/1.5/cli/index.html", "")).toBe("/en/1.5/cli/index.html"); + }); +}); + +describe("assetUrlsFor (css + js pair under the version base)", () => { + it("production/canary base", () => { + expect(W.assetUrlsFor("/en/rolling/")).toEqual({ + css: "/en/rolling/pagefind/pagefind-ui.css", + js: "/en/rolling/pagefind/pagefind-ui.js", + }); + }); + it("PR preview base keeps the /pr-<n>/ prefix", () => { + expect(W.assetUrlsFor("/pr-42/en/1.5/")).toEqual({ + css: "/pr-42/en/1.5/pagefind/pagefind-ui.css", + js: "/pr-42/en/1.5/pagefind/pagefind-ui.js", + }); + }); +}); diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts new file mode 100644 index 00000000..560fd0d2 --- /dev/null +++ b/workers/picker-test/picker.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented +// stub โ see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed +// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint +// documented in apex/test/manifest.test.ts; import as Vite `?raw` / native JSON assets +// instead so content is inlined at bundle time โ no runtime filesystem access needed. +// eslint-disable-next-line import/no-unresolved +import src from "../../docs/_static/js/version-picker.js?raw"; +// eslint-disable-next-line import/no-unresolved +import manifest from "../versions.json"; + +// Evaluate the plain script and grab its namespace (no DOM access at module scope allowed). +const ns: Record<string, CallableFunction> = {}; +new Function("window", src)(ns as never); +const P = (ns as never as { VyOSVersionPicker: Record<string, CallableFunction> }).VyOSVersionPicker; + +describe("parseLocation", () => { + it("extracts lang/slug/rest from a docs path", () => { + expect(P.parseLocation("/en/1.5/cli/index.html")) + .toEqual({ lang: "en", slug: "1.5", rest: "cli/index.html" }); + }); + it("returns null off the version tree (e.g. previews without prefix knowledge)", () => { + expect(P.parseLocation("/kb/x")).toBeNull(); + }); + + // The slug segment is restricted to the sphinx slug charset so hostile text can never + // reach URL construction as a version identifier (CodeQL js/xss-through-dom). + it("accepts real version slugs", () => { + expect(P.parseLocation("/en/rolling/index.html")).toMatchObject({ slug: "rolling" }); + expect(P.parseLocation("/en/1.5/cli/index.html")).toMatchObject({ slug: "1.5" }); + }); + it("rejects a slug carrying markup metacharacters", () => { + expect(P.parseLocation("/en/foo<img>/page.html")).toBeNull(); + }); + it("rejects a slug carrying a quote or a space", () => { + expect(P.parseLocation('/en/foo"bar/page.html')).toBeNull(); + expect(P.parseLocation("/en/foo bar/page.html")).toBeNull(); + }); + // "." and ".." are the only normalizing dot-segments: as a slug they would walk out of + // the /<lang>/<slug>/ tree once the browser resolves the URL. Interior dots are fine. + it("rejects the dot-segments '.' and '..' as slugs, keeping dotted version slugs", () => { + expect(P.parseLocation("/en/../index.html")).toBeNull(); + expect(P.parseLocation("/en/./index.html")).toBeNull(); + expect(P.parseLocation("/en/1.5/index.html")).toMatchObject({ slug: "1.5" }); + }); +}); + +describe("bannerFor (ยง4)", () => { + it("dev โ info banner", () => { + expect(P.bannerFor("rolling", manifest)).toMatchObject({ kind: "dev" }); + }); + it("newest lts โ no banner; older lts โ newer-lts notice naming 1.5", () => { + expect(P.bannerFor("1.5", manifest)).toBeNull(); + expect(P.bannerFor("1.4", manifest)).toMatchObject({ kind: "newer-lts", newest: "1.5" }); + }); + it("eol โ warning linking newest LTS", () => { + expect(P.bannerFor("1.3", manifest)).toMatchObject({ kind: "eol", newest: "1.5" }); + }); +}); + +describe("targetUrlFor", () => { + it("same path on target version", () => { + expect(P.targetUrlFor({ lang: "en", slug: "1.5", rest: "cli/index.html" }, "1.4")) + .toBe("/en/1.4/cli/index.html"); + }); +}); + +describe("navUrlFor (query + fragment preserved across version switch)", () => { + const loc = { lang: "en", slug: "1.4", rest: "quick-start.html" }; + it("neither โ bare target path", () => { + expect(P.navUrlFor(loc, "1.5", "", "")).toBe("/en/1.5/quick-start.html"); + }); + it("query-only", () => { + expect(P.navUrlFor(loc, "1.5", "?ref=x", "")) + .toBe("/en/1.5/quick-start.html?ref=x"); + }); + it("hash-only", () => { + expect(P.navUrlFor(loc, "1.5", "", "#section-3")) + .toBe("/en/1.5/quick-start.html#section-3"); + }); + it("both, in query-then-hash order", () => { + expect(P.navUrlFor(loc, "1.5", "?ref=x", "#section-3")) + .toBe("/en/1.5/quick-start.html?ref=x#section-3"); + }); +}); + +/* DOM-text sources (select.value, location.pathname) reach a location.href sink, so every + * path component is percent-encoded at construction time (CodeQL js/xss-through-dom). */ +describe("URL construction percent-encodes hostile path components", () => { + const loc = { lang: "en", slug: "1.5", rest: "cli/index.html" }; + // Characters that could break out of a path segment or introduce a URL scheme. + const HOSTILE = ['"', "'", "<", ">", " ", ":"]; + + it("is a no-op on legitimate sphinx slugs โ URLs byte-identical to pre-hardening", () => { + expect(P.targetUrlFor(loc, "1.4")).toBe("/en/1.4/cli/index.html"); + expect(P.targetUrlFor(loc, "rolling")).toBe("/en/rolling/cli/index.html"); + expect(P.targetUrlFor({ lang: "en", slug: "1.4", rest: "" }, "1.5")).toBe("/en/1.5/"); + expect(P.langUrlFor(loc, "de")).toBe("/de/1.5/cli/index.html"); + }); + + it("encodePath keeps '/' separators while encoding each segment", () => { + expect(P.encodePath("cli/index.html")).toBe("cli/index.html"); + expect(P.encodePath('a b/c"d/e.html')).toBe("a%20b/c%22d/e.html"); + }); + + // location.pathname returns well-formed escapes verbatim, so encoding blindly would + // double-encode them (%2E -> %252E) and break the deep link on the HEAD probe. + it("encodePath normalizes pre-existing escapes instead of double-encoding", () => { + expect(P.encodePath("index%2Ehtml")).toBe("index.html"); + expect(P.encodePath("a%20b/c.html")).toBe("a%20b/c.html"); + expect(P.encodePath("a%2Fb/c")).toBe("a%2Fb/c"); // encoded slash stays in its segment + }); + + it("encodePath degrades safely on a malformed escape (no throw)", () => { + const url = P.encodePath("100%zz/x"); + expect(url).toBe("100%25zz/x"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + // Normalization runs per %HH run, not per segment: a whole-segment decode throws on the + // malformed escape and then double-encodes the valid one beside it (a%2520b%25zz). + it("encodePath normalizes each escape run independently in a mixed-validity segment", () => { + const url = P.encodePath("a%20b%zz/x"); + expect(url).toBe("a%20b%25zz/x"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("encodePath keeps an invalid-UTF-8 escape run verbatim (already pure %HH text)", () => { + const url = P.encodePath("x%E0%A4y.html"); + expect(url).toBe("x%E0%A4y.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("targetUrlFor encodes a markup-injecting target slug", () => { + const url = P.targetUrlFor(loc, '"><img src=x>'); + expect(url).toBe("/en/%22%3E%3Cimg%20src%3Dx%3E/cli/index.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("targetUrlFor kills the colon in a javascript:-shaped slug", () => { + expect(P.targetUrlFor(loc, "javascript:alert(1)")) + .toBe("/en/javascript%3Aalert(1)/cli/index.html"); + }); + + it("targetUrlFor encodes a hostile lang segment", () => { + const url = P.targetUrlFor({ lang: 'en"><script>', slug: "1.5", rest: "a.html" }, "1.4"); + expect(url).toBe("/en%22%3E%3Cscript%3E/1.4/a.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); + + it("langUrlFor encodes the new lang plus the retained slug and rest", () => { + const url = P.langUrlFor({ lang: "en", slug: 'x"y', rest: 'a b/c.html' }, "de<i>"); + expect(url).toBe("/de%3Ci%3E/x%22y/a%20b/c.html"); + for (const c of HOSTILE) expect(url).not.toContain(c); + }); +}); |
