Forms — QA Plan
Status: This feature is heading into QA. The public signing surface (FIT-178) and the member in-app signing UI are the highest-risk areas. The PDF render pipeline and the auto-issue side-effect have the most surprising failure modes.
Environments: Staging mirrors production. Set R2_COMPLIANCE_BUCKET_NAME to a distinct staging bucket so QA artifacts don’t pollute prod retention.
Personas: Use usePersona('coach-il'), usePersona('member-il-adult'), usePersona('member-il-minor'), usePersona('parent'), plus an unauthenticated browser context for the token flow.
Locales: Every signing test must run at least once in he (RTL) and ideally also in en (LTR fallback). The Hebrew rendering of the PDF is regulatory-critical.
1. Template lifecycle (staff web)
| # | Scenario | Expected |
|---|---|---|
| 1.1 | Coach creates a draft compliance form with the health_declaration preset | POST /templates returns 201, row appears in list with Draft badge |
| 1.2 | Coach patches the draft (rename, add field, edit field) | All edits persist; row remains Draft; version=1 |
| 1.3 | Coach tries to publish a compliance template with no signature field | 400 with i18n key forms.publishWarning.signatureRequired (Hebrew “חתימה” required) |
| 1.4 | Coach adds a signature field and publishes | 200; publishedAt populated; row shows Published badge |
| 1.5 | Coach tries to patch the published row | 409 Conflict; UI shows “Already published — use Publish new version” |
| 1.6 | Coach toggles auto-issue-on-join ON | PATCH succeeds; no new version row created; flag persists; new members start receiving the form |
| 1.7 | Coach toggles auto-issue OFF | Flag flips; existing pending instances are NOT recalled |
| 1.8 | Coach bumps version with a field change | New row with version=2, publishedAt=now; v1 row untouched |
| 1.9 | Coach archives v2 | archivedAt set; in-flight instances still resolve; new assigns blocked |
| 1.10 | Online org coach opens /dashboard/forms | Compliance create UI hidden / disabled; direct POST /templates with kind=compliance returns 403 with copy “Compliance forms are not available for online organizations” |
| 1.11 | Coach with member role tries to access /dashboard/forms | 403 |
| 1.12 | Coach creates a typeKey not in the six allowed (e.g. pizza_order) with kind=compliance | 400 with the enum list in the message body |
| 1.13 | Coach creates a check-in template with validityPeriodDays set | 400 — assertKindShape rejects |
2. Assignment (single + bulk + auto-issue)
| # | Scenario | Expected |
|---|---|---|
| 2.1 | Coach assigns the form to one member | 201 with data.id; member sees Pending signature in member-forms-tab; assignedByUserId is the coach |
| 2.2 | Coach loads coverage preview | missingCount matches the count of active role=member memberships lacking a valid signed typeKey |
| 2.3 | Coach hits Send to N members | 201 with { issuedCount, skippedCount }; refresh shows zero missing |
| 2.4 | Coach hits Send to N members again immediately | 201 with issuedCount=0, skippedCount=N; no duplicate form_instances rows in DB (verify via DB query or staff UI) |
| 2.5 | A new member signs up while auto-issue-on-join=true | After membership activation, the member’s GET /forms/mine returns the pending instance |
| 2.6 | Auto-issue fails (force a transient DB error) | Membership activation succeeds; FormsService logs the error; member onboarding does not block. Verify in API logs |
| 2.7 | Coach (role) signs up in an org with auto-issue on | No instance is created for the coach (only role=member activations trigger fan-out) |
| 2.8 | Coach assigns to a member who already has a pending v1 and the template is now v2 | New instance for v2 is created — the v1 pending is NOT recalled (gap to confirm with product before QA pass) |
3. Authenticated in-app signing (default path — pending UI FIT-178)
These tests run against the mobile app simulator OR the web /forms/mine page once shipped. Until the UI exists, run API-only assertions via Postman / a script.
| # | Scenario | Expected |
|---|---|---|
| 3.1 | Member loads /forms/instances/{id} | { instance, form } returned; openedAt set on first call only |
| 3.2 | Member submits answers + signature r2Key | 200; form_signatures row inserted; form_instances.status='signed'; expiresAt = signedAt + validityPeriodDays |
| 3.3 | Member submits the same instance twice | First succeeds; second returns 409 (canTransitionCompliance('signed','signed') false) |
| 3.4 | Member submits another member’s instance | 403 — Only the assignee can sign this form |
| 3.5 | Staff tries POST /instances/{id}/submit with a member’s instance ID | 403 — staff cannot sign on a member’s behalf |
| 3.6 | Member submits with missing required field | 400; error body lists field IDs |
| 3.7 | Member submits with no signature r2Key in answers | 400 — “Signature image r2Key required in answers” |
| 3.8 | Signature PNG upload succeeds but signature r2Key references a different member’s path | Should fail — the staged-upload service validates ownership. Verify via path injection attempt |
4. Token-gated signing (parental consent, WhatsApp distribution)
| # | Scenario | Expected |
|---|---|---|
| 4.1 | Coach generates a signing link for a pending instance | 201 with { token: 64-hex, expiresAt: now+7d }; DB shows signing_token set, expires_at = now+7d |
| 4.2 | Coach generates a link for a signed instance | 409 — “Cannot mint a link for an instance in status ‘signed‘“ |
| 4.3 | Coach generates a link for an archived instance | 409 |
| 4.4 | Unauthenticated browser opens the public URL /{lang}/forms/sign/{token} | Server-side fetch via GET /forms/sign/{token} succeeds; page renders Hebrew RTL form |
| 4.5 | Same URL opened a second time | Page renders again; openedAt is unchanged on the second call (set-once) |
| 4.6 | Browser tampers token (replace 1 char) | 404 from GET /forms/sign/{token} |
| 4.7 | Coach mints; clock moved to T+8d (or set expiresAt to past via DB); browser opens | 410 Gone from GET /forms/sign/{token} |
| 4.8 | Unauthenticated signer uploads PNG via POST /forms/sign/{token}/signature-upload | 200 with { uploadUrl, r2Key, expiresInSeconds: 300 }; the r2Key matches {orgId}/forms/signatures/{instanceId}/{ts}.png |
| 4.9 | Unauthenticated signer attempts to PUT a non-PNG (e.g. image/jpeg) to the presigned URL | R2 rejects with 403 because content-type mismatch; verify in network tab |
| 4.10 | Unauthenticated signer submits with the token | 200; PDF rendered; form_instances.signing_token is NULLed; status signed |
| 4.11 | Signer hits the URL again after successful submit | 404 — token is burned |
| 4.12 | Two browsers race to submit the same token | One succeeds with 200; the other gets 404 (token already burned) or 409 (status=‘signed’) depending on order |
5. PDF rendering (FIT-158 — regulatory-critical)
Run each of these and open the resulting PDF in a viewer that handles Hebrew RTL well (Acrobat, Preview). Visually inspect every render.
| # | Scenario | Expected |
|---|---|---|
| 5.1 | Health declaration signed by IL member, locale=he | Title in Hebrew, RTL layout, table headers right-aligned, signature image embedded, audit footer in Hebrew (מסמך זה נחתם אלקטרונית באמצעות מערכת Fitkit), version + typeKey + date in Hebrew long format |
| 5.2 | Same template signed with locale=en | LTR, English audit footer |
| 5.3 | Org has a logo URL | Logo appears at the top, 48×48, before the org name (or after, depending on dir) |
| 5.4 | Org has no logo URL | Header still renders cleanly; only org name |
| 5.5 | Body richtext contains paragraph breaks | Preserved via white-space: pre-wrap |
| 5.6 | Body richtext contains HTML | Escaped — verify < and > render as text, no XSS executed |
| 5.7 | Field labels contain HTML | Escaped |
| 5.8 | Hebrew text mixed with English / numbers | Bidi rendering correct — no reversed digits in Hebrew dates |
| 5.9 | Long body causing page overflow | Auto-paginates; signature block stays attached to its label (no orphan signature on next page — visually inspect) |
| 5.10 | validityPeriodDays=null template | Footer prints “Token” + audit but no expiry; form_instances.expiresAt is NULL after sign |
| 5.11 | Special chars in name (apostrophes, quotes, Hebrew nikud) | Escaped, not mangled |
| 5.12 | Compute SHA-256 of the downloaded bytes | Matches form_signatures.pdf_checksum_sha256 exactly |
| 5.13 | Re-download PDF 30 days later (cached presign) | Same URL still works until TTL expires; bytes identical |
| 5.14 | Puppeteer process crash mid-render (kill the chromium PID) | Submit returns 500; instance stays pending; retry succeeds |
6. Auto-issue on join (event-driven)
| # | Scenario | Expected |
|---|---|---|
| 6.1 | Org has zero auto-issue templates; new member activated | No instances created |
| 6.2 | Org has 3 auto-issue templates (different typeKeys); new member activated | 3 pending instances, one per typeKey, pinned to the latest version of each |
| 6.3 | Same as 6.2; the event is replayed | No duplicates — coveredTypeKeys set dedupes |
| 6.4 | Member already has a valid signed instance for health_declaration; org adds a new auto-issue template for health_declaration v2; member is reactivated | v2 NOT issued (existing valid signed covers it). When v1 expires, next reconcile picks up v2 — verify via assignAllMissing |
| 6.5 | Auto-issue runs while org type is online | Returns silently without creating instances |
| 6.6 | Multiple auto-issue templates for the same typeKey at different versions | Only the highest version is issued (latestPerType map) |
| 6.7 | EventEmitter throws inside the listener | Caught and logged; original MEMBERSHIP_ACTIVATED flow completes |
7. Permissions boundaries
| # | Scenario | Expected |
|---|---|---|
| 7.1 | Member calls GET /templates | 403 — staff required |
| 7.2 | Member of org A calls GET /organizations/{orgB}/forms/mine | 403 (membership lookup fails for org B) |
| 7.3 | Coach of org A calls GET /organizations/{orgB}/forms/templates | 403 |
| 7.4 | Coach of org A loads a template by ID belonging to org B | 404 from getTemplateScoped (does NOT leak existence cross-org) |
| 7.5 | Member calls GET /forms/instances/{otherMembersInstanceId} (in same org) | 403 — not assignee + not staff |
| 7.6 | Public route accessed with a valid Clerk session but no token | 404 / 400 from getByToken (“Invalid token”) |
8. Multi-org isolation
| # | Scenario | Expected |
|---|---|---|
| 8.1 | Two orgs both have health_declaration v1 | Both rows exist independently with distinct UUIDs |
| 8.2 | Member belongs to both orgs | GET /forms/mine returns only the active org’s instances (route is org-scoped) |
| 8.3 | Cross-org signing_token collision | Impossible — token is globally unique (form_instances_signing_token_uq) |
| 8.4 | Org A signs a form, then is deleted | CASCADE wipes forms, form_instances, form_signatures for that org. Document this as a known risk for QA sign-off — bucket-level immutability is not yet provisioned (FIT-158) |
9. Audit trail (today’s shape, not the full FIT-20 surface)
| # | Scenario | Expected |
|---|---|---|
| 9.1 | Sign while behind a single proxy with X-Forwarded-For: 1.2.3.4 | form_signatures.ip_address = '1.2.3.4' |
| 9.2 | Sign with multi-hop XFF 1.2.3.4, 5.6.7.8 | First hop 1.2.3.4 stored |
| 9.3 | Sign over IPv6 | Full IPv6 in DB (45-char cap fits) and PDF |
| 9.4 | Sign with no proxy (direct socket) | req.socket.remoteAddress recorded |
| 9.5 | Sign with empty UA | DB column NULL; PDF footer renders — |
| 9.6 | PDF footer shows: token id (first 16 chars), IP, UA (truncated to 200), signed at UTC, instance id | Visually verify on a generated PDF |
| 9.7 | Same SHA-256 from a re-download of the PDF as in pdf_checksum_sha256 | Bit-identical |
10. RTL Hebrew rendering (web staff UI)
| # | Scenario | Expected |
|---|---|---|
| 10.1 | Switch dashboard to he locale | All forms.* labels render in Hebrew; layout flips to RTL where appropriate |
| 10.2 | Form builder with Hebrew labels | Field labels saved verbatim; preview renders RTL |
| 10.3 | Template detail panel in Hebrew | ”Auto-issue”, “Generate link”, “Send to N members” all translate |
| 10.4 | Generated signing-link URL | Path includes /{lang}/forms/sign/... — lang segment preserves the locale parent’s choice |
11. Mobile-specific (when FIT-178 ships)
| # | Scenario | Expected |
|---|---|---|
| 11.1 | Member receives push for “New compliance form” | (when wired) deep-links to the instance |
| 11.2 | Signature drawn on phone canvas | Smooth strokes, no jitter; PNG uploaded successfully |
| 11.3 | Submit while offline | Queued by mobile cache layer; retried on reconnect (verify behavior with product) |
| 11.4 | Member rotates device mid-signature | Canvas state preserved or gracefully reset (document expected UX) |
| 11.5 | PDF render runs on a slow puppeteer cold-start | Mobile UI shows a spinner; no double-submit possible (button disabled while pending) |
12. Smoke checklist (pre-deploy)
-
POST /templateswith thehealth_declarationpreset succeeds against staging. - Publish → assign → sign → download produces a valid Hebrew PDF.
- PDF SHA-256 matches
form_signatures.pdf_checksum_sha256. - Compliance bucket lifecycle inspection: confirm
R2_COMPLIANCE_BUCKET_NAMEis set in prod and points at the dedicated bucket (NOT the default). -
auto_issue_on_join=trueon a test template + activate a new member → instance appears. - Generate signing link → open in incognito → submit → instance flips to
signed, token NULLed. - Open token URL after expiry → 410 Gone.
- Cross-org leak attempt → 404.
- Online-org compliance create attempt → 403.
- Pre-publish without signature field → 400 with the Hebrew copy.
13. Known caveats (call out during QA pass)
- No event emitted on signed. Once FIT-178 lands and push notifications attach, the
form.signedevent becomes the wiring point. Today nothing reacts to a successful sign other than the DB write. - No re-sign cascade on version bump. Members signed at v1 stay signed until expiry; v2 is issued only via bulk reconcile or new auto-issue.
- R2 retention not yet bucket-enforced. Application code never deletes signed PDFs, but org delete cascades through. Document this risk; production should add Cloudflare R2 Object Lock to the compliance bucket before any real customer signs.
- PII inside signed PDF is unredactable. “Right to erasure” requests require a bespoke wipe script; no UI exists.
- Audit log table (FIT-20) is not built. Today the only audit evidence is
form_signaturesper-row + the PDF footer. The compliance reporting surface (who issued what when, who archived) needs the audit table to land first.