Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesFormsForms — QA Plan

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)

#ScenarioExpected
1.1Coach creates a draft compliance form with the health_declaration presetPOST /templates returns 201, row appears in list with Draft badge
1.2Coach patches the draft (rename, add field, edit field)All edits persist; row remains Draft; version=1
1.3Coach tries to publish a compliance template with no signature field400 with i18n key forms.publishWarning.signatureRequired (Hebrew “חתימה” required)
1.4Coach adds a signature field and publishes200; publishedAt populated; row shows Published badge
1.5Coach tries to patch the published row409 Conflict; UI shows “Already published — use Publish new version”
1.6Coach toggles auto-issue-on-join ONPATCH succeeds; no new version row created; flag persists; new members start receiving the form
1.7Coach toggles auto-issue OFFFlag flips; existing pending instances are NOT recalled
1.8Coach bumps version with a field changeNew row with version=2, publishedAt=now; v1 row untouched
1.9Coach archives v2archivedAt set; in-flight instances still resolve; new assigns blocked
1.10Online org coach opens /dashboard/formsCompliance create UI hidden / disabled; direct POST /templates with kind=compliance returns 403 with copy “Compliance forms are not available for online organizations”
1.11Coach with member role tries to access /dashboard/forms403
1.12Coach creates a typeKey not in the six allowed (e.g. pizza_order) with kind=compliance400 with the enum list in the message body
1.13Coach creates a check-in template with validityPeriodDays set400 — assertKindShape rejects

2. Assignment (single + bulk + auto-issue)

#ScenarioExpected
2.1Coach assigns the form to one member201 with data.id; member sees Pending signature in member-forms-tab; assignedByUserId is the coach
2.2Coach loads coverage previewmissingCount matches the count of active role=member memberships lacking a valid signed typeKey
2.3Coach hits Send to N members201 with { issuedCount, skippedCount }; refresh shows zero missing
2.4Coach hits Send to N members again immediately201 with issuedCount=0, skippedCount=N; no duplicate form_instances rows in DB (verify via DB query or staff UI)
2.5A new member signs up while auto-issue-on-join=trueAfter membership activation, the member’s GET /forms/mine returns the pending instance
2.6Auto-issue fails (force a transient DB error)Membership activation succeeds; FormsService logs the error; member onboarding does not block. Verify in API logs
2.7Coach (role) signs up in an org with auto-issue onNo instance is created for the coach (only role=member activations trigger fan-out)
2.8Coach assigns to a member who already has a pending v1 and the template is now v2New 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.

#ScenarioExpected
3.1Member loads /forms/instances/{id}{ instance, form } returned; openedAt set on first call only
3.2Member submits answers + signature r2Key200; form_signatures row inserted; form_instances.status='signed'; expiresAt = signedAt + validityPeriodDays
3.3Member submits the same instance twiceFirst succeeds; second returns 409 (canTransitionCompliance('signed','signed') false)
3.4Member submits another member’s instance403 — Only the assignee can sign this form
3.5Staff tries POST /instances/{id}/submit with a member’s instance ID403 — staff cannot sign on a member’s behalf
3.6Member submits with missing required field400; error body lists field IDs
3.7Member submits with no signature r2Key in answers400 — “Signature image r2Key required in answers”
3.8Signature PNG upload succeeds but signature r2Key references a different member’s pathShould fail — the staged-upload service validates ownership. Verify via path injection attempt
#ScenarioExpected
4.1Coach generates a signing link for a pending instance201 with { token: 64-hex, expiresAt: now+7d }; DB shows signing_token set, expires_at = now+7d
4.2Coach generates a link for a signed instance409 — “Cannot mint a link for an instance in status ‘signed‘“
4.3Coach generates a link for an archived instance409
4.4Unauthenticated browser opens the public URL /{lang}/forms/sign/{token}Server-side fetch via GET /forms/sign/{token} succeeds; page renders Hebrew RTL form
4.5Same URL opened a second timePage renders again; openedAt is unchanged on the second call (set-once)
4.6Browser tampers token (replace 1 char)404 from GET /forms/sign/{token}
4.7Coach mints; clock moved to T+8d (or set expiresAt to past via DB); browser opens410 Gone from GET /forms/sign/{token}
4.8Unauthenticated signer uploads PNG via POST /forms/sign/{token}/signature-upload200 with { uploadUrl, r2Key, expiresInSeconds: 300 }; the r2Key matches {orgId}/forms/signatures/{instanceId}/{ts}.png
4.9Unauthenticated signer attempts to PUT a non-PNG (e.g. image/jpeg) to the presigned URLR2 rejects with 403 because content-type mismatch; verify in network tab
4.10Unauthenticated signer submits with the token200; PDF rendered; form_instances.signing_token is NULLed; status signed
4.11Signer hits the URL again after successful submit404 — token is burned
4.12Two browsers race to submit the same tokenOne 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.

