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

Trainora Test run, as shipped in the download

306 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.

== 1. expiryFrom: months added, month-end CLAMPED, 0 = never expires ==
  PASS  1 Aug + 12m (got '2027-08-01', want '2027-08-01')
  PASS  31 Jan + 1m clamps to Feb 28 (2026 is not a leap year) (got '2026-02-28', want '2026-02-28')
  PASS  29 Feb + 12m clamps to Feb 28 (got '2025-02-28', want '2025-02-28')
  PASS  31 Mar + 1m clamps to Apr 30 (got '2026-04-30', want '2026-04-30')
  PASS  31 Dec + 1m crosses the year (got '2027-01-31', want '2027-01-31')
  PASS  1 Jan + 36m (got '2029-01-01', want '2029-01-01')
  PASS  0 months = never expires (got NULL, want NULL)
  PASS  negative months = never expires (got NULL, want NULL)
  PASS  a nonsense date yields nothing (got NULL, want NULL)
  PASS  30 Jan and 31 Jan + 1m land on the SAME day, not three days apart

== 2. state: the expiry day itself is still valid ==
  PASS  expires in 90 days, warn 30 -> valid (got 'valid', want 'valid')
  PASS  expires in 10 days, warn 30 -> expiring (got 'expiring', want 'expiring')
  PASS  expires TODAY -> expiring, never expired (got 'expiring', want 'expiring')
  PASS  expired yesterday -> expired (got 'expired', want 'expired')
  PASS  exactly at the warning edge -> expiring (got 'expiring', want 'expiring')
  PASS  one day past the edge -> valid (got 'valid', want 'valid')
  PASS  no expiry date at all -> valid forever (got 'valid', want 'valid')
  PASS  warn 0 means only today is amber (got 'expiring', want 'expiring')
  PASS  a cell with no record at all (got 'none', want 'none')

== 2b. a failed check holds nothing (F2b, 2026-09-24) ==
  PASS  the engine knows three results (got array (
  0 => 'pass',
  1 => 'pass_remarks',
  2 => 'fail',
), want array (
  0 => 'pass',
  1 => 'pass_remarks',
  2 => 'fail',
))
  PASS  a fail is FAILED even with an expiry still ahead of it (got 'failed', want 'failed')
  PASS  a fail with no expiry is FAILED, never "valid forever" (got 'failed', want 'failed')
  PASS  a pass with remarks is read by its dates like any pass (got 'valid', want 'valid')
  PASS  a record with no result at all (older rows) is a pass (got 'expired', want 'expired')
  PASS  isFail reads the result and nothing else
  PASS  a failed cell says so (got 'failed', want 'failed')
  PASS  failed is the worst state, read first (got 'failed', want 'failed')
  PASS  a per-row tally counts failed cells in its own key (got array (
  'valid' => 1,
  'expiring' => 0,
  'expired' => 0,
  'missing' => 0,
  'failed' => 1,
), want array (
  'valid' => 1,
  'expiring' => 0,
  'expired' => 0,
  'missing' => 0,
  'failed' => 1,
))

== 3. daysUntil ==
  PASS  10 days ahead (got 10, want 10)
  PASS  today is 0 (got 0, want 0)
  PASS  yesterday is -1 (got -1, want -1)
  PASS  across a month boundary (got 31, want 31)
  PASS  null in, null out (got NULL, want NULL)

== 4. phrase: never invents a number it was not given ==
  PASS  missing (got 'never recorded', want 'never recorded')
  PASS  overdue (got '84d overdue', want '84d overdue')
  PASS  today (got 'expires today', want 'expires today')
  PASS  soon (got 'in 12d', want 'in 12d')
  PASS  valid with no date (got 'valid', want 'valid')

== 5. applies: blank scope = everyone, otherwise the tag must match ==
  PASS  a blank scope applies to everyone
  PASS  a scoped requirement applies to its own group
  PASS  and not to another group
  PASS  matching ignores case and stray spaces
  PASS  a subject with no group is outside a scoped requirement

== 6. dates ==
  PASS  isDate accepts a real date
  PASS  isDate rejects 30 February
  PASS  isDate rejects a non-date
  PASS  isDate rejects a partial date
  PASS  addDays forward (got '2026-09-01', want '2026-09-01')
  PASS  addDays backward (got '2026-08-22', want '2026-08-22')

