Files
chicken_babies_site/docs/MANUAL_TESTING.md
Phillip Tarrant f9f90d408e chore: phase 6 hardening — CSP/HSTS, access log, docker, backup, CI
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>
2026-04-22 07:38:23 -05:00

335 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```bash
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>` `alt` attribute 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_EMAIL` is set, a `mailto:` link renders above the form.
- [ ] When `ADMIN_CONTACT_EMAIL` is unset, the muted placeholder sentence appears and no `mailto:` 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 `alt` attribute ("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.png` loads and is roughly 256px tall.
- [ ] `/static/img/logo.webp` loads with content-type `image/webp`.
- [ ] `/static/img/favicon.ico` is requested by the browser and returns 200.
- [ ] `/static/img/apple-touch-icon.png` is 180×180 and has a cream (#FAF3E7) background.
### Ops smoke
- [ ] `pytest -q` passes locally.
- [ ] `python -c "from app.main import app"` exits cleanly.
- [ ] `python scripts/generate_static_assets.py` regenerates the four asset files without error.
- [ ] `docker compose config` still 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_csrf` cookie is set with `SameSite=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-error` state 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_at` is preserved in the DB (check via `sqlite3 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 `/about` page 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 `.jpg` regardless of the source format.
- [ ] Hitting the `/media/<yyyy>/<mm>/<name>.jpg` URL 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-Token` header 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.
- [ ] `/about` shows 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_EMAIL` set in `.env` (the destination inbox).
- For the production-like happy path: `RESEND_API_KEY` + `RESEND_FROM`
set; otherwise the send path logs `contact_notification_dev_fallback`
and the admin inbox will not actually receive mail.
- Optionally set `HCAPTCHA_SITE_KEY` + `HCAPTCHA_SECRET` to exercise
the real widget; with both unset the dev fallback auto-passes and
logs `hcaptcha_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 `disabled` attribute).
- [ ] "Send message" button is enabled.
- [ ] Form has `method="POST"` and `action="/contact"` (view source).
- [ ] Honeypot `<input name="website">` is present in the markup but
wrapped in a `.visually-hidden` container marked
`aria-hidden="true"` — it is invisible to sighted users.
- [ ] When `HCAPTCHA_SITE_KEY` is set, the `h-captcha` div and the
`https://js.hcaptcha.com/1/api.js` script 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 with `handled=0`.
- [ ] Server log contains a `contact_submitted` structured event with a
`message_preview` at most 40 chars long (no full body).
- [ ] With `RESEND_API_KEY` set: admin inbox receives the notification
email. `From:` matches `RESEND_FROM`; `Reply-To:` matches the
submitted email; subject is `New contact submission from {name}`.
- [ ] Without `RESEND_API_KEY`: server log contains
`contact_notification_dev_fallback` with 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-email` shows **"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 `website` field and submitting returns
**"Thanks for reaching out"** (same as success) AND no row is
persisted AND an audit row with
`event_type='contact_spam_rejected'` and `reason=honeypot` exists.
- [ ] 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_limited` audit row is
added with `scope=ip` and `endpoint=/contact`.
### Email send failure
- [ ] With a valid form but `RESEND_API_KEY` pointed at an invalid key:
the user still sees **"Thanks for reaching out"**, the DB row is
created, and the server log contains
`contact_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.py` passes.
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).
---
## Phase 6 — Hardening + Deploy
Run a dev server alongside these checks:
```bash
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-policy`
returns a policy containing `nonce-<...>`, `frame-ancestors 'none'`,
`form-action 'self'`, and `https://js.hcaptcha.com` in `script-src`.
- [ ] Two consecutive `curl -sI /` calls print **different** `nonce-<...>`
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 a `Permissions-Policy` disabling camera /
geolocation / microphone are all present on the response.
- [ ] In development, `Strict-Transport-Security` is **absent** (so
localhost over HTTP keeps working).
- [ ] Set `APP_ENV=production` + all prod-required env vars and restart;
confirm `Strict-Transport-Security: max-age=31536000;
includeSubDomains` now 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.js` loads and the live preview
still updates as you type.
### Access log (`/healthz` quiet, magic-link redacted)
- [ ] Normal page loads emit one `http_request` line with `method`,
`path`, `status_code`, `duration_ms`, `client_ip`, and
`user_agent` fields.
- [ ] `curl http://127.0.0.1:8000/healthz` does **not** produce an
`http_request` log 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 build` succeeds.
- [ ] `docker compose up -d` brings the service to healthy; inspect
with `docker compose ps` — the STATUS column reads `(healthy)`
within ~30s of startup.
- [ ] `docker compose exec app id` reports `uid=10001(app) gid=10001(app)`.
- [ ] `docker inspect --format '{{.State.Health.Status}}' <container>`
returns `healthy`.
### Backup script
- [ ] `./scripts/backup.sh` exits 0 and produces
`data/backups/app-<ts>.db` and `data/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-*.db` and 14 `media-*.tar.gz` artifacts remain in
`data/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 `master` on Gitea and confirm the
`Build and Push Docker Image` workflow runs to green.
- [ ] `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` and
`:sha-<short>` tags exist in the registry afterwards.
- [ ] Pulling the `:latest` image and hitting `/healthz` reports the
expected `commit_sha` (matches the built commit).
### Ops smoke
- [ ] `pytest -q` passes 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).