Ships the cross-cutting hardening set: - SecurityHeadersMiddleware: per-request nonce-based CSP, HSTS (production only), Referrer-Policy, Permissions-Policy, X-Content-Type-Options, frame-ancestors 'none', form-action 'self'. - AccessLogMiddleware: one http_request INFO event per request (method/path/status/duration_ms/ip/ua). Skips /healthz, redacts /admin/auth/consume/<token> paths, logs 500 + re-raises on downstream exceptions. - Public base.html inline nav-toggle script gets a nonce so it passes strict CSP without relaxing to 'unsafe-inline'. - Dockerfile: non-root app user (uid/gid 10001) + stdlib-only HEALTHCHECK against /healthz. - scripts/backup.sh: sqlite3 .backup + tar data/media with 14-entry retention; host-side cron install documented. - .gitea/workflows/build-image.yml: on push to master / workflow_dispatch, builds and publishes git.sneakygeek.net/ptarrant/chicken_babies_site:latest + sha-<short>, with GIT_COMMIT_SHA threaded as a build-arg so /healthz keeps reporting the right commit in deployed images. - 8 new tests (security headers + access log). Pre-existing dev failures (logo asset rename + RESEND env pollution) remain unchanged; verified not Phase 6 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
15 KiB
Manual Testing Checklist
Living document. Each phase appends its own section — do not delete older sections when the code behind them changes; mark items as superseded instead so the audit trail stays intact.
Run the site locally before walking through the list:
source venv/bin/activate
uvicorn app.main:app --reload
Then open http://127.0.0.1:8000/ in a real browser (not just curl).
Phase 1 — Public Site Skeleton
Home (/)
- Page returns 200 and renders without console errors.
- Header shows the Chicken Babies R Us logo at ~48px tall.
<img>altattribute reads Chicken Babies R Us.- Nav items appear in order: Home · About · Contact · Shop.
- "Home" is visibly the active nav link and carries
aria-current="page". - "Shop" nav link is visually muted (lower contrast) but still clickable.
- Page intro ("Welcome to Chicken Babies R Us") is present.
- With no posts in the DB, the empty-state reads "No posts yet — check back soon!".
- Footer shows "Chicken Babies R Us · Morrison, Tennessee".
- No street address is visible anywhere on the page (CLAUDE.md constraint).
About (/about)
- Page returns 200 and renders without console errors.
- H1 reads "About the farm".
- Copy mentions Morrison, Tennessee by name.
- Copy name-checks Head Hen.
- No street address appears anywhere.
- Nav marks "About" as active (
aria-current="page").
Contact (/contact)
- Page returns 200 and renders without console errors.
- H1 reads "Get in touch".
- When
ADMIN_CONTACT_EMAILis set, amailto:link renders above the form. - When
ADMIN_CONTACT_EMAILis unset, the muted placeholder sentence appears and nomailto:link renders. - The note "Secure contact form coming soon" is visible.
- Form fields (name, email, message) are visually disabled and cannot be typed into.
- "Send message" button is visually disabled.
- Form has no
method="POST"attribute (view source). - Nav marks "Contact" as active.
Shop (/shop)
- Page returns 200 and renders without console errors.
- H1 reads "Shop".
- "Coming soon" card is visible with mention of eggs, chicks, and waterfowl.
- Nav marks "Shop" as active.
Responsive
Use the browser devtools responsive toolbar.
- 360 × 800 (mobile): nav collapses behind a hamburger toggle; toggle opens/closes on click; logo remains legible; no horizontal scroll.
- 768 × 1024 (tablet): nav appears inline; layout uses full container width; no horizontal scroll.
- 1280 × 800 (desktop): content capped at
--max-width(68rem ≈ 1088px); generous whitespace either side.
Accessibility
- Tab-key order from top of page: skip link → logo → nav links → main content.
- Pressing Tab from a cold page load reveals the skip link in the top-left corner.
- Activating the skip link jumps focus into
<main>. - Logo has a non-empty
altattribute ("Chicken Babies R Us"). - Navigating with a screen reader announces each landmark (
header,nav,main,footer). - Spot-check color contrast of
--c-ink(#2B3A42) on--c-cream(#FAF3E7) — should be comfortably above WCAG AA for body text.
Assets
/static/img/logo.pngloads and is roughly 256px tall./static/img/logo.webploads with content-typeimage/webp./static/img/favicon.icois requested by the browser and returns 200./static/img/apple-touch-icon.pngis 180×180 and has a cream (#FAF3E7) background.
Ops smoke
pytest -qpasses locally.python -c "from app.main import app"exits cleanly.python scripts/generate_static_assets.pyregenerates the four asset files without error.docker compose configstill parses cleanly.
Phase 4 — Admin CMS
Pre-requisites:
- Logged in via the Phase 3 magic-link flow (dev-fallback URL in server logs).
- Landed on the Phase 4 dashboard at
/admin.
Dashboard (/admin)
- Page returns 200.
- Page title reads "Dashboard".
- Signed-in email appears in the greeting.
- Posts table lists the seeded "Welcome to the Farm" row with status Published.
- Each row shows Edit / Publish-or-Unpublish / Delete buttons.
- Delete click triggers a confirmation dialog.
- "New post" button is visible and links to
/admin/posts/new. - "Edit About" button links to
/admin/pages/about/edit. <meta name="csrf-token">is present in the rendered HTML (view source).- The
cb_csrfcookie is set withSameSite=Lax,HttpOnly=false(readable by JS).
Create a post (/admin/posts/new)
- Form renders without errors.
- Title + status + body fields visible; preview pane on the right.
- Drop zone is visible below the textarea with prompt copy.
- Typing in the textarea causes the preview to update within ~300ms.
- Dragging a JPG / PNG / WebP image onto the drop zone uploads it; a Markdown image tag is inserted at the cursor.
- Dropping a GIF, plain text file, or anything >8 MB triggers the
.is-errorstate on the drop zone. - Submitting with a blank title re-renders with "Title is required." and preserves other fields.
- Submitting a valid form 303-redirects to
/admin?msg=created.
Edit a post (/admin/posts/{id}/edit)
- Form is pre-populated with the post's current title + body.
- Slug is rendered read-only below the title.
- For a published post, the UI notes the slug is locked.
- Saving updates the row; public
/reflects the change immediately (no caching delay).
Publish / unpublish / delete
- Publish button on a draft row flips the status to published and shows a "published" flash on the next dashboard load.
- Unpublish button on a published row flips the status back to draft;
published_atis preserved in the DB (check viasqlite3 data/app.db). - Delete button on any row removes it entirely after confirmation.
About page edit (/admin/pages/about/edit)
- Form renders with the current About title and body.
- There is no slug editor.
- Saving updates the row and the public
/aboutpage on next load.
Media upload
data/media/<yyyy>/<mm>/is created lazily the first time an image is saved.- Uploaded files are stored under a random filename ending in
.jpgregardless of the source format. - Hitting the
/media/<yyyy>/<mm>/<name>.jpgURL directly serves the image at HTTP 200. - An uploaded transparent PNG comes through as an RGB JPEG (transparent areas become white).
- Uploading an animated GIF is rejected with a generic error.
- Uploading a >8 MB file is rejected.
- Uploading while the
X-CSRF-Tokenheader is missing returns 403.
CSRF
- Admin POST routes (
/admin/logout,/admin/posts,/admin/posts/*/delete,/admin/posts/*/publish,/admin/pages/about,/admin/media/upload,/admin/preview) all return 403 when the submitted token does not match the cookie. - A legitimate form submission succeeds because both the cookie and the form field were issued during the previous GET.
Public site regression
/shows only published posts (newly created drafts do NOT appear).- A newly-published post shows at the top of
/within one request. /aboutshows the most recently edited copy.- No admin-facing text (status, dashboard wording) leaks into the public HTML.
Phase 5 — Contact Form
Pre-requisites:
ADMIN_CONTACT_EMAILset in.env(the destination inbox).- For the production-like happy path:
RESEND_API_KEY+RESEND_FROMset; otherwise the send path logscontact_notification_dev_fallbackand the admin inbox will not actually receive mail. - Optionally set
HCAPTCHA_SITE_KEY+HCAPTCHA_SECRETto exercise the real widget; with both unset the dev fallback auto-passes and logshcaptcha_dev_fallback.
GET /contact
- Page returns 200 and renders without console errors.
- H1 reads "Get in touch".
- Name, email, and message fields render as editable inputs (no
disabledattribute). - "Send message" button is enabled.
- Form has
method="POST"andaction="/contact"(view source). - Honeypot
<input name="website">is present in the markup but wrapped in a.visually-hiddencontainer markedaria-hidden="true"— it is invisible to sighted users. - When
HCAPTCHA_SITE_KEYis set, theh-captchadiv and thehttps://js.hcaptcha.com/1/api.jsscript appear. When unset, neither appears. - Nav marks "Contact" as active.
Happy path
- Fill in Name, Email, Message (>= 10 chars) and submit.
- Response is HTTP 200 and renders "Thanks for reaching out".
sqlite3 data/app.db "SELECT id, name, email, length(message), handled FROM contact_submissions"shows the new row withhandled=0.- Server log contains a
contact_submittedstructured event with amessage_previewat most 40 chars long (no full body). - With
RESEND_API_KEYset: admin inbox receives the notification email.From:matchesRESEND_FROM;Reply-To:matches the submitted email; subject isNew contact submission from {name}. - Without
RESEND_API_KEY: server log containscontact_notification_dev_fallbackwith the submitter's name, email, and message length.
Validation errors
- Submitting with a blank name shows "Please enter your name." inline.
- Submitting with
not-an-emailshows "Please enter a valid email address.". - Submitting with a 9-character message shows "Message must be at least 10 characters.".
- Submitting with a > 4000-character message shows "Message must be 4000 characters or fewer.".
- Submitting with a > 80-character name shows "Name must be 80 characters or fewer.".
- The response status code on every validation failure is 400.
- Prior valid values remain filled in so the user doesn't retype.
Spam paths
- Filling the honeypot
websitefield and submitting returns "Thanks for reaching out" (same as success) AND no row is persisted AND an audit row withevent_type='contact_spam_rejected'andreason=honeypotexists. - With a real hCaptcha configured: submitting without solving the
widget returns the same generic thank-you page. Audit row:
contact_spam_rejected/reason=hcaptcha. No DB row.
Rate limit
- Submit the form 4 times from the same browser session within
an hour. The fourth submission returns HTTP 429 and renders
the "Too many attempts" template. A
rate_limitedaudit row is added withscope=ipandendpoint=/contact.
Email send failure
- With a valid form but
RESEND_API_KEYpointed at an invalid key: the user still sees "Thanks for reaching out", the DB row is created, and the server log containscontact_notification_failed(logged by EmailService). The user experience is indistinguishable from success.
Ops smoke
pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.pypasses.python -c "from app.main import app; print(len(app.routes))"prints a count greater than the Phase 4 count (the newPOST /contactadds one route).
Phase 6 — Hardening + Deploy
Run a dev server alongside these checks:
source venv/bin/activate
uvicorn app.main:app --reload
Security headers (/, /contact, /admin/login)
curl -sI http://127.0.0.1:8000/ | grep -i content-security-policyreturns a policy containingnonce-<...>,frame-ancestors 'none',form-action 'self', andhttps://js.hcaptcha.cominscript-src.- Two consecutive
curl -sI /calls print differentnonce-<...>values — the middleware mints one per request. X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Cross-Origin-Opener-Policy: same-origin, and aPermissions-Policydisabling camera / geolocation / microphone are all present on the response.- In development,
Strict-Transport-Securityis absent (so localhost over HTTP keeps working). - Set
APP_ENV=production+ all prod-required env vars and restart; confirmStrict-Transport-Security: max-age=31536000; includeSubDomainsnow appears. - Browser devtools console on
/shows the inline nav-toggle script executes (the mobile hamburger still toggles the menu). No "Refused to execute inline script" CSP violations appear. - On
/contact, the hCaptcha widget renders and its API script loads (no CSP violations in the console). - On any admin page,
admin_editor.jsloads and the live preview still updates as you type.
Access log (/healthz quiet, magic-link redacted)
- Normal page loads emit one
http_requestline withmethod,path,status_code,duration_ms,client_ip, anduser_agentfields. curl http://127.0.0.1:8000/healthzdoes not produce anhttp_requestlog line (the middleware skips/healthz).- Request a magic link, then hit the consume URL in the browser.
The server log shows
path=/admin/auth/consume/<redacted>— the raw token never appears in stdout.
Dockerfile hardening
docker compose buildsucceeds.docker compose up -dbrings the service to healthy; inspect withdocker compose ps— the STATUS column reads(healthy)within ~30s of startup.docker compose exec app idreportsuid=10001(app) gid=10001(app).docker inspect --format '{{.State.Health.Status}}' <container>returnshealthy.
Backup script
./scripts/backup.shexits 0 and producesdata/backups/app-<ts>.dbanddata/backups/media-<ts>.tar.gz.sqlite3 data/backups/app-<ts>.db "SELECT COUNT(*) FROM posts"returns the same count as the live DB.- Run the script 15 times in a loop; after the 15th run, exactly
14
app-*.dband 14media-*.tar.gzartifacts remain indata/backups/(newest kept, older pruned). - Install the cron entry on the VM (example):
15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh >> /var/log/chicken_babies/backup.log 2>&1.
Gitea Actions workflow
- Push a commit to
masteron Gitea and confirm theBuild and Push Docker Imageworkflow runs to green. git.sneakygeek.net/ptarrant/chicken_babies_site:latestand:sha-<short>tags exist in the registry afterwards.- Pulling the
:latestimage and hitting/healthzreports the expectedcommit_sha(matches the built commit).
Ops smoke
pytest -qpasses with no new failures beyond the known pre-existing two (test_production_missing_key_refuses_startup,test_layout_includes_logo_image).python -c "from app.main import app; print(len(app.routes))"prints the same route count as before Phase 6 (no new routes).