Waiverly · Test run · run 2026-09-25
Waiverly Test run, as shipped in the download
238 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.
[DB: SQLite]
== 1. Canonical text + content hashing ==
PASS CRLF and LF bodies hash identically (got 'a441ee0b36b56efefa2d4a0ed1d3cbeb332ed58e3de7448c9939a93c42b260e0', want 'a441ee0b36b56efefa2d4a0ed1d3cbeb332ed58e3de7448c9939a93c42b260e0')
PASS leading/trailing whitespace is canonicalized away (got 'a441ee0b36b56efefa2d4a0ed1d3cbeb332ed58e3de7448c9939a93c42b260e0', want 'a441ee0b36b56efefa2d4a0ed1d3cbeb332ed58e3de7448c9939a93c42b260e0')
PASS changing one word changes the hash
PASS changing the title changes the hash
PASS adding an acknowledgment changes the hash
PASS hash is 64 hex chars
== 2. Template versioning (immutable snapshots) ==
PASS creating a template publishes version 1
PASS metadata-only edit does NOT bump the version
PASS metadata edit still saved (minor_age) (got 16, want 16)
PASS current version unchanged (got 1, want 1)
PASS text edit publishes version 2
PASS version 1 snapshot is untouched (immutable) (got 'Original text.', want 'Original text.')
PASS version 1 hash still verifies against its own text (got '1898463c2e568c6836bc855d81618633c4a9d351631b6d5e1cbc8dd83f6ba523', want '1898463c2e568c6836bc855d81618633c4a9d351631b6d5e1cbc8dd83f6ba523')
== 3. Age math (minor gate) ==
PASS day before 18th birthday -> 17 (got 17, want 17)
PASS on the 18th birthday -> 18 (got 18, want 18)
PASS day after -> still 18 (got 18, want 18)
PASS Feb-29 dob, non-leap Feb 28 -> not yet 18 (got 17, want 17)
PASS Feb-29 dob, non-leap Mar 1 -> 18 (got 18, want 18)
PASS Feb-29 dob, leap-year Feb 29 -> exactly N (got 20, want 20)
PASS born today -> 0 (got 0, want 0)
== 4. Expiry month math (month-end clamping) ==
PASS Jan 31 + 1mo -> Feb 28 (non-leap) (got '2026-02-28', want '2026-02-28')
PASS Jan 31 + 1mo -> Feb 29 (leap) (got '2024-02-29', want '2024-02-29')
PASS Aug 31 + 1mo -> Sep 30 (got '2026-09-30', want '2026-09-30')
PASS +12mo -> same date next year (got '2027-07-04', want '2027-07-04')
PASS Dec 15 + 2mo rolls the year (got '2027-02-15', want '2027-02-15')
PASS Jan 31 + 13mo -> Feb 28 next year (got '2027-02-28', want '2027-02-28')
PASS +24mo from Feb 29 clamps (got '2026-02-28', want '2026-02-28')
== 5. Signing: validation + record creation ==
PASS adult signs successfully
PASS verify code is 10 chars (got 10, want 10)
PASS code avoids 0/O/1/I lookalikes
PASS adult is not flagged minor (got 0, want 0)
PASS expiry = signed date + 6 months (template setting) (got '2027-03-25', want '2027-03-25')
PASS record binds to the CURRENT version (v2) (got 2, want 2)
PASS fresh record passes integrity
PASS status of a fresh record (got 'valid', want 'valid')
PASS rejects empty name — “Enter your full name.”
PASS rejects bad email — “That email address does not look right.”
PASS rejects missing DOB — “Enter your date of birth.”
PASS rejects impossible DOB — “Enter your date of birth.”
PASS C2R: refuses an array where signer_name belongs — “Please fill in each field with a single value.”
PASS C2R: refuses an array where signer_email belongs — “Please fill in each field with a single value.”
PASS C2R: refuses an array where signer_dob belongs — “Please fill in each field with a single value.”
PASS C2R: refuses an array where the signature belongs — “Please fill in each field with a single value.”
PASS C2R: refuses an array where guardian_name belongs — “Please fill in each field with a single value.”
PASS C2R: not one of those refusals stored a waiver named "Array"
PASS C2R: `acks` is still allowed to be a list (the one legitimate array field)
PASS rejects future DOB — “Check the date of birth.”
PASS rejects unticked acknowledgment — “Please tick every acknowledgment box.”
PASS rejects missing signature — “Please sign in the signature box.”
PASS rejects non-PNG payload — “Please sign in the signature box.”
PASS oversize-fixture really exceeds the cap
PASS rejects oversize signature — “Please sign in the signature box.”
PASS rejects inactive template — “This waiver is not accepting signatures.”
PASS minor WITHOUT guardian is rejected — “A parent or guardian must complete this waiver for a minor.”
PASS minor WITH guardian signs (flagged minor) (got 1, want 1)
PASS guardian name stored (got 'Casey Guardian', want 'Casey Guardian')
PASS guardian signature stored
PASS kiosk source recorded (got 'kiosk', want 'kiosk')
PASS adult submission blanks stray guardian fields (got '', want '')
== 6. Derived status: valid / expired / superseded ==
PASS valid while before expiry (got 'valid', want 'valid')
PASS expired the day AFTER expires_on (got 'expired', want 'expired')
PASS still valid ON expires_on itself (got 'valid', want 'valid')
PASS never-expires ('' expiry) stays valid decades on (got 'valid', want 'valid')
PASS old version + require_resign -> superseded (got 'superseded', want 'superseded')
PASS old version + require_resign OFF -> stays valid (got 'valid', want 'valid')
PASS superseded beats expired (precedence) (got 'superseded', want 'superseded')
PASS publishing v3 supersedes the v2 record
PASS superseded record still shows the exact v2 text it signed
PASS superseded record still passes integrity
== 7. Tamper evidence ==
PASS editing the signer name in the DB flags TAMPERED
PASS restoring the original value verifies again
PASS swapping the signature image flags TAMPERED
PASS original signature verifies again
== 8. Verify-code lookup ==
PASS byCode finds the record
PASS byCode is case/format tolerant (lowercase + dashes)
PASS unknown code -> null
PASS all codes unique (got 4, want 4)
== 9. CSV export formula-injection guard ==
PASS neutralizes =formula signer name (got '\'=cmd|\'/c calc\'!A1', want '\'=cmd|\'/c calc\'!A1')
PASS neutralizes +formula (got '\'+SUM(A1)', want '\'+SUM(A1)')
PASS neutralizes @formula (got '\'@A1', want '\'@A1')
PASS neutralizes leading-minus text (not numeric) (got '\'-1+1@cmd', want '\'-1+1@cmd')
PASS neutralizes leading TAB (got '\' =1', want '\' =1')
PASS leaves plain negative number untouched (got '-45', want '-45')
PASS leaves plain decimal untouched (got '-45.00', want '-45.00')
PASS leaves ordinary name untouched (got 'Alex Sample', want 'Alex Sample')
PASS leaves date string untouched (got '2026-07-04', want '2026-07-04')
PASS leaves empty untouched (got '', want '')
== 10. Regression: validity window covered by record hash + input length caps ==
PASS extending expires_on in the DB flags TAMPERED
PASS restoring expires_on verifies again
PASS backdating signed_on in the DB flags TAMPERED
PASS restoring signed_on verifies again
PASS rejects over-long phone (would 500 on MySQL strict) — “That phone number is too long.”
PASS rejects over-long email — “That email address does not look right.”
PASS rejects over-long guardian relation — “The guardian relationship is too long.”
PASS normal-length fields still accepted after caps
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations (auth HTML can not enter cache storage)
PASS sw.js cache name carries the version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
== Own It 2.0 ==
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: backup_tables
PASS v2: no_signature_image
PASS v2: no_signer_ip
PASS v2: has_record_hash
PASS v2: routes registered
PASS v2: NO signing endpoint (a waiver must be signed by a person, in a browser)
PASS v2: integrity check reuses the product core (Waiver::integrityOk)
PASS v2: signing fires waiver.signed + notifies, never fatally
PASS v2: 2FA intercepts login + rate limit
PASS v2: zero clip() dependency
PASS v2: no ternary key re-reads (PLAN item 10)
PASS v2: dark palette; signature pad pinned light in BOTH themes
PASS v2: theme.js on the interactive layouts (print.php deliberately excluded)
PASS v3: API.md ships + sw cache and healthz/ping/openapi all report the CURRENT version (3.1.5)
PASS v2: cli-server base guard present
-- Own It 3.0 --
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_never_loosens
PASS v3: settings_singleton_ensured
PASS v3: upgrade_from_2x
PASS v3: migrate_called_at_boot
PASS v3: roles
PASS v3: last_admin_guard
PASS v3: waiver_signed
PASS v3: signed_text_is_pinned
PASS v3: becomes_superseded
PASS v3: integrity_still_ok
PASS v3: mcp_tools
PASS v3: mcp_is_read_only
PASS v3: mcp_arg_guard
PASS v3: mcp_returns_signed_snapshot
PASS v3: mcp_never_returns_signature
PASS v3: mcp_get_is_405
PASS v3: expiry_data_shared
PASS v3: pdf_renders_pinned_text
PASS v3: gdpr_blocked_while_valid
PASS v3: gdpr_allowed_once_expired
PASS v3: gdpr_keeps_the_evidence_skeleton
PASS v3: ical_token
PASS v3: ical_feed_shape
PASS v3: branding_content_decides
PASS v3: restore_refuses_other_product
PASS v3: audit_redacts
PASS v3: routes registered, and LITERALS come before {id} patterns
PASS v3: the PDF renders the PINNED snapshot, never the current template
PASS v3: GDPR refuses to erase a waiver that can still be relied on
PASS v3: the signature image is never exposed by the API or the audit trail
PASS v3: expiry is computed in ONE place and reused by the list, the feed and the stats tool
PASS v3: re-signing never edits the original waiver
PASS v3: core Mcp is byte-identical to the shared core
PASS v3: core AuditLog is byte-identical to the shared core
PASS v3: core Pdf is byte-identical to the shared core
PASS v3: core Ical is byte-identical to the shared core
PASS v3: core BackupRestore is byte-identical to the shared core
PASS v3: core Branding is byte-identical to the shared core
PASS v3: core RolesKit is byte-identical to the shared core
PASS v3: core Gdpr is byte-identical to the shared core
PASS v3: core Oidc is byte-identical to the shared core
PASS v3/C2: the viewer seat is READ-ONLY in the map — it holds nothing that changes the record
PASS v3/C2: the legal text and the register are role-gated, not merely signed-in
PASS v3/C2: the backup routes hand over the whole register — settings.edit, not requireLogin
PASS v3/C2: no route that predates 3.0 is left on a bare requireLogin except the user's own account
PASS v3/C2: the sidebar hides Settings from a role the route refuses
PASS v3/C2: /backup.sqlite is an app ROUTE and the dev router does not 403 it
PASS v3/C2: bin/demo.php can actually run — migrate() calls AuditLog::ensure()
PASS nav: every sidebar link is highlighted by the page it opens
PASS nav: no page highlights a sidebar item that does not exist
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
== UI kit: returning-signer check (kiosk hero endpoint) ==
PASS returning: exact email+DOB finds the record, case-insensitively
PASS returning: reports the engine-computed expiry (got '2027-09-25', want '2027-09-25')
PASS returning: answer carries NO name and NO code (nothing to enumerate)
PASS returning: right email + wrong DOB stays silent
PASS returning: unknown email stays silent
PASS returning: malformed email / DOB are refused before the query
PASS returning: past the expiry date the engine says expired
PASS returning: a text bump flips the answer to superseded (must re-sign)
PASS returning: /w/{token}/check + /k/{token}/check routed, CSRF-checked, minimal JSON
== v3.1: the signer gets their own copy ==
PASS signer copy: carries the title, the dates and the reference code
PASS signer copy: links the PUBLIC verify page, code included
PASS signer copy: carries NO signature image, record hash, IP or internal note
PASS signer copy: subject names the waiver and the business
PASS signer copy: no base URL configured → the email simply omits the link
PASS signer copy: OFF by default — an operator who never asked sends nothing (got 'Signer copies are switched off in Settings.', want 'Signer copies are switched off in Settings.')
PASS signer copy: demo installs never reach a credential (got 'Demo mode — no mail leaves this server.', want 'Demo mode — no mail leaves this server.')
PASS signer copy: unconfigured SMTP refuses in plain words (got 'SMTP is not configured.', want 'SMTP is not configured.')
PASS signer copy: a signer who gave no address is not an error, just a no-send (got 'This signer did not give an email address.', want 'This signer did not give an email address.')
PASS signer copy: the automatic send does not repeat itself (got 'A copy was already sent for this waiver.', want 'A copy was already sent for this waiver.')
PASS signer copy: an operator CAN send it again on purpose (got '', want '')
PASS signer copy: the manual route is permission-gated and CSRF-checked
PASS signer copy: signing calls it, and a mail failure cannot break the signature
PASS signer copy: the SMTP password is write-only — a blank post keeps the stored secret
PASS signer copy: a refused SMTP connection returns a reason instead of a fatal error
PASS signer copy: the failure is written on the row, not marked as sent
PASS signer copy: the failed send leaves the signed record untouched
PASS API.md: names the real signing paths (/w/{token}, /k/{token}), not a /sign route that 404s
== F2: walk-in kit — QR poster per template, prefilled share links ==
PASS poster: GET /templates/{id}/poster is routed to template_poster, behind template.view
PASS poster: the QR carries the Settings public address when one is set (got 'https://waivers.example.com/w/846e092faa0109b1ba3e828e12b6eb87', want 'https://waivers.example.com/w/846e092faa0109b1ba3e828e12b6eb87')
PASS poster: renders the share link as a QR (qr.js) plus the address in print
PASS poster: the kiosk token is never on it (the kiosk stays on the desk tablet)
PASS poster: every template row links to its poster
PASS prefill: ?name=&email= on the share link fills both fields, escaped
PASS prefill: the kiosk ignores it (the next person must not inherit a name)
PASS signer copy: the signing route itself also catches anything the copy throws, so the owner notice still sends
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
PASS licence: every font ships the SIL OFL text for its own family beside it
PASS licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
PASS forms: every POST form on a screen reaches a registered route (26 checked)
PASS demo ribbon: on the public pages it says what the page is, not "signed in as the admin"
PASS demo ribbon: below 600px it starts as the small pill unless the visitor opened it
PASS backups: every non-admin role gets 403 on every backup, restore and backup-token route
PASS backups: an administrator still gets the JSON backup (got 'GATE 200', want 'GATE 200')
PASS backup JSON: an administrator gets a backup, with every credential column planted (10)
PASS backup JSON: the download carries none of them
PASS backup JSON: the scheduled backup carries none of them
PASS backup JSON: the download and the scheduled writer read the one redaction list
PASS security page: the backup card says every credential is redacted, not only the SMTP password
————————————————————————————————————————————————————
ALL 238 TESTS PASSED (0 failed)