== 7. tableFor: the only interpolated identifier is whitelisted ==
  PASS  subject maps to this product's table (got 'people', want 'people')
  PASS  an unknown role is refused, not guessed
  PASS  ROLES is exactly the six the engine knows
  PASS  Domain names a table for every role

== 8. the shared engine is byte-identical to the canonical copy ==
  PASS  the shipped engine matches the canonical one byte for byte
  PASS  the engine file ships with the product
  PASS  the two products ship the SAME code apart from the four that may differ

== 9. Pdf: produces a valid, non-trivial document ==
  PASS  starts with the PDF header
  PASS  ends with the EOF marker
  PASS  is not a stub

== 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 CURRENT version (old caches purged on activate)
  PASS  offline fallback page ships
  PASS  the layout links the manifest + registers the SW
  PASS  the layout carries a theme toggle and loads the kit

== platform shape ==
  PASS  index.php migrates at boot — the ONLY upgrade path for an existing install
  PASS  the twelve domain routes are registered
  PASS  route segments come from Domain, so index.php is one file for both products
  PASS  the platform routes are registered (api, mcp, 2fa, security, backup, import, healthz, openapi)
  PASS  2FA intercepts login + login is rate-limited
  PASS  exactly ONE place inserts a record, and it is the engine
  PASS  every door reaches the engine: grid cell, round attendance, REST, MCP, (Trainora 3.2) a confirmed certificate sent in, (Inspectora 3.2) the checklist inspection and (Trainora 3.3) a spreadsheet import
  PASS  no controller recomputes an expiry date of its own
  PASS  no controller hardcodes a physical table name — they all ask Domain
  PASS  the write path returns refusals rather than throwing (every door must explain itself)
  PASS  every MCP read tool declares readOnly (the core hides writes from a read key by THIS flag)
  PASS  the MCP endpoint hands the key scope to the core
  PASS  a read-scoped key is refused at the door, once — not per route
  PASS  mintKey persists the scope and the key form offers it
  PASS  the certificate hand-off emits certora's three header names, and nothing generates a certificate here
  PASS  settings is two forms writing one row, each declaring its block
  PASS  counterparty mail is opt-in and swallows its own failures
  PASS  absolute URLs are built once (no doubled origin)
  PASS  dark palette + zero hardcoded white backgrounds
  PASS  the app layer only re-tints the accent; the kit owns the rest
  PASS  API.md ships and documents the vendor-neutral connect recipe
  PASS  /backup.sqlite is carved out of the blanket .sqlite block in BOTH the dev router and .htaccess
  PASS  every cron script the docs and screens name ships (reminders.php)
  PASS  the reminder runner is CLI-only, calls the one shared pass, and the web server denies cron/
  PASS  BACKUP: both backup downloads are administrators-only, and only administrators are shown them
  PASS  WEBHOOKS: the events offered for subscription are exactly the events the code fires (record.created)
  PASS  DASHBOARD: a completed getting-started step draws its tick (the kit draws the ✓ into <i>)

== every function the routes name actually exists ==
  PASS  every route handler is defined (83 routes checked)
  PASS  security page: the backup card says webhook URLs and every credential are left out of the JSON (ruling 1, 2026-09-25)
  PASS  api roles: a viewer's key cannot write a record (403); a member's key gets past the gate to the data checks

== 3.2: my training record, sent-in certificates, sign-off, role x location, the report, Certora ==
  PASS  3.2: rule_role_and_location
  PASS  3.2: rule_without_location_column_is_unchanged
  PASS  3.2: engine_refuses_a_record_that_does_not_apply
  PASS  3.2: link_resolves_its_person_only
  PASS  3.2: page_lists_what_applies_worst_first
  PASS  3.2: upload_bytes_decide_the_type
  PASS  3.2: confirm_records_and_files_the_evidence
  PASS  3.2: a_submission_is_decided_once
  PASS  3.2: decline_keeps_the_reason
  PASS  3.2: signoff_ladder
  PASS  3.2: report_cadence
  PASS  3.2: report_recipients_cleaned
  PASS  3.2: report_names_what_needs_attention
  PASS  3.2: report_is_sent_and_stamped
  PASS  3.2: report_not_sent_twice_in_a_week
  PASS  3.2: report_rides_the_reminder_run
  PASS  3.2: certora_payload_matches_its_api
  PASS  3.2: certora_answer_read_back
  PASS  3.2: certora_key_is_backup_redacted
  PASS  3.2: routes_registered_only_for_trainora
  PASS  3.2: writes_are_gated
  PASS  3.2: a viewer cannot sign a record off; a member can, with the assessor named
  PASS  3.2: a wrong "My training record" link answers 404 and names no one
  PASS  3.2: the sent-in inbox is for people who may write records

