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)