site: serve bench.12vectors.com from a Cloudflare Worker
site/wrangler.jsonc puts site/dist/ behind bench.12vectors.com as static
assets. No `main`: the site is files, and a Worker with no script is the
cheapest correct way to serve them.
html_handling force-trailing-slash, so /x redirects to /x/ — the
url the pages link and rel=canonical names. One
page, one address; no url ends in .html.
not_found_handling 404-page, so an unknown path gets dist/404.html
with a 404 status rather than the landing page
with a 200.
routes bench.12vectors.com as a custom domain. Cloudflare
takes the hostname at the zone level and makes the
DNS record; nothing else on 12vectors.com moves.
site/root/_headers carries the response policy. HTML revalidates on
every view, so a deploy is visible on the next reload without anyone
clearing a cache; /static/* is kept for a year and never re-checked,
which is safe because the stylesheet and icon urls carry a hash of their
contents. The general rule is written first and the specific one second,
so a host that merged the two instead of overriding would still land on
max-age=0 — the safe side. Alongside it the baseline a public page owes:
nosniff, a referrer policy, a year of HSTS without preload,
X-Frame-Options, and a default-src 'none' CSP that makes "no analytics,
no third-party anything" something the browser enforces rather than
something a test asserted once.
Deploys are run by hand, as releases already are — no Cloudflare token
in repository secrets, no first deploy pipeline. site/README.md names
the account, the Worker, the route and the four-command sequence, plus
the four things to check after a deploy that no test here can reach.
The tests cover everything before Cloudflare: that the config says what
the site needs, that the build writes the files it names, and that
wrangler.jsonc, pages.json and README.md cannot drift apart about which
domain this is. A live response is not among them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+96
-5
@@ -18,13 +18,98 @@ python3 site/build.py # → site/dist/
|
||||
```
|
||||
|
||||
`site/dist/` is gitignored — it is output, rebuilt on every deploy.
|
||||
Deployment is task 32's; this directory only has to produce the files.
|
||||
|
||||
`site/` never reaches a host repo: releases ship exactly what
|
||||
`../manager/core/release-manifest` lists, and it does not list this
|
||||
directory. `tests/test_release_artifact.py` asserts that out loud rather
|
||||
than leaving it to inference.
|
||||
|
||||
## Where the site lives
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Host | Cloudflare Workers, [static assets](https://developers.cloudflare.com/workers/static-assets/) — no script, no KV, no database |
|
||||
| Account | the Cloudflare account holding the `12vectors.com` zone. `npx wrangler whoami` must list it before a deploy will work |
|
||||
| Worker | `bench-site` |
|
||||
| Route | `bench.12vectors.com`, a **custom domain** — Cloudflare owns the hostname at the zone level and creates the DNS record itself |
|
||||
| Served from | `site/dist/`, uploaded whole on every deploy |
|
||||
| Config | `site/wrangler.jsonc` (routing) and `site/root/_headers` (caching, security) |
|
||||
|
||||
Deploys are run by hand, by a person, exactly as releases are
|
||||
(`../release.sh`). There is no deploy pipeline and no Cloudflare token in
|
||||
repository secrets; adding a GitHub Action on merge to `main` is a
|
||||
separate decision with a separate cost, and a follow-up card.
|
||||
|
||||
## Deploying
|
||||
|
||||
From a clean checkout, four commands. Re-running the whole sequence is
|
||||
safe: the build empties and rewrites `dist/`, and `wrangler deploy`
|
||||
replaces the Worker's assets rather than adding to them.
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r site/requirements.txt # once per machine
|
||||
python3 site/fetch-fonts.py # once per checkout
|
||||
python3 site/build.py # → site/dist/
|
||||
npx wrangler@4 deploy --config site/wrangler.jsonc
|
||||
```
|
||||
|
||||
`npx wrangler@4 login` first, once per machine, against an account that
|
||||
can see the `12vectors.com` zone. Paths inside `wrangler.jsonc` are
|
||||
relative to that file, so the command works from anywhere in the repo.
|
||||
|
||||
The first deploy is the one that takes the hostname over. It creates the
|
||||
DNS record for `bench.12vectors.com` and routes it to the Worker;
|
||||
anything else answering on that name stops answering. Every deploy after
|
||||
it is an asset upload.
|
||||
|
||||
### Preview it locally
|
||||
|
||||
```bash
|
||||
python3 site/build.py
|
||||
npx wrangler@4 dev --config site/wrangler.jsonc # → http://localhost:8787
|
||||
```
|
||||
|
||||
`dev` serves `dist/` through the same static-assets router as production,
|
||||
so trailing-slash redirects and the 404 page behave as they will live.
|
||||
The custom domain is ignored locally.
|
||||
|
||||
### After a deploy, check these four
|
||||
|
||||
The things this repository's tests cannot reach, because they are
|
||||
answers rather than files:
|
||||
|
||||
1. `https://bench.12vectors.com/` serves the landing page over TLS.
|
||||
2. `https://bench.12vectors.com/concepts/claiming-a-card` redirects to
|
||||
the same path with a trailing slash, and no url anywhere ends in
|
||||
`.html`.
|
||||
3. A path that does not exist — `/nope/` — renders the site's own 404
|
||||
page **with a 404 status**, not the landing page with a 200.
|
||||
4. `curl -sI https://bench.12vectors.com/` shows
|
||||
`x-content-type-options`, `referrer-policy`,
|
||||
`strict-transport-security` and a `cache-control` that revalidates.
|
||||
|
||||
## How it is served
|
||||
|
||||
- **One url per page.** `html_handling: "force-trailing-slash"` redirects
|
||||
`/concepts/claiming-a-card` to `/concepts/claiming-a-card/`, which is
|
||||
what the pages link and what `<link rel=canonical>` names. Pages are
|
||||
written as `<route>/index.html`, so no url ends in `.html`.
|
||||
- **A real 404.** `not_found_handling: "404-page"` serves `dist/404.html`
|
||||
with a 404 status. That page is a normal manifest entry (`/404.html`,
|
||||
layout `notfound`) — the site's own design, its own nav, and a link
|
||||
back to the landing page.
|
||||
- **Two caching policies, because there are two kinds of file.** HTML
|
||||
revalidates on every view, so a deploy is visible on the next reload
|
||||
with nobody clearing anything. Everything under `/static/` is kept for
|
||||
a year and never re-checked, which is only safe because the stylesheet
|
||||
and the icon are linked with a `?v=<hash>` of their own contents:
|
||||
change the file and the url changes with it.
|
||||
- **Baseline headers, no third parties.** `nosniff`, a referrer policy,
|
||||
a year of HSTS, `X-Frame-Options`, and a Content-Security-Policy of
|
||||
`default-src 'none'` with `'self'` for styles, fonts and images. The
|
||||
site collects nothing and loads nothing from anywhere else; the CSP is
|
||||
that promise in a form the browser enforces.
|
||||
|
||||
## What is where
|
||||
|
||||
| Path | What it is |
|
||||
@@ -33,6 +118,8 @@ than leaving it to inference.
|
||||
| `build.py` | The generator — slicing, drift detection, rendering, links |
|
||||
| `templates/` | One `string.Template` per layout (`$name`, `$$` for a literal dollar) |
|
||||
| `static/` | Copied to `dist/static/` verbatim: the stylesheet, the icon, the fonts |
|
||||
| `root/` | Copied to the **top** of `dist/` verbatim: `_headers`, which the host reads and never serves |
|
||||
| `wrangler.jsonc` | The Worker: assets directory, url handling, 404, custom domain |
|
||||
| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
|
||||
| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
|
||||
|
||||
@@ -51,12 +138,15 @@ than leaving it to inference.
|
||||
}
|
||||
```
|
||||
|
||||
- **`path`** starts and ends with `/`; `/x/y/` is written to
|
||||
`dist/x/y/index.html`.
|
||||
- **`path`** starts with `/` and ends with `/`; `/x/y/` is written to
|
||||
`dist/x/y/index.html`. A route that names an `.html` file instead is
|
||||
written to exactly that path — `/404.html` is the only one, and it
|
||||
exists because the host looks for that literal filename.
|
||||
- **`layout`** names a file in `templates/`.
|
||||
- **`section`** groups the page in the nav and the sidebar. The IA is
|
||||
read out of this file in this file's order — nothing is derived from
|
||||
the directory layout.
|
||||
the directory layout. A `null` section keeps the page out of both,
|
||||
which is how the 404 page stays off the nav.
|
||||
- **`source`** is repo-relative, or `null` for a landing page whose body
|
||||
is authored in its template rather than sliced.
|
||||
- **`from`** is the heading the slice starts at. It is matched on the
|
||||
@@ -78,7 +168,8 @@ Each of these exits non-zero with a message naming the route:
|
||||
- a slice that comes out empty;
|
||||
- a markdown link to a repo path that does not exist, or that escapes
|
||||
the repo — a dead relative link must never reach the site;
|
||||
- a template placeholder the builder does not supply.
|
||||
- a template placeholder the builder does not supply;
|
||||
- a file in `root/` that a route would also write.
|
||||
|
||||
Repo-relative links that *do* resolve are rewritten: to a site route if
|
||||
`link_routes` maps the file to one, otherwise to the file on GitHub.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Response headers for bench.12vectors.com.
|
||||
#
|
||||
# Read by Cloudflare Workers static assets at deploy time — the file is
|
||||
# consumed, never served — and copied here from site/root/ by
|
||||
# site/build.py, because the host looks for it at the root of the build
|
||||
# and nowhere else.
|
||||
#
|
||||
# Rules apply in order and a later rule wins on a header it repeats. The
|
||||
# two blocks below are written so that even a host that merged them
|
||||
# instead would land on the safe side: HTML would still revalidate.
|
||||
|
||||
# Everything, so that no page can ever forget one of these.
|
||||
#
|
||||
# nosniff a text/plain file must not become a script
|
||||
# Referrer-Policy a full url is never sent to another origin
|
||||
# HSTS one year, this host and anything below it. No
|
||||
# `preload`: that is a submission to browser
|
||||
# vendors and a commitment this card did not make
|
||||
# X-Frame-Options nothing here is meant to be framed
|
||||
# CSP the runtime form of the site's own promise —
|
||||
# no analytics, no font CDN, no third-party
|
||||
# anything. `default-src 'none'` means an asset
|
||||
# must be named below to load at all, and no
|
||||
# 'unsafe-inline' anywhere means an injected
|
||||
# <script> does not run
|
||||
# Cache-Control HTML revalidates on every view, so a deploy is
|
||||
# visible on the next reload. The ETag makes that
|
||||
# a 304 rather than a re-download
|
||||
/*
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
X-Frame-Options: DENY
|
||||
Content-Security-Policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
|
||||
# The stylesheet and the icon are linked with a ?v=<hash> of their own
|
||||
# contents (site/build.py, stamp()), and the fonts never change under a
|
||||
# given filename. All of it is safe to keep for a year and never check:
|
||||
# a deploy that changes the stylesheet changes the url that asks for it.
|
||||
/static/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -0,0 +1,43 @@
|
||||
// bench.12vectors.com — the built minisite, served as static assets.
|
||||
//
|
||||
// There is no `main`: this Worker has no script at all. site/dist/ is
|
||||
// plain files and Cloudflare's static-assets router serves them, so the
|
||||
// cheapest correct thing is to give it nothing to run. Anything that
|
||||
// would need a fetch handler (an api, a redirect that depends on state)
|
||||
// is a different card.
|
||||
//
|
||||
// npx wrangler@4 deploy --config site/wrangler.jsonc
|
||||
//
|
||||
// See site/README.md for the whole sequence, the account, and what the
|
||||
// domain currently points at.
|
||||
{
|
||||
"name": "bench-site",
|
||||
"compatibility_date": "2026-07-01",
|
||||
|
||||
"assets": {
|
||||
// Relative to this file. site/build.py writes it; it is gitignored,
|
||||
// so a deploy from a clean checkout builds first.
|
||||
"directory": "./dist",
|
||||
|
||||
// /concepts/claiming-a-card -> 301 -> /concepts/claiming-a-card/,
|
||||
// which is the url the pages link and the one <link rel=canonical>
|
||||
// names. One page, one address: the slashless form redirects rather
|
||||
// than serving a second copy, and no url ever ends in .html.
|
||||
"html_handling": "force-trailing-slash",
|
||||
|
||||
// An unknown path gets dist/404.html with a 404 status — the site's
|
||||
// own not-found page, in the site's own design. Not "single-page-
|
||||
// application", which would answer 200 with the landing page and
|
||||
// tell a crawler every typo is a real url.
|
||||
"not_found_handling": "404-page"
|
||||
},
|
||||
|
||||
// The hostname, taken over at the zone level: Cloudflare points
|
||||
// bench.12vectors.com at this Worker and creates the DNS record. It
|
||||
// touches nothing else on 12vectors.com.
|
||||
"routes": [
|
||||
{ "pattern": "bench.12vectors.com", "custom_domain": true }
|
||||
],
|
||||
|
||||
"observability": { "enabled": true }
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"""bench.12vectors.com is a Cloudflare Worker serving site/dist/ as
|
||||
static assets. What can be tested here is everything the deploy depends
|
||||
on *before* it reaches Cloudflare: that the config says what the site
|
||||
needs it to say, that the build produces the files that config names,
|
||||
and that the three of them — wrangler.jsonc, pages.json, README.md —
|
||||
cannot drift apart about which domain this is.
|
||||
|
||||
What cannot be tested here is a live response. `not_found_handling` is
|
||||
asserted as configuration plus the 404.html it points at; that a request
|
||||
to a missing path comes back 404 is Cloudflare's half, and the deploy
|
||||
checklist in site/README.md is where a person confirms it.
|
||||
|
||||
python3 -m unittest discover -s tests
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SITE = REPO / "site"
|
||||
WRANGLER = SITE / "wrangler.jsonc"
|
||||
HEADERS = SITE / "root" / "_headers"
|
||||
README = SITE / "README.md"
|
||||
|
||||
# Set on every response, whatever it is. The task's three, plus the two
|
||||
# that come free with them.
|
||||
BASELINE = ("X-Content-Type-Options", "Referrer-Policy",
|
||||
"Strict-Transport-Security")
|
||||
|
||||
try:
|
||||
import markdown_it # noqa: F401
|
||||
HAS_MARKDOWN_IT = True
|
||||
except ImportError: # pragma: no cover - environment
|
||||
HAS_MARKDOWN_IT = False
|
||||
|
||||
|
||||
# ── reading the two config formats by hand ────────────────────────────
|
||||
|
||||
COMMENT = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*|/\*.*?\*/', re.DOTALL)
|
||||
|
||||
|
||||
def read_jsonc(path: Path) -> dict:
|
||||
"""wrangler.jsonc is JSON with comments, and comments are how that
|
||||
file explains itself. Strings are matched first so a `//` inside one
|
||||
survives."""
|
||||
def keep(match):
|
||||
text = match.group(0)
|
||||
return text if text.startswith('"') else " "
|
||||
return json.loads(COMMENT.sub(keep, path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def read_headers(path: Path) -> list:
|
||||
"""`_headers` as [(pattern, {header: value})], in file order — an
|
||||
unindented line opens a rule, an indented one adds a header to it."""
|
||||
rules, current = [], None
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if not line.startswith((" ", "\t")):
|
||||
current = (line.strip(), {})
|
||||
rules.append(current)
|
||||
elif current is not None:
|
||||
name, _, value = line.strip().partition(":")
|
||||
current[1][name.strip()] = value.strip()
|
||||
return rules
|
||||
|
||||
|
||||
def readme_text() -> str:
|
||||
return README.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def rule(pattern: str) -> dict:
|
||||
for found, headers in read_headers(HEADERS):
|
||||
if found == pattern:
|
||||
return headers
|
||||
raise AssertionError(f"_headers has no rule for {pattern}")
|
||||
|
||||
|
||||
def run_build(repo: Path, out: Path) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(repo / "site" / "build.py"), "--out", str(out)],
|
||||
capture_output=True, text=True, cwd=repo)
|
||||
|
||||
|
||||
class Built(unittest.TestCase):
|
||||
"""The real site, built once into a scratch directory."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not HAS_MARKDOWN_IT:
|
||||
raise unittest.SkipTest("markdown-it-py is not installed")
|
||||
cls.out = Path(tempfile.mkdtemp(prefix="bench-deploy-")).resolve()
|
||||
result = run_build(REPO, cls.out)
|
||||
if result.returncode != 0: # not assert: must survive python -O
|
||||
raise RuntimeError(
|
||||
f"site/build.py failed:\n{result.stdout}{result.stderr}")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if hasattr(cls, "out"):
|
||||
shutil.rmtree(cls.out, ignore_errors=True)
|
||||
|
||||
|
||||
# ── the worker config ─────────────────────────────────────────────────
|
||||
|
||||
class TheWorkerServesTheBuild(unittest.TestCase):
|
||||
"""site/wrangler.jsonc, read the way wrangler reads it."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = read_jsonc(WRANGLER)
|
||||
self.assets = self.config.get("assets", {})
|
||||
|
||||
def test_the_assets_directory_is_what_the_builder_writes(self):
|
||||
"""Paths in wrangler config are relative to the config file, so
|
||||
this one has to resolve to site/dist and not to a sibling of the
|
||||
repo root."""
|
||||
directory = (WRANGLER.parent / self.assets["directory"]).resolve()
|
||||
self.assertEqual(directory, (SITE / "dist").resolve())
|
||||
|
||||
def test_there_is_no_worker_script(self):
|
||||
"""The site is files. A `main` would mean a fetch handler to
|
||||
maintain, and nothing here needs one."""
|
||||
self.assertNotIn("main", self.config)
|
||||
|
||||
def test_a_url_has_exactly_one_form(self):
|
||||
"""force-trailing-slash: /x redirects to /x/, which is the url
|
||||
the pages link and the one rel=canonical names. Anything else
|
||||
leaves two urls serving one page."""
|
||||
self.assertEqual("force-trailing-slash",
|
||||
self.assets.get("html_handling"))
|
||||
|
||||
def test_an_unknown_path_gets_the_sites_own_404(self):
|
||||
"""Not single-page-application, which answers 200 with the
|
||||
landing page and tells a crawler every typo is a real url."""
|
||||
self.assertEqual("404-page", self.assets.get("not_found_handling"))
|
||||
|
||||
def test_the_route_is_a_custom_domain(self):
|
||||
routes = self.config.get("routes", [])
|
||||
self.assertEqual(1, len(routes), "expected exactly one route")
|
||||
self.assertTrue(routes[0].get("custom_domain"),
|
||||
"the route is not a custom domain")
|
||||
|
||||
def test_the_worker_is_named(self):
|
||||
self.assertTrue(self.config.get("name"))
|
||||
|
||||
def test_a_compatibility_date_is_pinned(self):
|
||||
self.assertRegex(self.config.get("compatibility_date", ""),
|
||||
r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
class TheDomainIsWrittenDownOnce(unittest.TestCase):
|
||||
"""Three files name this site's address. They are allowed to say it
|
||||
three times; they are not allowed to disagree."""
|
||||
|
||||
def setUp(self):
|
||||
self.config = read_jsonc(WRANGLER)
|
||||
self.manifest = json.loads(
|
||||
(SITE / "pages.json").read_text(encoding="utf-8"))
|
||||
|
||||
def test_the_route_is_the_host_the_pages_canonicalise_to(self):
|
||||
"""A rel=canonical pointing somewhere the Worker does not answer
|
||||
is worse than none at all."""
|
||||
canonical = urlparse(self.manifest["site"]["base_url"])
|
||||
self.assertEqual("https", canonical.scheme)
|
||||
self.assertEqual(canonical.netloc,
|
||||
self.config["routes"][0]["pattern"])
|
||||
|
||||
def test_the_readme_names_the_worker_and_the_route(self):
|
||||
"""Acceptance: the next person should not have to guess where
|
||||
the site lives."""
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
self.assertIn(self.config["name"], readme)
|
||||
self.assertIn(self.config["routes"][0]["pattern"], readme)
|
||||
|
||||
def test_the_readme_names_the_deploy_command(self):
|
||||
self.assertRegex(readme_text(), r"wrangler[^\n]*\bdeploy\b")
|
||||
|
||||
def test_the_readme_names_the_account(self):
|
||||
self.assertRegex(readme_text(), r"(?i)\baccount\b")
|
||||
|
||||
|
||||
# ── the 404 page ──────────────────────────────────────────────────────
|
||||
|
||||
class TheNotFoundPageIsAPage(Built):
|
||||
"""`not_found_handling: "404-page"` is half of it. The other half is
|
||||
that a 404.html exists, looks like the rest of the site, and offers a
|
||||
way out."""
|
||||
|
||||
def setUp(self):
|
||||
self.page = (self.out / "404.html").read_text("utf-8")
|
||||
|
||||
def test_it_is_written_where_the_host_looks(self):
|
||||
"""A 404/index.html would never be found: the host wants a file
|
||||
called 404.html at the root of the assets directory."""
|
||||
self.assertTrue((self.out / "404.html").is_file())
|
||||
self.assertFalse((self.out / "404" / "index.html").exists())
|
||||
|
||||
def test_it_wears_the_sites_design(self):
|
||||
self.assertIn('rel="stylesheet" href="/static/site.css', self.page)
|
||||
self.assertIn("404", self.page)
|
||||
|
||||
def test_it_offers_the_way_back(self):
|
||||
self.assertIn('href="/"', self.page)
|
||||
|
||||
def test_it_claims_no_canonical_url(self):
|
||||
"""A 404 is not a page to canonicalise to, and telling a crawler
|
||||
to index it is worse still."""
|
||||
self.assertNotIn('rel="canonical"', self.page)
|
||||
self.assertIn('name="robots" content="noindex"', self.page)
|
||||
|
||||
def test_it_stays_out_of_the_nav_and_the_sidebar(self):
|
||||
home = (self.out / "index.html").read_text("utf-8")
|
||||
article = (self.out / "concepts" / "claiming-a-card"
|
||||
/ "index.html").read_text("utf-8")
|
||||
for html in (home, article):
|
||||
self.assertNotIn("404.html", html)
|
||||
|
||||
|
||||
# ── headers ───────────────────────────────────────────────────────────
|
||||
|
||||
class TheHeaderPolicyTravelsWithTheBuild(Built):
|
||||
"""site/root/_headers is the policy; the build has to carry it to
|
||||
the root of dist/ or the host never reads it."""
|
||||
|
||||
def test_it_reaches_the_root_of_the_build(self):
|
||||
shipped = self.out / "_headers"
|
||||
self.assertTrue(shipped.is_file())
|
||||
self.assertEqual(HEADERS.read_text("utf-8"), shipped.read_text("utf-8"))
|
||||
|
||||
def test_it_is_not_also_served_as_a_page(self):
|
||||
"""The host consumes _headers rather than serving it, but the
|
||||
build must not have made a route out of it either."""
|
||||
self.assertFalse((self.out / "_headers" / "index.html").exists())
|
||||
|
||||
def test_no_page_needs_what_the_policy_forbids(self):
|
||||
"""The CSP carries no 'unsafe-inline', so an inline <script> or a
|
||||
style="" attribute is a page that renders right in every test
|
||||
here and wrong in production. The only place that can be caught
|
||||
before a deploy is the build output."""
|
||||
for path in sorted(self.out.rglob("*.html")):
|
||||
html = path.read_text("utf-8")
|
||||
where = path.relative_to(self.out)
|
||||
self.assertNotIn("<script", html.lower(),
|
||||
f"{where} has a script the CSP would block")
|
||||
self.assertNotRegex(
|
||||
html, r"""\sstyle\s*=\s*["']""",
|
||||
f"{where} has an inline style the CSP would block")
|
||||
|
||||
|
||||
class EveryResponseCarriesTheBaseline(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.everything = rule("/*")
|
||||
|
||||
def test_the_three_headers_a_public_page_owes(self):
|
||||
for name in BASELINE:
|
||||
self.assertIn(name, self.everything)
|
||||
|
||||
def test_hsts_is_a_year_and_does_not_preload(self):
|
||||
value = self.everything["Strict-Transport-Security"]
|
||||
self.assertIn("max-age=31536000", value)
|
||||
self.assertNotIn("preload", value,
|
||||
"preload is a submission to browser vendors, "
|
||||
"not a header to set in passing")
|
||||
|
||||
def test_nothing_third_party_can_load(self):
|
||||
"""The site's promise — no analytics, no font CDN — as something
|
||||
the browser enforces rather than something a test asserted once
|
||||
at build time."""
|
||||
policy = self.everything["Content-Security-Policy"]
|
||||
self.assertIn("default-src 'none'", policy)
|
||||
self.assertNotIn("unsafe-inline", policy)
|
||||
self.assertNotIn("unsafe-eval", policy)
|
||||
self.assertNotIn("*", policy)
|
||||
self.assertNotIn("//", policy, "the policy names another origin")
|
||||
|
||||
|
||||
class TheTwoKindsOfFileAreCachedDifferently(unittest.TestCase):
|
||||
"""The edge case in the task: a stale HTML page must not survive a
|
||||
deploy, while the assets it links may live for a year."""
|
||||
|
||||
def test_html_revalidates_on_every_view(self):
|
||||
value = rule("/*")["Cache-Control"]
|
||||
self.assertIn("max-age=0", value)
|
||||
self.assertIn("must-revalidate", value)
|
||||
self.assertNotIn("immutable", value)
|
||||
|
||||
def test_fingerprinted_assets_are_kept_for_a_year(self):
|
||||
value = rule("/static/*")["Cache-Control"]
|
||||
self.assertIn("max-age=31536000", value)
|
||||
self.assertIn("immutable", value)
|
||||
|
||||
def test_the_general_rule_comes_first(self):
|
||||
"""Order is the whole argument: /static/* overrides /* on
|
||||
Cache-Control, and a host that merged them instead of overriding
|
||||
would land on max-age=0 — the safe side."""
|
||||
patterns = [pattern for pattern, _ in read_headers(HEADERS)]
|
||||
self.assertLess(patterns.index("/*"), patterns.index("/static/*"))
|
||||
|
||||
|
||||
# ── fingerprinting ────────────────────────────────────────────────────
|
||||
|
||||
class Scratch(unittest.TestCase):
|
||||
"""A copy of site/ plus the markdown it reads, so a test can edit the
|
||||
stylesheet without touching the real one."""
|
||||
|
||||
def setUp(self):
|
||||
if not HAS_MARKDOWN_IT:
|
||||
raise unittest.SkipTest("markdown-it-py is not installed")
|
||||
self.root = Path(tempfile.mkdtemp(prefix="bench-deploy-")).resolve()
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
shutil.copytree(SITE, self.root / "site",
|
||||
ignore=shutil.ignore_patterns("dist", "__pycache__"))
|
||||
for name in ("AGENTS.md", "README.md"):
|
||||
shutil.copy(REPO / name, self.root / name)
|
||||
self.out = self.root / "site" / "dist"
|
||||
|
||||
def build(self):
|
||||
return run_build(self.root, self.out)
|
||||
|
||||
def home(self) -> str:
|
||||
return (self.out / "index.html").read_text("utf-8")
|
||||
|
||||
|
||||
class TheStylesheetUrlFollowsTheStylesheet(Scratch):
|
||||
"""`immutable` for a year is only safe because the url changes when
|
||||
the file does. That is the claim under test."""
|
||||
|
||||
STAMP = re.compile(r'href="(/static/site\.css\?v=([0-9a-f]{10}))"')
|
||||
|
||||
def test_the_pages_link_a_stamped_url(self):
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
self.assertRegex(self.home(), self.STAMP.pattern)
|
||||
|
||||
def test_the_file_still_sits_at_its_plain_path(self):
|
||||
"""Only the link carries the stamp — nothing in static/ is
|
||||
renamed, so the /static/* glob in _headers still matches and the
|
||||
tree stays readable."""
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
self.assertTrue((self.out / "static" / "site.css").is_file())
|
||||
|
||||
def test_editing_the_stylesheet_changes_the_stamp(self):
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
before = self.STAMP.search(self.home()).group(2)
|
||||
|
||||
css = self.root / "site" / "static" / "site.css"
|
||||
css.write_text(css.read_text("utf-8") + "\n.lost{color:red}\n",
|
||||
encoding="utf-8")
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
after = self.STAMP.search(self.home()).group(2)
|
||||
|
||||
self.assertNotEqual(before, after,
|
||||
"a changed stylesheet kept its url, so a "
|
||||
"year-long cache would keep the old one")
|
||||
|
||||
def test_a_rebuild_that_changes_nothing_keeps_the_stamp(self):
|
||||
"""The other half: a deploy that did not touch the stylesheet
|
||||
must not invalidate everyone's cache."""
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
before = self.STAMP.search(self.home()).group(2)
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
self.assertEqual(before, self.STAMP.search(self.home()).group(2))
|
||||
|
||||
|
||||
class TheRootTreeIsCopiedVerbatim(Scratch):
|
||||
|
||||
def test_a_root_file_lands_at_the_top_of_the_build(self):
|
||||
(self.root / "site" / "root" / "robots.txt").write_text(
|
||||
"User-agent: *\n", encoding="utf-8")
|
||||
self.assertEqual(0, self.build().returncode)
|
||||
self.assertEqual("User-agent: *\n",
|
||||
(self.out / "robots.txt").read_text("utf-8"))
|
||||
|
||||
def test_a_root_file_a_route_also_claims_stops_the_build(self):
|
||||
"""Both would be written, one would win, and which one is not
|
||||
something a person should have to work out from a diff."""
|
||||
(self.root / "site" / "root" / "404.html").write_text(
|
||||
"mine", encoding="utf-8")
|
||||
result = self.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("404.html", result.stderr)
|
||||
|
||||
|
||||
class TheDeployIsInTheRepository(unittest.TestCase):
|
||||
"""The two facts about deployment that live outside a config file."""
|
||||
|
||||
def test_the_worker_config_and_the_headers_are_tracked(self):
|
||||
tracked = subprocess.run(
|
||||
["git", "check-ignore", "site/wrangler.jsonc", "site/root/_headers"],
|
||||
capture_output=True, text=True, cwd=REPO)
|
||||
self.assertNotEqual(tracked.returncode, 0,
|
||||
"the deploy config is gitignored")
|
||||
|
||||
def test_the_release_artifact_still_does_not_ship_the_site(self):
|
||||
"""A Worker config in site/ must not start travelling into host
|
||||
projects with a release."""
|
||||
manifest = (REPO / "manager" / "core" / "release-manifest"
|
||||
).read_text("utf-8")
|
||||
for line in manifest.splitlines():
|
||||
if line.strip().startswith("#") or not line.strip():
|
||||
continue
|
||||
self.assertNotIn("site", line.split(None, 1)[1],
|
||||
f"the release manifest names site/: {line}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user