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>
335 lines
15 KiB
Markdown
335 lines
15 KiB
Markdown
# 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).
|