== 3.3: the certificate number on a record, past records from a spreadsheet ==
  PASS  3.3: check_only_writes_nothing
  PASS  3.3: number_is_cleaned_and_kept
  PASS  3.3: api_shape_carries_it
  PASS  3.3: a_sent_in_certificate_brings_its_number
  PASS  3.3: the_agent_can_give_the_number
  PASS  3.3: dry_run_writes_nothing
  PASS  3.3: dry_run_names_each_refusal
  PASS  3.3: import_records_through_the_writer
  PASS  3.3: email_and_code_find_the_row
  PASS  3.3: the_same_file_twice_adds_nothing
  PASS  3.3: a_missing_column_is_named
  PASS  3.3: the_dry_run_page_shows_line_and_reason
  PASS  3.3: a_member_saves_the_number_a_viewer_cannot
  PASS  3.3: the_record_page_shows_it
  PASS  3.3: search_finds_the_record_by_number
  PASS  3.3: the_records_csv_ends_with_the_number
  PASS  3.3: the_import_is_for_administrators
  PASS  3.3: the_api_records_and_returns_the_number
  PASS  3.3: the_person_signs_with_a_typed_name
  PASS  3.3: both_sides_see_the_signature
  PASS  3.3: a_later_change_shows_as_changed_since

== runtime ==
  PASS  migrate() creates the six engine tables under this domain's names
  PASS  install() ALONE produces the current schema
  PASS  migrate() creates the settings row
  PASS  migrate() twice changes nothing
  PASS  HAPPY: a completion is recorded and its expiry computed
  PASS  REFUSAL: a future date is refused
  PASS  REFUSAL: a nonsense date is refused
  PASS  REFUSAL: a requirement scoped to another group is refused
  PASS  REFUSAL: an archived subject is refused
  PASS  REFUSAL: unknown ids are refused, not created
  PASS  REFUSAL: a refused write leaves the table untouched
  PASS  PERMISSION: viewer may read and may not write
  PASS  PERMISSION: member may write records
  PASS  PERMISSION: member may not delete
  PASS  PERMISSION: admin may do everything
  PASS  PERMISSION: an unknown role gets the most restrictive one
  PASS  IDEMPOTENCY: marking attendance twice writes one record
  PASS  the state a round stores is the word the screens compare against
  PASS  IDEMPOTENCY: the same person cannot be booked twice
  PASS  IDEMPOTENCY: a reminder ledger row is claimed before the send
  PASS  IDEMPOTENCY: a failed send releases its slot
  PASS  a renewal supersedes without deleting the old record
  PASS  the current record is the latest achieved_on
  PASS  coverage counts only cells that APPLY
  PASS  the attention list is ordered worst-first, stably
  PASS  FAILED: a failed record makes the cell failed; an earlier-dated pass does not clear it, a later pass does
  PASS  FAILED: a fail stores no expiry, and the API never calls it "never expires"
  PASS  FAILED: a fail comes off the in-date count (today and in the history) and heads the attention list
  PASS  FAILED: a result the engine does not know is refused and writes nothing
  PASS  FAILED: a pass with remarks is a pass, with its expiry
  PASS  FAILED: the agent tool takes a result only where the product records one; otherwise it is unchanged
  PASS  EXPORT: the matrix CSV header and cells are as documented
  PASS  EXPORT: the records CSV header is as documented
  PASS  HAND-OFF: exactly name,course,email — certora's importer contract
  PASS  HAND-OFF: an expired holder is not offered a certificate
  PASS  a fresh install writes to nobody
  PASS  switched on with no SMTP still sends nothing
  PASS  RUNNER: cron/reminders.php --dry-run names who is due and sends and writes nothing
  PASS  RUNNER: cron/reminders.php writes to each person due AND sends the owner digest, over real SMTP
  PASS  RUNNER: a real pass records when it ran and what it did
  PASS  RUNNER: a second pass the same day attempts no mail at all
  PASS  RUNNER: a digest the mail server refuses is retried next run
  PASS  RUNNER: switched-on reminders that never ran are flagged, a fresh run is not
  PASS  EVIDENCE: the bytes decide the type, not the name
  PASS  every write path leaves an audit row
  PASS  no audit row carries a secret
  PASS  the calendar feed carries no email addresses
  PASS  GDPR: names go, the compliance history stays
  PASS  GDPR: refused while a seat is still booked
  PASS  backup + restore parses its own file
  PASS  BACKUP: the permission the downloads ask for is held by an administrator and by no viewer or member
  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

