Files
chicken_babies_site/docs/MANUAL_TESTING.md
Phillip Tarrant d9090f5055 feat: phase 5 contact form — hCaptcha, honeypot, rate limit, notify
Working /contact POST flow: honeypot → hCaptcha server-verify →
field validation → SlowAPI 3/hr IP rate limit → contact_submissions
row → best-effort Resend notification (Reply-To = submitter) →
generic success page. Spam paths don't persist and render the same
success page (anti-enumeration). Send failures don't break the
request path — the row is already durable.

New services: HCaptchaService (async httpx + dev fallback),
ContactService. EmailService gains send_contact_notification.
Production config validator now requires ADMIN_CONTACT_EMAIL,
HCAPTCHA_SECRET, HCAPTCHA_SITE_KEY. 23 new tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:47:06 -05:00

249 lines
11 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).