Home›Waiverly›Test run
Waiverly · Test run · run 2026-11-07

Waiverly Test run, as shipped in the download

399 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 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-05-07', want '2027-05-07')
  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 (4.1.3)
  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-11-07', want '2027-11-07')
  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/b9491c392f7ea6489475452ff76e2714', want 'https://waivers.example.com/w/b9491c392f7ea6489475452ff76e2714')
  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  license: every font ships the SIL OFL text for its own family beside it
  PASS  license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
  PASS  forms: every POST form on a screen reaches a registered route (39 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 (16)
  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

== 3.2: waiver questions, show-if rules, marketing consent, sealed ==
  PASS  questions: five parsed with their types, required flags and options
  PASS  questions: an "if:" names the earlier question, stored by its key
  PASS  questions: a one-option choice, an unknown type and an "if:" on nothing are refused
  PASS  questions: the edit screen gets back the same list
  PASS  content hash: a version without questions hashes exactly as before 3.2
  PASS  template: the questions and the consent wording are part of the version snapshot
  PASS  template: saving the same questions publishes nothing; changing them publishes a new version
  PASS  signing: a required question left empty is refused
  PASS  signing: a required follow-up is refused only when it is shown
  PASS  signing: yes/no, choice and date answers are checked
  PASS  record: the shown answers are stored with their questions; a hidden answer is never stored
  PASS  record: new records are sealed in format 2 and verify
  PASS  record: the consent box is unticked unless the signer ticks it
  PASS  record: a template with no consent box records no consent, whatever is posted
  PASS  tamper: an edited answer shows TAMPERED
  PASS  tamper: a withdrawn consent edited in the database shows TAMPERED
  PASS  tamper: relabeling a format-2 record as format 1 does not make it verify
  PASS  tamper: restored, it verifies again
  PASS  format 1: a record signed before 3.2 (no answers) still verifies with the old formula
  PASS  outputs: the CSV has answers and consent columns; the PDF prints the answers and the consent
  PASS  outputs: the API one-record read carries the answers; every payload carries the consent
  PASS  signing page: questions render by type with their show-if rule; the consent box is unticked; questions.js applies the rule
  PASS  template form: the questions box and the consent wording are on the edit screen

== 3.2 pass B: check-in against a valid waiver, mailing-list export, photo ==
  PASS  check: a valid record wins over an expired one for the same person
  PASS  check: only expired → expired; nobody → none
  PASS  check: a tampered record is never counted as valid
  PASS  check: GET /api/waivers/check is routed before /api/waivers/{code}, behind a key, and answers no signature or code
  PASS  arrivals: a valid waiver checks in; an expired one is refused and told to sign again
  PASS  arrivals: the front desk (viewer and member roles) may check in; the page and route exist
  PASS  mailing list: only signers who ticked the box, once per address, in Mailchimp's headings
  PASS  photo: only a JPEG data URL of sane size is taken
  PASS  photo: a template that requires one refuses a signing without it
  PASS  photo: taken, stored and sealed in the record
  PASS  photo: a swapped photo shows TAMPERED
  PASS  photo: off by default; the template form offers it; the sign page asks with the camera; anonymizing erases it and the answers

== 3.2 overflow: sign for a child after your own waiver; one kiosk for every waiver ==
  PASS  guardian link: an adult who has just signed is remembered in this session, and fills the guardian in
  PASS  guardian link: it lasts 15 minutes, never longer
  PASS  guardian link: "sign for a child" opens the form with the guardian's name, email and phone filled in
  PASS  guardian link: the child's waiver is linked to the adult's, once, and an adult's waiver is never linked
  PASS  guardian link: the adult's "all set" page offers "Sign for a child now"
  PASS  guardian link: a fresh form forgets it, so the next guest at a kiosk never sees the last one's details
  PASS  guardian link: the child's record links to the adult's waiver and the adult's lists the children
  PASS  guardian link: signing links the child when the form came from the adult, and remembers an adult
  PASS  kiosk hub: no link until one is made
  PASS  kiosk hub: the link lists every active waiver, each opening its kiosk form with the way back
  PASS  kiosk hub: the form and the reset both come back to the hub
  PASS  kiosk hub: a made-up hub is ignored (back to the waiver's own kiosk)
  PASS  kiosk hub: wrong links are rate-limited; making or replacing the link needs template.edit and CSRF; the token leaves the shareable backup

== Text messages: the signing link by text, through the owner's own SMS gateway ==
  PASS  texts: off by default — no gateway, nothing stored, nothing sent, and the owner is told where to turn it on
  PASS  texts: a gateway address that is not https never counts as on
  PASS  texts: numbers are normalized; something that cannot be a number is refused
  PASS  gateway: JSON by default, fields to/from/text, the key as a Bearer token
  PASS  gateway: Twilio-style — To/From/Body, form-encoded, key as HTTP Basic
  PASS  gateway: "not sent" leaves the key out; no sender leaves the from field out
  PASS  consent: no text without the tick that they gave this number for the link; a reminder needs a visit date
  PASS  consent: the tick is stored with who recorded it; a link without the reminder tick has remind_consent 0
  PASS  text: names the business and the waiver, links to sign (marked as texted) and to stop
  PASS  text: the token in the text is kept only as its SHA-256; no column holds it, and it finds its request
  PASS  once: the same text again is refused by its key before the gateway is called
  PASS  reminder: no reminder without its own consent tick, even when the visit is tomorrow (the tick is in the query)
  PASS  reminder: with consent, one reminder the day before the visit, and only one however often the run repeats
  PASS  window: 09:00–20:00 in the business time zone by default; an overnight window works too
  PASS  window: a due reminder waits outside the window, then goes inside it
  PASS  signed: through the texted link, or with the same number on the form, closes the request: no reminder
  PASS  signed: a texted link only closes its own waiver's request, once
  PASS  stop: opening the link only asks (a link preview stops nobody); the page names the business and never shows the whole number
  PASS  stop: the button stops that number for good: no reminder, and no new link from any team member
  PASS  failure: a refused or unreachable gateway is written on the text's row and shown, never thrown
  PASS  failure: no reminder follows a link text that did not go
  PASS  page: the form asks for the consent ticks (the link one required) and lists each text with its status and error
  PASS  sign link: a texted link keeps its marker through the form; another waiver's page ignores it
  PASS  sign link: signing through it closes the request (sign_submit marks it, link signings only)
  PASS  settings: saving needs settings.edit and CSRF; https only; the key is write-only; sending needs waiver.resend
  PASS  privacy: the gateway key and the token hashes leave the shareable backup; erasing a signer erases the texted request that led to the waiver
  PASS  privacy: anonymizing the waiver blanks that request's name and number
  PASS  demo: the public demo never sends a text; the cron run sits behind the scheduled-backup token
  PASS  template: a reminder run stops at the first refusal (one attempt, the next one untouched)
  PASS  template: a refused reminder is never tried twice on one day; the others go
  PASS  template: asked directly, a reminder refused today is still not claimed again today (the claim itself refuses)
  PASS  template: a reminder refused on an earlier day is claimed again, once, and goes
  PASS  template: the daily cap stops the reminders for the day
  PASS  template: consent is read again at the moment of sending (withdrawn mid-run, the next text does not go)
  PASS  template: both consents are conditions of the query that picks reminders, as well as being read again at send
  PASS  template: the audit names the number only by its last four digits

————————————————————————————————————————————————————

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  PASS  updates: the updater table holds exactly one state row
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /updates is updates_page()
  PASS  updates: route post /updates/key is updates_key_save()
  PASS  updates: route post /updates/check is updates_check()
  PASS  updates: route post /updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Waiverly with records seeds nothing and changes nothing
  PASS  A2: real use: one of the owner's own rows among the sample
  PASS  A2: remove: the owner's own row stays, and a sample row it points at is kept
  PASS  A2: remove: the report says what went, and what was kept because the owner's records use it
  PASS  A2: remove: the rest of the sample is gone
  PASS  A2: remove: every kept sample row keeps all its own parts
  PASS  A2: remove: no row is left pointing at a row that was removed
  PASS  A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
  PASS  A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
  PASS  A2: remove: the owner's own uploaded file is untouched
  PASS  A2: "Load demo data" on a Waiverly with records refuses and deletes nothing
  PASS  A2: backup: written, sent to the bucket (a signed PUT), and read back
  PASS  A2: backup: the bucket secret is not in the backup it protects
  PASS  A2: backup: under the standard keep count (14) every backup is kept
  PASS  A2: switched off: with automatic backups off, a backup is never due however old the last one
  PASS  A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
  PASS  A2: an offsite endpoint must be https for an owner (plain http only in tests)
  PASS  A2: all 25 owner's-install checks reported
  PASS  A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
  PASS  A2: index.php loads the three cores and the wiring
  PASS  A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
  PASS  A2: route post /sample-data/remove is sample_data_remove()
  PASS  A2: route post /restore/backups is backups_save()
  PASS  A2: route post /restore/backup-now is backup_now()
  PASS  A2: route get /health is health_page()
  PASS  A2: sample_data_remove() opens with the restore page's admin gate
  PASS  A2: backups_save() opens with the restore page's admin gate
  PASS  A2: backup_now() opens with the restore page's admin gate
  PASS  A2: health_page() opens with the restore page's admin gate
  PASS  A2: every POST checks the CSRF token
  PASS  A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
  PASS  A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
  PASS  A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
  PASS  A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
  PASS  A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Waiverly, removable)
  PASS  A2: the menu links to Health
  PASS  A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
  PASS  A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
  SKIP  A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)

== sec3 D4: Export selected (CSV) writes what a signer typed as text, never a formula ==
  PASS  sec3 D4 (setup): four waivers signed through the public form's path (=1+1, @SUM(1), -2+3, a plain name)
  PASS  sec3 D4: the export answers a CSV with the four selected waivers
  PASS  sec3 D4: a signer named =1+1 reads '=1+1 (and the address =1+1@… '=1+1@…)
  PASS  sec3 D4: a signer named @SUM(1) reads '@SUM(1)
  PASS  sec3 D4: a signer named -2+3 reads '-2+3, and the address +4@… reads '+4@…
  PASS  sec3 D4: no cell of the file starts with = + - @ (the formula characters)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 17 personal columns
  PASS  erase planting · sqlite: the app's own erase runs through its route and reports no failure
  PASS  erase planting · sqlite: no planted marker survives anywhere (22 tables dumped), outside the 1 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the erase page (/waivers/1) says what an erase keeps: "Kept: the signer's phone number on the text stop list, if th…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the signer's phone number on the text stop list, if th…"
ALL 399 TESTS PASSED (0 failed)

← Back to Waiverly · Manual · Quickstart · API