== every backup and restore route is administrator-only ==
  PASS  BACKUP: a signed-in viewer gets 403 on GET /backup.json
  PASS  BACKUP: a signed-in viewer gets 403 on GET /backup.sqlite
  PASS  BACKUP: a signed-in viewer gets 403 on GET /restore
  PASS  BACKUP: a signed-in viewer gets 403 on POST /restore
  PASS  BACKUP: a signed-in viewer gets 403 on POST /restore/commit
  PASS  BACKUP: a signed-in viewer gets 403 on POST /api-settings/backup-token
  PASS  BACKUP: a signed-in viewer without the token gets nothing from GET /backup/scheduled
  PASS  BACKUP: a signed-in member gets 403 on GET /backup.json
  PASS  BACKUP: a signed-in member gets 403 on GET /backup.sqlite
  PASS  BACKUP: a signed-in member gets 403 on GET /restore
  PASS  BACKUP: a signed-in member gets 403 on POST /restore
  PASS  BACKUP: a signed-in member gets 403 on POST /restore/commit
  PASS  BACKUP: a signed-in member gets 403 on POST /api-settings/backup-token
  PASS  BACKUP: a signed-in member without the token gets nothing from GET /backup/scheduled
  PASS  BACKUP: an admin still downloads the JSON backup

== the dashboard speaks this product's language ==
  PASS  WORDS: the main button reads "Open the training matrix", never "Open the what is …"
  PASS  WORDS: the training product keeps its own first-run step and Certora hand-off

== fix wave: the webhook buttons work ==
  PASS  webhooks: Add creates the endpoint (no 404)
  PASS  webhooks: Pause switches it off (no 404)
  PASS  webhooks: pressing it again switches it back on
  PASS  webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
  PASS  webhooks: Delete removes it (no 404)

== F2b: a failed check, through the screens ==
  PASS  UNCHANGED: a stray "fail" posted to the grid form is recorded as the completion it always was
  PASS  UNCHANGED: the grid has no failed state, no "Failed" filter or legend and no result field
  PASS  UNCHANGED: the round sheet asks for no result, and attendance still writes a pass
  PASS  UNCHANGED: the dashboard keeps its Expired tile
  PASS  FAILED: the REST door hands the posted result to the same reader as the other doors

-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
  PASS  demo ribbon: the read-only report link (Inspectora 3.2) does not tell its visitor they are signed in as the admin
  PASS  demo ribbon: Trainora has no public page, so a page served without sign-in keeps the admin wording too
  PASS  demo ribbon: an administrator's page keeps the admin wording
  PASS  demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
  PASS  demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
  PASS  backup: the JSON download carries none of the 12 credentials this schema holds (settings.oidc_client_secret, settings.backup_token_hash, settings.smtp_pass, users.password_hash, users.totp_secret, users.totp_recovery, people.portal_token_hash, api_keys.token_hash, webhooks.secret, backup_state.s3_secret, invites.token_hash, feed_tokens.token_hash)

————————————————————————————————————————————————————
  PASS  3.3 import dry run: the_upload_reaches_the_dry_run
  PASS  3.3 import dry run: the_dry_run_writes_nothing
  PASS  3.3 import dry run: the_dry_run_counts_only_what_will_import
  PASS  3.3 import dry run: the_dry_run_names_why
  PASS  3.3 import dry run: the_import_brings_in_exactly_what_the_dry_run_said

== 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)
  SKIP  updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
  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 product 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 (1 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 product 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: the owner's "keep" count is honored (2 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 product, 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)
  PASS  erase planting · sqlite: the subject is created and a marker is planted in all 14 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 (25 tables dumped), outside the 7 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the certificate scan they sent (declined, not evidence) is deleted from disk
  PASS  erase planting · sqlite: the erase page (/subjects/1) says what an erase keeps: "Kept: the name they typed to sign their own record, the cert…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the name they typed to sign their own record, the cert…"
ALL 306 TESTS PASSED (0 failed)

← Back to Trainora · Manual · Quickstart · API