Onboardora · Test run · run 2026-11-07
Onboardora Test run, as shipped in the download
284 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]
== due dates are derived, never stored ==
PASS date: day 0 is the start date itself (got '2026-06-01', want '2026-06-01')
PASS date: a positive offset moves forwards (got '2026-07-01', want '2026-07-01')
PASS date: a negative offset moves backwards (got '2026-05-25', want '2026-05-25')
PASS date: it crosses a month end correctly (got '2026-02-01', want '2026-02-01')
PASS date: and a leap day (got '2028-02-29', want '2028-02-29')
PASS date: and a year end (got '2027-01-04', want '2027-01-04')
PASS between: forwards is positive (got 14, want 14)
PASS between: backwards is negative (got -14, want -14)
PASS between: the same day is zero (got 0, want 0)
PASS label: the first morning reads as day 1 (got 'day 1', want 'day 1')
PASS label: an offset of four reads as day 5 (got 'day 5', want 'day 5')
PASS label: before they arrive, it counts down (got '7 days before', want '7 days before')
PASS label: and the singular is right (got '1 day before', want '1 day before')
PASS phase: negative is before they arrive (got 'before', want 'before')
PASS phase: zero is day one (got 'day-one', want 'day-one')
PASS phase: the first week ends at day 7 (got 'week-one', want 'week-one')
PASS phase: the first month ends at day 30 (got 'month-one', want 'month-one')
PASS phase: and everything after is the ninety (got 'ninety', want 'ninety')
PASS clean: an offset out of range is clamped, not accepted (got 365, want 365)
PASS clean: and on the other side (got -60, want -60)
PASS clean: nonsense reads as day one, not as a crash (got 0, want 0)
== a starter's plan is a snapshot of the checklist ==
PASS happy: a checklist stores its tasks as a PATTERN (starter_id NULL) (got 5, want 5)
PASS happy: adding a starter COPIES the tasks on to them (got 5, want 5)
PASS happy: and the pattern is untouched (got 5, want 5)
PASS immutable: rewriting the checklist does NOT change a plan already in flight (got 5, want 5)
PASS immutable: and the live plan still has its own task titles (got 1, want 1)
PASS immutable: while the NEXT starter gets the new one (got 1, want 1)
== applying a checklist is additive, not duplicating ==
PASS idempotent: re-applying the same checklist adds only what is missing (got 0, want 0)
PASS idempotent: so the plan is unchanged (got 5, want 5)
PASS happy: a SECOND checklist adds to the plan rather than replacing it (got 1, want 1)
PASS happy: so the plan now carries both (got 6, want 6)
== the plan reads its own dates ==
PASS happy: the plan knows how many tasks it has (got 6, want 6)
PASS happy: nothing is done yet (got 0, want 0)
PASS happy: it is 10 days before they start (got -10, want -10)
PASS happy: so the status derives to planned (got 'planned', want 'planned')
PASS happy: a task carries a due date derived from the start date
PASS happy: start + (-7) is right (got '2026-06-18', want '2026-06-18')
PASS happy: it is not late — it is not due for another three days
PASS happy: and the timeline knows which band it belongs in (got 'before', want 'before')
PASS derived: moving the start date moves every due date with it, with nothing stored to migrate (got '2026-06-28', want '2026-06-28')
PASS derived: there is NO due_date or is_late column to go stale
== late is derived too ==
PASS happy: five days in, the status derives to active (got 'active', want 'active')
PASS happy: everything due before today and not done is late (got 3, want 3)
PASS happy: and the plan says so (got 3, want 3)
PASS happy: the chase list finds them across everybody
PASS happy: worst first — the longest outstanding is at the top
PASS refusal: a task that is not due yet is not on the chase list
== ticking a task ==
PASS happy: ticking a task reports that it changed
PASS happy: it records when, and who
PASS happy: the plan counts it (got 1, want 1)
PASS happy: and it stops being late
PASS idempotent: ticking it again still reports success (two people, one checklist)
PASS idempotent: and does NOT move the time it was done (got '2026-11-07 14:41:17', want '2026-11-07 14:41:17')
PASS idempotent: nor the name of who did it first (got 'Marcus', want 'Marcus')
PASS idempotent: and the count does not double (got 1, want 1)
PASS happy: un-ticking works
PASS happy: and clears both the time and the name
PASS refusal: a task id that does not exist is refused, not created
PASS refusal: a TEMPLATE task cannot be ticked — a pattern is not somebody's work
== reassigning a task (the hero interaction) ==
PASS happy: reassigning reports that it changed
PASS happy: and names both ends, so the audit line means something later (got 'IT -> HR', want 'IT -> HR')
PASS happy: the row really moved (got 'hr', want 'hr')
PASS idempotent: reassigning to where it already is changes nothing and says so
PASS idempotent: and still reports where it is (got 'HR', want 'HR')
PASS happy: it can go to a named person
PASS happy: with their address kept for the reminder (got 'ros@test.co', want 'ros@test.co')
PASS refusal: a named owner with no name is refused — "who has this?" must stay answerable
PASS refusal: a malformed address is refused
PASS refusal: an owner kind the product does not have falls back to the manager, never to nothing
PASS refusal: a task that does not exist is refused
PASS refusal: a TEMPLATE task cannot be reassigned — a pattern has a default, not an owner
PASS permission: moving to a non-named owner clears the stale name and address behind it
== who a reminder goes to ==
PASS owner: a manager task goes to the manager (got 'marcus@test.co', want 'marcus@test.co')
PASS owner: an HR task goes to the HR address in settings (got 'hr@test.co', want 'hr@test.co')
PASS owner: an IT task goes to the IT address (got 'it@test.co', want 'it@test.co')
PASS owner: a named task goes to the address on the task (got 'ros@test.co', want 'ros@test.co')
PASS refusal: a buddy has nowhere to go — the product does not invent one (got '', want '')
PASS refusal: a malformed stored address is not used (got '', want '')
PASS owner: a task owned by THE STARTER goes to their personal address — their work mailbox does not exist until IT has done its own task (got 'ade.personal@example.com', want 'ade.personal@example.com')
PASS owner: with no personal address it falls back to the work one (got 'ade@test.co', want 'ade@test.co')
== status is derived, except the one decision a person made ==
PASS happy: every task done means complete — not "after 90 days" (got 'complete', want 'complete')
PASS happy: somebody who finished early IS finished (got 'complete', want 'complete')
PASS happy: canceled is the ONE status a person decides, so it wins over the derivation (got 'cancelled', want 'cancelled')
PASS happy: a starter with no tasks at all is not "complete" by accident (got 'active', want 'active')
== adding a starter refuses what it should ==
PASS refusal: a blank name is refused
PASS refusal: a start date that is not a date is refused
PASS refusal: an impossible date is refused
PASS refusal: a malformed email is refused
PASS refusal: a malformed MANAGER email is refused too
PASS refusal: a checklist that does not exist is refused rather than silently ignored
PASS refusal: an ARRAY where a name belongs is refused, never cast to the string "Array"
PASS happy: a starter with NO checklist is allowed — most people write one afterward
PASS refusal: a checklist with no name is refused
== documents and equipment ==
PASS happy: marking a document returned works
PASS idempotent: marking it again reports success
PASS idempotent: and does not move the time it came back (got '2026-11-07 14:41:19', want '2026-11-07 14:41:19')
PASS refusal: a document that does not exist is refused
PASS refusal: something can only come back if it went out
PASS happy: issuing it works
PASS happy: and now it can be returned
PASS happy: both facts are kept separately — issued AND returned
PASS happy: un-issuing also clears the return — it cannot have come back if it never went
== the board is one query everybody reads ==
PASS happy: the board has every non-canceled starter
PASS happy: with counts and a percentage on each row
PASS happy: a canceled starter is off the board
PASS happy: and their tasks are off the chase list too
== guards the runtime cannot prove ==
PASS guard: every App:: method the controllers call actually exists
PASS guard: every routed handler is defined
PASS guard: every POST route that mutates is CSRF-checked and permission-gated
PASS guard: the audit trail records only REAL changes, never a no-op tick
PASS guard: every read-only MCP tool declares readOnly, so a read key can actually see it
PASS guard: the MCP endpoint applies the key scope, and GET /mcp answers 405 before the key check
PASS guard: there is NO tool or endpoint that CREATES a starter
PASS guard: no serializer leaks the address somebody applied with
PASS guard: and a backup redacts it, along with every credential
PASS guard: outbound mail is gated on demo mode BEFORE any credential is read
PASS guard: mail needs BOTH the master switch and the event flag, and both default to 0
PASS guard: a mail failure can never break the thing that triggered it
PASS guard: every mail attempt, refused or not, writes an outbox row
PASS guard: a task is reminded ONCE — the stamp is written only on a successful send
PASS guard: the reminder cron refuses to answer over HTTP
PASS guard: the cron sends through the SAME function the app's button uses — no second message
PASS guard: the cron loads BOTH controllers, so it cannot fatal on its first real pass
PASS guard: overlapping cron runs are refused by an atomic mkdir, not a lock file
PASS guard: erasing somebody is refused while their onboarding is still running
PASS guard: no standing statement is written as a non-error .main > .alert — the kit turns those into a five-second toast and the message evaporates
PASS guard: no page reaches an external host — fonts and scripts are bundled
PASS guard: the API key scope is enforced at the door, once, not per route
PASS guard: api_keys.scope is added by migrate() with DEFAULT full (an upgrade changes nothing)
PASS guard: the one write on the REST surface also consults the ROLE, not just the key
PASS guard: a viewer sees the board and changes nothing; a member works the list
PASS webhooks: every door that ticks a task (browser, REST, agent) fires through the one helper
PASS webhooks: the helper really delivers task.completed and task.reopened (logged per endpoint)
PASS dashboard: a zero change vs the quarter before carries no arrow
PASS guard: the shipped version string is one value, everywhere it is claimed
PASS guard: no OTHER product's name survives anywhere in the tree
PASS guard: the shared core modules were not forked for this product
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 demo ribbon: every path renders the card with the admin wording (no public page)
PASS demo ribbon: below 600px it starts as the small pill; a tap opens it and the choice holds for the session
PASS demo ribbon: a response with no </body> (CSV, JSON, PDF) passes through untouched
PASS forms: every POST form on a screen reaches a registered route (45 checked)
PASS webhooks: ticking tasks in bulk sends task.completed for each task that moved, and for no other
PASS webhooks: the bulk handler goes through the helper that fires
PASS backups: every non-admin role gets 403 on every backup, restore and backup-token route
PASS backup JSON: an administrator gets a backup, with every credential column planted (13)
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 the JSON backup leaves out webhook URLs too (ruling 1, 2026-09-25)
PASS security page: the backup card says every credential is redacted, not only the SMTP password
PASS starter page: link_resolves_its_starter_only
PASS starter page: link_is_stored_hashed_and_redacted
PASS starter page: typed_name_match_is_forgiving_but_exact
PASS starter page: upload_bytes_decide_the_type
PASS starter page: starter_ticks_own_task
PASS starter page: starter_cannot_tick_others_tasks
PASS starter page: acknowledgement_needs_tick_and_own_name
PASS starter page: acknowledgement_is_recorded
PASS starter page: acknowledgement_is_once
PASS starter page: acknowledge_only_own_ack_documents
PASS starter page: upload_without_a_file_changes_nothing
PASS starter page: wrong_link_is_refused
PASS starter page: revoked_link_stops_at_once
PASS starter page: erasure_removes_uploads_and_typed_names
PASS starter page: document_received_is_subscribable
PASS starter page: team_file_route_is_gated
PASS 1.1 extras: tags_are_cleaned
PASS 1.1 extras: template_keeps_one_clean_tag_per_task
PASS 1.1 extras: tagged_starter_gets_the_conditional_task
PASS 1.1 extras: untagged_starter_gets_everyone_tasks_only
PASS 1.1 extras: two_tags_two_conditional_tasks
PASS 1.1 extras: long_tags_fit_whole
PASS 1.1 extras: a_tag_matches_whole_not_part
PASS 1.1 extras: retag_then_reapply_adds_the_match_once
PASS 1.1 extras: checklist_without_conditions_is_unchanged
PASS 1.1 extras: edit_saves_tags_and_an_older_form_keeps_them
PASS 1.1 extras: api_lists_the_tags
PASS 1.1 extras: tags_are_erased_with_the_person
PASS 1.1 extras: chat_hook_gets_one_line
PASS 1.1 extras: signed_hook_still_gets_the_document
PASS 1.1 extras: task_door_sends_the_line
PASS 1.1 extras: reopen_says_reopened
PASS 1.1 extras: chat_line_carries_no_email_or_note
PASS 1.1 extras: settings_form_stores_the_format
PASS 1.1 extras: a_hook_added_before_1_1_is_signed
PASS 1.1 extras: trainora_header_is_its_import
PASS 1.1 extras: trainora_row_is_work_email_role_and_start
PASS 1.1 extras: trainora_leaves_out_cancelled_and_erased
PASS 1.1 extras: trainora_file_downloads_formula_safe
PASS 1.1 extras: trainora_route_is_read_gated
PASS 1.1 extras: mcp_viewer_key_cannot_complete_or_reassign
PASS 1.1 extras: mcp_member_key_still_can
PASS 1.2 starter pack: pack_reads_back_with_crc
PASS 1.2 starter pack: pack_holds_readme_lists_and_files
PASS 1.2 starter pack: file_is_the_uploaded_bytes_under_a_safe_name
PASS 1.2 starter pack: a_missing_file_is_said_not_invented
PASS 1.2 starter pack: a_row_cannot_point_outside_uploads
PASS 1.2 starter pack: acknowledgement_is_in_the_documents_list
PASS 1.2 starter pack: another_starters_file_is_not_in_it
PASS 1.2 starter pack: checklist_has_due_dates_and_who_did_it
PASS 1.2 starter pack: csv_is_formula_safe
PASS 1.2 starter pack: equipment_is_listed
PASS 1.2 starter pack: the_route_streams_the_zip
PASS 1.2 starter pack: the_download_is_audited
PASS 1.2 starter pack: everything_is_for_administrators
PASS 1.2 starter pack: everything_has_a_folder_per_starter
PASS 1.2 starter pack: the_front_controller_loads_the_writer
PASS 1.2 starter pack: the_pages_offer_it
PASS api roles: POST /api/tasks/{id}/complete refuses every role without the permission (403) and admits the rest
————————————————————————————————————————————————————
== 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 Onboardora 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 Onboardora 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 Onboardora, 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 10 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 document they uploaded is deleted from disk
PASS erase planting · sqlite: a staff member's "done by" on their plan and another starter's reminder are left as they were
PASS erase planting · sqlite: the erase page (/starters/1) says what an erase keeps: "Kept: the audit trail's lines for what they did on their own…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the audit trail's lines for what they did on their own…"
ALL 284 TESTS PASSED (0 failed)