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

15 KiB
Raw Blame History

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> 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:

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.
  • 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).