#ScenarioExpected
5.1Health declaration signed by IL member, locale=heTitle in Hebrew, RTL layout, table headers right-aligned, signature image embedded, audit footer in Hebrew (מסמך זה נחתם אלקטרונית באמצעות מערכת Fitkit), version + typeKey + date in Hebrew long format
5.2Same template signed with locale=enLTR, English audit footer
5.3Org has a logo URLLogo appears at the top, 48×48, before the org name (or after, depending on dir)
5.4Org has no logo URLHeader still renders cleanly; only org name
5.5Body richtext contains paragraph breaksPreserved via white-space: pre-wrap
5.6Body richtext contains HTMLEscaped — verify < and > render as text, no XSS executed
5.7Field labels contain HTMLEscaped
5.8Hebrew text mixed with English / numbersBidi rendering correct — no reversed digits in Hebrew dates
5.9Long body causing page overflowAuto-paginates; signature block stays attached to its label (no orphan signature on next page — visually inspect)
5.10validityPeriodDays=null templateFooter prints “Token” + audit but no expiry; form_instances.expiresAt is NULL after sign
5.11Special chars in name (apostrophes, quotes, Hebrew nikud)Escaped, not mangled
5.12Compute SHA-256 of the downloaded bytesMatches form_signatures.pdf_checksum_sha256 exactly
5.13Re-download PDF 30 days later (cached presign)Same URL still works until TTL expires; bytes identical
5.14Puppeteer process crash mid-render (kill the chromium PID)Submit returns 500; instance stays pending; retry succeeds

6. Auto-issue on join (event-driven)

#ScenarioExpected
6.1Org has zero auto-issue templates; new member activatedNo instances created
6.2Org has 3 auto-issue templates (different typeKeys); new member activated3 pending instances, one per typeKey, pinned to the latest version of each
6.3Same as 6.2; the event is replayedNo duplicates — coveredTypeKeys set dedupes
6.4Member already has a valid signed instance for health_declaration; org adds a new auto-issue template for health_declaration v2; member is reactivatedv2 NOT issued (existing valid signed covers it). When v1 expires, next reconcile picks up v2 — verify via assignAllMissing
6.5Auto-issue runs while org type is onlineReturns silently without creating instances
6.6Multiple auto-issue templates for the same typeKey at different versionsOnly the highest version is issued (latestPerType map)
6.7EventEmitter throws inside the listenerCaught and logged; original MEMBERSHIP_ACTIVATED flow completes

7. Permissions boundaries

#ScenarioExpected
7.1Member calls GET /templates403 — staff required
7.2Member of org A calls GET /organizations/{orgB}/forms/mine403 (membership lookup fails for org B)
7.3Coach of org A calls GET /organizations/{orgB}/forms/templates403
7.4Coach of org A loads a template by ID belonging to org B404 from getTemplateScoped (does NOT leak existence cross-org)
7.5Member calls GET /forms/instances/{otherMembersInstanceId} (in same org)403 — not assignee + not staff
7.6Public route accessed with a valid Clerk session but no token404 / 400 from getByToken (“Invalid token”)

8. Multi-org isolation

#ScenarioExpected
8.1Two orgs both have health_declaration v1Both rows exist independently with distinct UUIDs
8.2Member belongs to both orgsGET /forms/mine returns only the active org’s instances (route is org-scoped)
8.3Cross-org signing_token collisionImpossible — token is globally unique (form_instances_signing_token_uq)
8.4Org A signs a form, then is deletedCASCADE 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)

#ScenarioExpected
9.1Sign while behind a single proxy with X-Forwarded-For: 1.2.3.4form_signatures.ip_address = '1.2.3.4'
9.2Sign with multi-hop XFF 1.2.3.4, 5.6.7.8First hop 1.2.3.4 stored
9.3Sign over IPv6Full IPv6 in DB (45-char cap fits) and PDF
9.4Sign with no proxy (direct socket)req.socket.remoteAddress recorded
9.5Sign with empty UADB column NULL; PDF footer renders
9.6PDF footer shows: token id (first 16 chars), IP, UA (truncated to 200), signed at UTC, instance idVisually verify on a generated PDF
9.7Same SHA-256 from a re-download of the PDF as in pdf_checksum_sha256Bit-identical

10. RTL Hebrew rendering (web staff UI)

#ScenarioExpected
10.1Switch dashboard to he localeAll forms.* labels render in Hebrew; layout flips to RTL where appropriate
10.2Form builder with Hebrew labelsField labels saved verbatim; preview renders RTL
10.3Template detail panel in Hebrew”Auto-issue”, “Generate link”, “Send to N members” all translate
10.4Generated signing-link URLPath includes /{lang}/forms/sign/... — lang segment preserves the locale parent’s choice

11. Mobile-specific (when FIT-178 ships)

#ScenarioExpected
11.1Member receives push for “New compliance form”(when wired) deep-links to the instance
11.2Signature drawn on phone canvasSmooth strokes, no jitter; PNG uploaded successfully
11.3Submit while offlineQueued by mobile cache layer; retried on reconnect (verify behavior with product)
11.4Member rotates device mid-signatureCanvas state preserved or gracefully reset (document expected UX)
11.5PDF render runs on a slow puppeteer cold-startMobile UI shows a spinner; no double-submit possible (button disabled while pending)

12. Smoke checklist (pre-deploy)

  • POST /templates with the health_declaration preset 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_NAME is set in prod and points at the dedicated bucket (NOT the default).
  • auto_issue_on_join=true on 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)

  1. No event emitted on signed. Once FIT-178 lands and push notifications attach, the form.signed event becomes the wiring point. Today nothing reacts to a successful sign other than the DB write.
  2. 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.
  3. 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.
  4. PII inside signed PDF is unredactable. “Right to erasure” requests require a bespoke wipe script; no UI exists.
  5. Audit log table (FIT-20) is not built. Today the only audit evidence is form_signatures per-row + the PDF footer. The compliance reporting surface (who issued what when, who archived) needs the audit table to land first.