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 (מסמך זה נחתם אלקטרונית באמצעות מערכת Taikan), 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 deletedThe delete failsform_signatures.organization_id is ON DELETE RESTRICT (FIT-280). Nothing is removed: the FK violation aborts the statement before the forms / form_instances cascades fire. Verify the whole delete rolls back, not just the signature row. Offboarding requires export-then-delete; see data-model → Org deletion & retention
8.5Org B has forms + instances but no signatures, then is deletedSucceeds. forms and form_instances cascade away as before — the restrict only bites when there is evidence to protect

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. The DB side is now guarded — application code never deletes signed PDFs and org delete is blocked by RESTRICT (FIT-280) — but the R2 objects themselves carry no immutability. Production should still add Cloudflare R2 Object Lock to the compliance bucket.
  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.