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

Inspectora Test run, as shipped in the download

305 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 'assets', want 'assets')
  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: the checklist inspection with photos, the report of one inspection, QR labels, certificates, assigned schedules ==
  PASS  3.2: steps_parsed
  PASS  3.2: result_needs_every_answer
  PASS  3.2: one_failed_step_fails_it
  PASS  3.2: no_checklist_uses_the_posted_result
  PASS  3.2: incomplete_writes_nothing
  PASS  3.2: answers_kept_in_order_and_fail_recorded
  PASS  3.2: a_pass_starts_the_clock
  PASS  3.2: engine_refusals_still_apply
  PASS  3.2: schedule_row_marked_done_once
  PASS  3.2: photo_filed_as_evidence_by_its_bytes
  PASS  3.2: photo_size_limit
  PASS  3.2: report_pdf_of_one_inspection
  PASS  3.2: link_opens_that_inspection_only
  PASS  3.2: a_new_link_stops_the_old
  PASS  3.2: withdrawn_link_opens_nothing
  PASS  3.2: share_mail_names_the_result_and_link
  PASS  3.2: certificate_rows
  PASS  3.2: labels_active_assets_by_group
  PASS  3.2: only_members_and_admins_can_be_assigned
  PASS  3.2: my_inspections_lists_what_is_left
  PASS  3.2: a_held_schedule_leaves_the_list
  PASS  3.2: unassign
  PASS  3.2: assignee_from_the_create_form
  PASS  3.2: routes_registered_only_for_inspectora
  PASS  3.2: writes_are_gated
  PASS  3.2: schema_is_gated
  PASS  3.2: a_send_is_claimed_once_released_on_refusal_and_a_dead_claim_taken_over
  PASS  3.2: a viewer cannot open the inspection form; a member sees every step
  PASS  3.2: posting with a step unanswered records nothing and says why
  PASS  3.2: answered in full, one failed step records a fail with both answers
  PASS  3.2: the read-only link opens that report with no login, kept out of search engines
  PASS  3.2: a wrong link answers 404 and names nothing
  PASS  3.2: a viewer cannot make or withdraw a link
  PASS  3.2: a viewer can print the report of one inspection
  PASS  3.2: a failed inspection has no certificate
  PASS  3.2: the QR label sheet lists the assets
  PASS  3.2: "My inspections" shows the member their assigned schedule with an Inspect button
  PASS  3.2: deleting an inspection takes its checklist answers with it, and its link opens nothing
  PASS  3.2: a viewer cannot assign a schedule
  PASS  3.3: the phone form carries a one-off send token and keeps its answers on the phone as they are filled in
  PASS  3.3: the same send twice (its answer lost the first time) is recorded once, and the second is told so
  PASS  3.3: a refused send gives its token back, so the corrected form with the same token is recorded
  PASS  3.3: the record page clears the phone's kept copy of that send only, and only when it says which

== 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 due board", never "Open the what is …"
  PASS  WORDS: the first-run step asks for the inspections already done, not what people hold
  PASS  WORDS: the certificates box describes evidence on the record, with no Certora hand-off
  PASS  WORDS: the assets register is not drawn with a person icon
  PASS  UI: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)

== 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  FAILED: the grid form records a fail, and the cell comes back failed (got 'failed', want 'failed')
  PASS  FAILED: the stored record says fail and carries no next-due date
  PASS  FAILED: the grid paints the cell failed, offers a "Failed" filter and legend, and the form asks for the result
  PASS  FAILED: a round sheet asks for each row's result, and a fail on it is recorded as a fail
  PASS  FAILED: the dashboard counts failed checks with the expired ones
  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: Inspectora 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, records.share_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 (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 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 1 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)
ALL 305 TESTS PASSED (0 failed)

← Back to Inspectora · Manual · Quickstart · API