feat: launch desfoto.de as a standalone photography site and retire the old redirect
This commit is contained in:
359
tests/test_site.py
Normal file
359
tests/test_site.py
Normal file
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic structural tests for the committed desfoto.de build in site/.
|
||||
|
||||
Run after `python3 scripts/build-site.py`:
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
|
||||
The tests only read generated files, so they are fast and side-effect free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SITE = ROOT / "site"
|
||||
SRC = ROOT / "src"
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
import pages # noqa: E402
|
||||
from content import CONTACT, LEGAL, SITE as META # noqa: E402
|
||||
|
||||
ROUTE_PATHS = [route[0] for route in pages.ROUTES]
|
||||
HTML_FILES = sorted(SITE.rglob("*.html"))
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def visible_text(html_text: str) -> str:
|
||||
body = re.search(r"<body\b.*</body>", html_text, re.S)
|
||||
chunk = body.group(0) if body else html_text
|
||||
chunk = re.sub(r"<(script|style)\b.*?</\1>", " ", chunk, flags=re.S)
|
||||
return unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", chunk))).strip()
|
||||
|
||||
|
||||
class BuildLayout(unittest.TestCase):
|
||||
def test_every_route_is_built(self):
|
||||
for route in pages.ROUTES:
|
||||
_path, rel = route[0], route[1]
|
||||
with self.subTest(route=route[0]):
|
||||
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
|
||||
|
||||
def test_no_stray_html_files(self):
|
||||
expected = {route[1] for route in pages.ROUTES} | {"404.html"}
|
||||
actual = {str(f.relative_to(SITE)) for f in HTML_FILES}
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
def test_site_files_are_world_readable(self):
|
||||
"""The nginx worker is not root: 0600 build output would 403 in production."""
|
||||
for path in SITE.rglob("*"):
|
||||
if path.is_dir():
|
||||
continue
|
||||
with self.subTest(asset=str(path.relative_to(SITE))):
|
||||
self.assertTrue(path.stat().st_mode & 0o044, f"{path} is not world readable")
|
||||
|
||||
def test_meta_files_exist(self):
|
||||
for rel in (
|
||||
"sitemap.xml",
|
||||
"robots.txt",
|
||||
"site.webmanifest",
|
||||
"favicon.svg",
|
||||
".well-known/security.txt",
|
||||
"assets/site.css",
|
||||
"assets/site.js",
|
||||
"assets/fonts.css",
|
||||
):
|
||||
with self.subTest(rel=rel):
|
||||
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
|
||||
|
||||
|
||||
class HeadTags(unittest.TestCase):
|
||||
def test_titles_descriptions_and_canonicals(self):
|
||||
for path in ROUTE_PATHS:
|
||||
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
|
||||
with self.subTest(route=path):
|
||||
text = read(SITE / rel)
|
||||
self.assertIn('<html lang="de">', text)
|
||||
title = re.search(r"<title>([^<]+)</title>", text)
|
||||
self.assertIsNotNone(title)
|
||||
self.assertTrue(title.group(1).strip())
|
||||
desc = re.search(r'<meta name="description" content="([^"]*)"', text)
|
||||
self.assertIsNotNone(desc)
|
||||
self.assertGreaterEqual(len(desc.group(1)), 60)
|
||||
self.assertIn(f'<link rel="canonical" href="{META["url"]}{path}">', text)
|
||||
self.assertIn('<meta name="robots" content="index,follow', text)
|
||||
self.assertIn('<meta property="og:image" content="https://desfoto.de/assets/img/og-', text)
|
||||
self.assertEqual(text.count('class="masthead"'), 1)
|
||||
self.assertEqual(text.count('id="main"'), 1)
|
||||
|
||||
def test_not_found_page_is_not_indexable(self):
|
||||
text = read(SITE / "404.html")
|
||||
self.assertIn('<meta name="robots" content="noindex,follow">', text)
|
||||
self.assertNotIn('rel="canonical"', text)
|
||||
|
||||
def test_single_h1_per_page(self):
|
||||
for path in ROUTE_PATHS:
|
||||
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
|
||||
with self.subTest(route=path):
|
||||
self.assertEqual(read(SITE / rel).count("<h1"), 1)
|
||||
|
||||
def test_structured_data_is_valid_json(self):
|
||||
for path in ROUTE_PATHS:
|
||||
rel = dict((r[0], r[1]) for r in pages.ROUTES)[path]
|
||||
for block in re.findall(
|
||||
r'<script type="application/ld\+json">(.*?)</script>',
|
||||
read(SITE / rel),
|
||||
re.S,
|
||||
):
|
||||
with self.subTest(route=path):
|
||||
json.loads(block)
|
||||
|
||||
|
||||
class ImagesAndAssets(unittest.TestCase):
|
||||
def test_every_img_has_alt(self):
|
||||
for file in HTML_FILES:
|
||||
for tag in re.findall(r"<img\b[^>]*>", read(file)):
|
||||
with self.subTest(file=file.name):
|
||||
self.assertIn('alt="', tag)
|
||||
|
||||
def test_wordpress_style_external_assets_absent(self):
|
||||
"""No privacy page promise may be broken: nothing is loaded cross-origin."""
|
||||
allowed = {"https://www.youtube-nocookie.com"}
|
||||
for file in HTML_FILES + [SITE / "assets" / "site.css", SITE / "assets" / "fonts.css"]:
|
||||
text = read(file)
|
||||
candidates = list(re.findall(r'(?:src|srcset|imagesrcset|poster)="([^"]+)"', text))
|
||||
candidates += list(re.findall(r"url\(([^)]*)\)", text))
|
||||
for match in candidates:
|
||||
for url in re.findall(r"(https?://[^\s,)'\"]+)", match):
|
||||
with self.subTest(file=file.name, url=url):
|
||||
self.assertIn(url.split("/embed")[0], allowed | {META["url"]})
|
||||
|
||||
def test_referenced_images_exist(self):
|
||||
for file in HTML_FILES:
|
||||
text = read(file)
|
||||
for candidate in set(re.findall(r"/assets/img/([A-Za-z0-9._-]+)", text)):
|
||||
with self.subTest(file=file.name, asset=candidate):
|
||||
self.assertTrue((SITE / "assets" / "img" / candidate).is_file())
|
||||
|
||||
def test_no_hero_image_without_dimensions(self):
|
||||
for file in HTML_FILES:
|
||||
for tag in re.findall(r"<img\b[^>]*>", read(file)):
|
||||
with self.subTest(file=file.name):
|
||||
self.assertIn("width=", tag)
|
||||
self.assertIn("height=", tag)
|
||||
|
||||
|
||||
class PrivacyAndLegal(unittest.TestCase):
|
||||
def test_no_cookies_analytics_or_third_party_scripts(self):
|
||||
for file in HTML_FILES:
|
||||
text = read(file).lower()
|
||||
with self.subTest(file=file.name):
|
||||
for forbidden in ("google-analytics", "googletagmanager", "gtag(", "document.cookie", "fonts.googleapis"):
|
||||
self.assertNotIn(forbidden, text)
|
||||
|
||||
def test_nginx_writes_no_access_log(self):
|
||||
conf = read(ROOT / "nginx.conf")
|
||||
self.assertIn("access_log off;", conf)
|
||||
self.assertIn("error_log /dev/null crit;", conf)
|
||||
self.assertNotIn("/var/log/nginx", conf)
|
||||
|
||||
def test_nginx_sets_the_documented_security_headers(self):
|
||||
conf = read(ROOT / "nginx.conf")
|
||||
for header in (
|
||||
"X-Content-Type-Options",
|
||||
"Referrer-Policy",
|
||||
"X-Frame-Options",
|
||||
"Permissions-Policy",
|
||||
"Strict-Transport-Security",
|
||||
"Content-Security-Policy",
|
||||
):
|
||||
with self.subTest(header=header):
|
||||
self.assertIn(f"add_header {header}", conf)
|
||||
self.assertIn("default-src 'self'", conf)
|
||||
self.assertIn("frame-src https://www.youtube-nocookie.com", conf)
|
||||
self.assertIn("form-action 'self' mailto:", conf)
|
||||
|
||||
def test_styles_and_scripts_revalidate(self):
|
||||
"""site.css/site.js are not content-hashed: they must be revalidated."""
|
||||
conf = read(ROOT / "nginx.conf")
|
||||
cache_rule = re.search(r'~\^/assets/site\\\.\(css\|js\)\$\s+"([^"]+)"', conf)
|
||||
self.assertIsNotNone(cache_rule)
|
||||
self.assertIn("max-age=0", cache_rule.group(1))
|
||||
self.assertIn("must-revalidate", cache_rule.group(1))
|
||||
|
||||
def test_index_html_redirect_handles_query_strings(self):
|
||||
conf = read(ROOT / "nginx.conf")
|
||||
self.assertIn(r"^/(.*/)?index\.html(\?.*)?$", conf)
|
||||
|
||||
def test_impressum_has_required_provider_data(self):
|
||||
text = visible_text(read(SITE / "impressum" / "index.html"))
|
||||
for value in (
|
||||
LEGAL["owner"],
|
||||
LEGAL["street"],
|
||||
LEGAL["city"],
|
||||
CONTACT["email"],
|
||||
CONTACT["phone"],
|
||||
"§ 5 DDG",
|
||||
"§ 19 UStG",
|
||||
LEGAL["w_id"],
|
||||
"§ 18 Abs. 2 MStV",
|
||||
):
|
||||
with self.subTest(value=value):
|
||||
self.assertIn(value, text)
|
||||
|
||||
def test_datenschutz_covers_the_actual_data_flows(self):
|
||||
text = visible_text(read(SITE / "datenschutz" / "index.html"))
|
||||
for needle in (
|
||||
"keine Zugriffsprotokolle",
|
||||
"keine Cookies",
|
||||
"youtube-nocookie.com",
|
||||
"Art. 6 Abs. 1 lit. f DSGVO",
|
||||
LEGAL["authority"],
|
||||
"Art. 22 DSGVO",
|
||||
):
|
||||
with self.subTest(needle=needle):
|
||||
self.assertIn(needle, text)
|
||||
|
||||
def test_datenschutz_matches_the_log_free_server_configuration(self):
|
||||
"""The page must describe `error_log /dev/null`, not a retention period."""
|
||||
text = visible_text(read(SITE / "datenschutz" / "index.html"))
|
||||
self.assertIn("Fehlerprotokoll wird nicht geschrieben", text)
|
||||
self.assertNotIn("14 Tagen gelöscht", text)
|
||||
conf = read(ROOT / "nginx.conf")
|
||||
self.assertIn("error_log /dev/null crit;", conf)
|
||||
|
||||
def test_site_javascript_is_local_and_stateless(self):
|
||||
js = read(SITE / "assets" / "site.js")
|
||||
for forbidden in (
|
||||
"document.cookie",
|
||||
"localStorage",
|
||||
"sessionStorage",
|
||||
"indexedDB",
|
||||
"navigator.sendBeacon",
|
||||
"XMLHttpRequest",
|
||||
"fetch(",
|
||||
"WebSocket",
|
||||
):
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertNotIn(forbidden, js)
|
||||
|
||||
def test_contact_form_sends_nothing_by_default(self):
|
||||
text = read(SITE / "kontakt" / "index.html")
|
||||
self.assertIn("data-contact-form", text)
|
||||
self.assertIn('action="mailto:info@dennyschulz.de"', text)
|
||||
self.assertNotIn('method="post" action="/', text)
|
||||
self.assertIn("sendet nichts an einen Server", visible_text(text))
|
||||
|
||||
def test_https_links_and_no_plain_http(self):
|
||||
for file in HTML_FILES:
|
||||
with self.subTest(file=file.name):
|
||||
self.assertNotIn("http://desfoto.de", read(file))
|
||||
|
||||
|
||||
class Content(unittest.TestCase):
|
||||
def test_all_requested_services_are_reachable(self):
|
||||
home = read(SITE / "index.html")
|
||||
for href in (
|
||||
"/businessfotografie/",
|
||||
"/familien-und-paare/",
|
||||
"/minishootings/",
|
||||
"/projekte/",
|
||||
"/video/",
|
||||
"/social-media/",
|
||||
):
|
||||
with self.subTest(href=href):
|
||||
self.assertIn(f'href="{href}"', home)
|
||||
|
||||
def test_pro_bono_work_is_offered(self):
|
||||
projekte = read(SITE / "projekte" / "index.html")
|
||||
self.assertIn("Pro bono", projekte)
|
||||
self.assertIn("Pro-bono-Anfrage senden", projekte)
|
||||
kontakt = read(SITE / "kontakt" / "index.html")
|
||||
self.assertIn("Pro-bono-Anfrage", kontakt)
|
||||
|
||||
def test_music_video_is_embedded_and_only_after_a_click(self):
|
||||
text = read(SITE / "video" / "index.html")
|
||||
self.assertIn("NWWFTf7l8g0", text)
|
||||
self.assertIn("youtube-nocookie.com", read(SITE / "assets" / "site.js"))
|
||||
self.assertNotIn("youtube.com/embed", text)
|
||||
|
||||
def test_no_placeholder_or_unfinished_text(self):
|
||||
markers = ("lorem ipsum", "todo", "tbd", "xxx", "platzhalter", "coming soon")
|
||||
for file in HTML_FILES:
|
||||
lower = read(file).lower()
|
||||
for marker in markers:
|
||||
with self.subTest(file=file.name, marker=marker):
|
||||
self.assertNotIn(marker, lower)
|
||||
|
||||
def test_footer_on_every_page(self):
|
||||
for file in HTML_FILES:
|
||||
text = read(file)
|
||||
with self.subTest(file=file.name):
|
||||
self.assertIn('href="/impressum/"', text)
|
||||
self.assertIn('href="/datenschutz/"', text)
|
||||
self.assertIn(CONTACT["email"], text)
|
||||
|
||||
def test_sitemap_matches_routes(self):
|
||||
sitemap = read(SITE / "sitemap.xml")
|
||||
for path in ROUTE_PATHS:
|
||||
with self.subTest(route=path):
|
||||
self.assertIn(f"<loc>{META['url']}{path}</loc>", sitemap)
|
||||
self.assertNotIn("404", sitemap)
|
||||
|
||||
def test_robots_allows_crawling(self):
|
||||
self.assertIn("User-agent: *", read(SITE / "robots.txt"))
|
||||
self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt"))
|
||||
|
||||
|
||||
class Release(unittest.TestCase):
|
||||
def test_deploy_removes_only_targeted_desfoto_labels(self):
|
||||
deploy = read(ROOT / ".ocauto" / "deploy")
|
||||
self.assertNotIn('"desfoto" not in line', deploy)
|
||||
self.assertNotIn("sudo python3", deploy)
|
||||
self.assertIn("traefik\\..*desfoto", deploy)
|
||||
self.assertIn("stack-backup", deploy)
|
||||
|
||||
def test_deploy_validates_before_replacing_the_shared_stack(self):
|
||||
deploy = read(ROOT / ".ocauto" / "deploy")
|
||||
self.assertIn('config --quiet', deploy)
|
||||
self.assertIn('mktemp "$STACK_DIR/', deploy)
|
||||
self.assertIn('sudo mv "$stack_tmp" "$stack_file"', deploy)
|
||||
|
||||
def test_deploy_hooks_are_executable(self):
|
||||
import os
|
||||
|
||||
for name in ("qa", "deploy", "verify"):
|
||||
with self.subTest(hook=name):
|
||||
path = ROOT / ".ocauto" / name
|
||||
self.assertTrue(path.is_file(), f"missing .ocauto/{name}")
|
||||
self.assertTrue(os.access(path, os.X_OK), f".ocauto/{name} is not executable")
|
||||
|
||||
|
||||
class Fonts(unittest.TestCase):
|
||||
def test_fonts_are_self_hosted(self):
|
||||
css = read(SITE / "assets" / "fonts.css")
|
||||
self.assertIn("@font-face", css)
|
||||
self.assertIn("font-display:swap", css)
|
||||
urls = re.findall(r"url\('([^']+)'\)", css)
|
||||
self.assertTrue(urls)
|
||||
for url in urls:
|
||||
with self.subTest(url=url):
|
||||
self.assertTrue(url.startswith("/assets/fonts/"))
|
||||
self.assertTrue((SITE / url.lstrip("/")).is_file())
|
||||
|
||||
def test_licences_shipped(self):
|
||||
for name in ("font-fraunces-OFL.txt", "font-manrope-OFL.txt"):
|
||||
with self.subTest(name=name):
|
||||
self.assertTrue((ROOT / "docs" / name).is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Reference in New Issue
Block a user