Visitora · Test run · run 2026-11-07
Visitora Test run, as shipped in the download
375 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]
== on site is derived, never stored ==
PASS happy: two sign-ins put two people on site (got 2, want 2)
PASS happy: onSite() returns the same people the count claims
PASS happy: the row carries the host and the sign-in time the list needs
PASS derived: there is NO is_onsite column to go stale
PASS derived: one definition of "on site", used by every reader
== signing out ==
PASS happy: signing out reports that THIS call did it
PASS happy: the person leaves the list (got 1, want 1)
PASS idempotent: a second sign-out does nothing and says so
PASS idempotent: the recorded departure time is not moved by the second call (got '2026-11-07 14:35:30', want '2026-11-07 14:35:30')
PASS idempotent: the count is unchanged by the second call (got 1, want 1)
PASS refusal: signing out a visit that does not exist is false, not a crash
== badges are reused, not exhausted ==
PASS happy: badges are handed out lowest-first (got 2, want 2)
PASS happy: a returned badge is the next one out again (got 1, want 1)
PASS happy: the badge is marked returned when its wearer signs out
PASS refusal: with every badge out, the next visitor gets 0 rather than a duplicate (got 0, want 0)
PASS refusal: that visitor is signed in with no badge, not turned away (got 0, want 0)
PASS refusal: nobody on site is wearing the same number as anybody else
== signing in refuses what it should ==
PASS refusal: somebody already inside cannot sign in twice (two rows, one body)
PASS happy: but somebody who has signed OUT can come back the same day
PASS refusal: a blank name is refused
PASS refusal: with require_host on, a visitor must choose somebody
PASS refusal: with rules required, they must be accepted
PASS refusal: an ARRAY where a name belongs is refused, never cast to the string "Array"
PASS refusal: a malformed email is refused
== one person, one row ==
PASS happy: a returning visitor reuses their row, matched on email (got 5, want 5)
PASS happy: the newest company is kept (got 'Northgate Security', want 'Northgate Security')
PASS happy: with no email, the phone matches — punctuation and spacing do not split a person
PASS refusal: a local number and an international one are NOT merged — the product cannot know they are the same person, and guessing would merge two strangers
PASS refusal: two people who gave neither email nor phone are NOT merged by name alone
PASS normalize: a number too short to dial is stored as nothing (got '', want '')
PASS dates: minusDays clamps a negative to zero — it can never reach FORWARDS, which is what makes it safe to hand a retention window straight to it (got '2026-03-10', want '2026-03-10')
PASS dates: and it goes backwards correctly across a month boundary (got '2026-02-25', want '2026-02-25')
PASS normalize: a leading + survives (got '+447700900111', want '+447700900111')
PASS normalize: the "(0)" in +44 (0)7700 … means drop that zero, so one person stays one row (got '+447700900111', want '+447700900111')
PASS normalize: a bare leading 0 is NOT dropped — without a country code it is part of the number (got '07700900111', want '07700900111')
PASS normalize: ordinary punctuation is stripped (got '+15550102030', want '+15550102030')
== accepted rules are self-contained ==
PASS happy: accepting the rules stores the exact text with the visit
PASS happy: and its fingerprint
PASS happy: the stored evidence verifies against itself
PASS immutable: changing the site rules cannot rewrite what somebody already accepted (got 'Wear your badge.', want 'Wear your badge.')
PASS immutable: and the old acceptance still verifies
PASS tamper-evident: an evidence row edited behind the app fails its fingerprint
PASS robust: a partial evidence row does not emit a PHP warning into the response
PASS robust: evidence with NO fingerprint is reported as not intact, never silently ok
PASS canon: CRLF and CR fold to LF so the same words hash the same everywhere (got 'a
b
c', want 'a
b
c')
== roll call (the hero interaction) ==
PASS happy: a fresh roll call has nobody accounted for (got 0, want 0)
PASS happy: everybody on site is missing until somebody says otherwise (got 4, want 4)
PASS happy: marking one person accounted for works
PASS happy: the tally moves by exactly one (got 1, want 1)
PASS happy: and the missing count follows it (got 3, want 3)
PASS idempotent: marking the same person again still reports success (two marshals, one list)
PASS idempotent: and does NOT move the time they were first accounted for (got '2026-11-07 14:35:31', want '2026-11-07 14:35:31')
PASS idempotent: the tally does not double-count (got 1, want 1)
PASS happy: a mark can be undone
PASS happy: undoing clears the time as well as the state (got NULL, want NULL)
PASS refusal: somebody who already left cannot be marked — they are not in the roll call
PASS refusal: a visit id that does not exist is refused, not created
PASS refusal: a state the product does not have is refused
PASS happy: resetting clears exactly the marks that were set (got 2, want 2)
PASS happy: after a reset nobody is accounted for (got 0, want 0)
PASS idempotent: resetting an already-clear roll call clears nothing (got 0, want 0)
== pre-registration ==
PASS happy: a pre-registration mints an unguessable token
PASS happy: it is valid on the day (got 'valid', want 'valid')
PASS happy: and on the following morning, so a delayed visitor is not turned away by arithmetic (got 'valid', want 'valid')
PASS refusal: two days later it has expired (got 'expired', want 'expired')
PASS happy: and it is valid before the day, for somebody who confirms early (got 'valid', want 'valid')
PASS happy: the real row minted above is valid today (got 'valid', want 'valid')
PASS refusal: a canceled pass is canceled whatever the date (got 'cancelled', want 'cancelled')
PASS refusal: a used pass is used (got 'used', want 'used')
PASS refusal: a date that is not a date is refused
PASS refusal: a host who does not exist is refused
== retention actually deletes ==
PASS happy: the old visit is in scope
PASS happy: purging removes it (got 1, want 1)
PASS happy: and everything attached to it
PASS happy: a visitor left with no visits at all goes too — no orphan identity survives
PASS refusal: nobody on site was touched, whatever the calendar says (got 4, want 4)
PASS idempotent: purging again deletes nothing (got 0, want 0)
PASS refusal: somebody still inside the building is never purged, however old the record
PASS retention: the date extract is driver-correct, not hard-coded to one engine
== durations read like a human wrote them ==
PASS duration: seconds (got '45s', want '45s')
PASS duration: minutes (got '10m', want '10m')
PASS duration: hours and minutes (got '2h 11m', want '2h 11m')
PASS duration: a whole hour drops the zero minutes (got '1h', want '1h')
PASS duration: over a day (got '1d 6h', want '1d 6h')
PASS duration: an open visit counts up to the moment asked about (got '2h 30m', want '2h 30m')
PASS duration: a clock that went backwards reads as zero, not as a negative (got '0s', want '0s')
== 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 kiosk sign-in door is public BY DESIGN and says so where it is routed
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 /mcp answers GET with 405 before the key check
PASS guard: there is NO tool or endpoint that signs a visitor IN
PASS guard: neither serializer leaks a visitor phone number or an IP
PASS guard: a phone number and an IP are redacted out of any backup that leaves the box
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 request that triggered it
PASS guard: every mail attempt, refused or not, writes an outbox row
PASS guard: the purge cron refuses to answer over HTTP
PASS guard: the cron deletes through the SAME engine method the button uses — no second rule
PASS guard: overlapping purges are refused by an atomic mkdir, not a lock file
PASS guard: the purge audit records a COUNT and never a name
PASS guard: erasing a person is refused while they are inside the building
PASS guard: the evacuation screen, the CSV and the print view all read ONE query
PASS guard: the muster CSV carries the time it was generated
PASS guard: the roll call works without JavaScript — every row is a real form post
PASS guard: only /onsite may enter the offline cache, and signing out clears it
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 can see the building and change nothing; a member runs the desk
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
== F2: the kiosk tells the visitor the truth about their host; every door fires its webhook ==
PASS kiosk: on a default install the host email does not go (mail is off) — and says why
PASS kiosk: when the email did not go, the visitor reads "Reception will let Sam Host know", not "has been told"
PASS kiosk: when the email went, the visitor reads "Sam Host has been told you are here."
PASS kiosk: the sign-in hands the real send result to the thank-you screen, which reads it once
PASS webhooks: every event offered in Settings is sent by the app (got array (
0 => 'delivery.received',
1 => 'visit.signed_in',
2 => 'visit.signed_out',
3 => 'visit.watchlist',
), want array (
0 => 'delivery.received',
1 => 'visit.signed_in',
2 => 'visit.signed_out',
3 => 'visit.watchlist',
))
PASS webhooks: the kiosk sign-in sends visit.signed_in; every sign-out door (kiosk, desk, REST, MCP, bulk, badge QR) sends visit.signed_out
PASS webhooks: a visit.signed_in delivery is attempted and logged (the receiver here refuses the connection)
PASS visit: a sign-in that names no source is recorded as the kiosk (it stored an empty source, with a PHP warning) (got 'kiosk', want 'kiosk')
PASS forms: every POST form on a screen reaches a registered route (59 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 outbox: a pre-registration link never lands in the outbox log, whatever happens to the email
PASS backup JSON: an administrator gets a backup, with every credential column planted (19)
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
== 1.1 · staff codes and links ==
PASS code: fewer than 4 digits refused (got 'A kiosk code is 4 to 8 digits.', want 'A kiosk code is 4 to 8 digits.')
PASS code: letters refused (got 'A kiosk code is 4 to 8 digits.', want 'A kiosk code is 4 to 8 digits.')
PASS code: a run or a repeat refused (got array (
0 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
1 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
2 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
), want array (
0 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
1 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
2 => 'Choose a code that is not a run or a repeat (1234, 0000…).',
))
PASS code: set (got '', want '')
PASS code: another member of staff cannot take the same code (got 'Another member of staff already uses that code.', want 'Another member of staff already uses that code.')
PASS code: stored as a keyed hash, never the digits
PASS code: looks the person up; a wrong or malformed code finds nobody
PASS code: a generated code is six digits and works
PASS link: 48 hex, stored as its SHA-256, finds its owner
PASS link: a new link replaces the old one
PASS a hidden (inactive) member of staff cannot tap in by code
== 1.1 · in and out (presence only) ==
PASS toggle: out -> in (got 'in', want 'in')
PASS signing in twice keeps one open row
PASS on site: the member of staff is on the list, with their name
PASS toggle: in -> out (got 'out', want 'out')
PASS signing out twice is one sign-out
PASS presence rows carry no hours or pay: the table is in/out, source and the roll-call mark only
== 1.1 · the roll call is everyone ==
PASS a contractor is recorded as one; anything else is a visitor (got array (
0 => 'contractor',
1 => 'visitor',
2 => 'visitor',
3 => 'visitor',
), want array (
0 => 'contractor',
1 => 'visitor',
2 => 'visitor',
3 => 'visitor',
))
PASS roll call: staff are counted with visitors and contractors
PASS roll call: marking a member of staff accounted for counts
PASS roll call: marking twice keeps the first time
PASS roll call: someone who has left cannot be marked
PASS roll call: a new drill clears staff marks too
PASS staff and visitors are "inside" by ONE rule (Visit::onSiteSql) — Presence has no second definition
PASS retention: finished staff presence rows older than the window go with the visits
== 1.1 · the host told in the team's chat ==
PASS chat: Slack, Teams and Google Chat get {"text": …} (got array (
'text' => 'Ava Thompson (Northwind) has signed in at reception for Priya Raman — badge 4.',
), want array (
'text' => 'Ava Thompson (Northwind) has signed in at reception for Priya Raman — badge 4.',
))
PASS chat: Discord gets {"content": …} (got array (
0 => 'content',
), want array (
0 => 'content',
))
PASS chat: a contractor is said so; sign-out is one line (got 'Contractor Ava Thompson (Northwind) has signed out.', want 'Contractor Ava Thompson (Northwind) has signed out.')
PASS chat: the signed JSON format is untouched (got NULL, want NULL)
PASS chat: the line never carries an email or a phone
PASS chat: every format offered is a real one
PASS backup: a staff code and a phone link never leave in the shareable backup
PASS texts: off by default, only through the owner's own gateway (no provider built in), and a host opts in alone
== 1.1 · the demo shows staff, contractors and visitors ==
PASS demo: staff on the roll call is on, four staff in, two contractors and two visitors inside (got array (
0 => 1,
1 => 4,
2 => 2,
3 => 2,
4 => 8,
), want array (
0 => 1,
1 => 4,
2 => 2,
3 => 2,
4 => 8,
))
PASS http: kiosk (staff off): no staff panel; the visitor/contractor choice is there
PASS http: owner signed in
PASS http: settings: the staff switch is there, and the text-message block starts off
PASS http: hosts page: presence controls for each person
PASS http: a kiosk code is generated and shown once
PASS http: a phone link is made and shown once
PASS http: kiosk (staff on): the staff panel with a numeric code box
PASS http: kiosk: the right code signs her in ("Good to see you, Priya")
PASS http: kiosk: wrong codes are refused, and after ten the screen is told to wait
PASS http: kiosk: once rate-limited, even the right code waits (the limit cannot be walked around)
PASS http: roll call: Staff, then Contractors, then Visitors
PASS http: roll call: one tally over all four
PASS http: roll call: a member of staff is marked accounted for, and the tally moves (1 of 4)
PASS http: CSV: a type column, staff first, contractor and visitor rows
PASS http: muster sheet: a Staff table and a visitors-and-contractors table
PASS http: phone link: shows "You are in" for Priya
PASS http: phone link: "I am leaving" signs her out
PASS http: phone link: an unknown link is a 404
PASS http: receptionist signed in
PASS http: a receptionist (member, host.edit) can manage presence on the hosts page
PASS http: API /api/on-site: staff listed and counted in on_site
PASS http: webhooks: the chat formats are offered
PASS http: Slack: the arrival arrives as one readable line, name, company, host and badge
PASS http: kiosk: with only a chat post, the visitor reads that it was posted to the team — not that the host was told
PASS http: security page: the shareable backup leaves out every webhook URL, and says to re-enter them
PASS http: the server log carries no PHP warning or fatal
PASS http: the walk made every check it planned (26)
== 1.1 pass B · wardens' phones ==
PASS wardens: the link is stored as a SHA-256, never itself
PASS wardens: a warden's tick goes through the same one rule as the staff's (Visit::markEvac / Presence::markEvac)
PASS demo: two fire wardens with links, after two resets (the links are never shown)
PASS demo: one watchlist entry (a contractor whose insurance lapsed), matched by nobody yet
PASS demo: the kiosk shows the badge with a Print button
== 1.1 pass B · the watchlist ==
PASS watchlist: names compare with case, accents, punctuation and word order ignored
PASS watchlist: matches by name (any order) or by email; not by one shared word
PASS watchlist: the chat line names the person and says "see Visitora" — never the note
== 1.1 pass B · deliveries ==
PASS deliveries: the chat line says what, for whom and from whom
PASS deliveries: the retention purge removes collected ones past the window and keeps any still waiting
PASS demo: deliveries on at the kiosk, one parcel waiting
PASS http (pass B): the owner and the viewer sign in
PASS http (pass B): roll call: the "Wardens' phones" section for whoever manages hosts
PASS http (pass B): a warden's link is made, shown once with a QR, then gone
PASS http (pass B): a viewer cannot make warden links
PASS http (pass B): the warden's phone: the roll call with no account — the list, tick forms to its own door, no staff tools or menu
PASS http (pass B): the warden's page: never indexed, never passes its address on, and its own home-screen manifest
PASS http (pass B): the manifest starts at the warden's own roll call
PASS http (pass B): two wardens tick people off from two phones at once; the tally is shared
PASS http (pass B): each tick is recorded under the warden's name
PASS http (pass B): the staff's roll call shows the wardens' ticks (2 accounted for, none missing)
PASS http (pass B): a revoked link, and one that never existed, answer "not found" — and change nothing
PASS http (pass B): offline: the service worker keeps each warden's roll call as it keeps /onsite
PASS http (pass B): settings: "The badge at the kiosk" (off, offer, print at once) with the any-printer note
PASS http (pass B): badge off (the default): the thank-you screen as before, no badge
PASS http (pass B): print at once: the kiosk shows the badge card (name, host, badge number) with a sign-out QR, and prints it on load
PASS http (pass B): the QR is stored only as a hash
PASS http (pass B): the badge prints once, and only on the kiosk that signed them in (not on a reload, not for anyone opening the address)
PASS http (pass B): scanned on the visitor's phone: "Leaving, Cleo?" with one button; never indexed, never passes its address on
PASS http (pass B): one tap signs her out: off the roll call, audited as the badge QR, and the page says so
PASS http (pass B): a barcode scanner at the kiosk's sign-out box reads the badge and signs him out
PASS http (pass B): reception's badge carries the QR too; printing it again replaces it (the old one answers "not found")
PASS http (pass B): an unknown code: "not signed in here", and nothing changes
PASS http (pass B): watchlist: the page for whoever manages hosts; a viewer cannot see it
PASS http (pass B): watchlist: added; the audit trail records the act, not the name or the note
PASS http (pass B): the kiosk says nothing different to her
PASS http (pass B): the visit is marked with the entry, and audited
PASS http (pass B): the desk email carries the reason (mail is off here, so it is recorded in the outbox as not sent, with the reason)
PASS http (pass B): reception sees it: a "watchlist" mark in the visit list and the reason on the visit
PASS http (pass B): someone who shares only a first name is not marked
PASS http (pass B): a subject-access export includes the watchlist entry about them
PASS http (pass B): the watchlist counts her matches
PASS http (pass B): removing the entry removes the mark from her visits
PASS http (pass B): deliveries off (the default): no delivery choice at the kiosk
PASS http (pass B): deliveries on: "Delivery for someone here?" and its form (who, what, company)
PASS http (pass B): the courier is thanked honestly: no email went, the team chat took it, so "posted to the team"
PASS http (pass B): the team chat line: "A delivery that needs a signature for Priya Raman is at reception (Swift Couriers)."
PASS http (pass B): for someone not on the list (typed name) it is kept; with nobody named it is refused
PASS http (pass B): a delivery is not a visit: nobody was added to the roll call
PASS http (pass B): reception: both waiting, each marked as told in team chat, and a count of 2 in the menu
PASS http (pass B): Priya's own phone page says a delivery is waiting for her
PASS http (pass B): a viewer sees the list but cannot mark it collected
PASS http (pass B): collected: it moves to the collected list with who took it; her phone page no longer mentions it; audited
PASS http (pass B): collecting twice changes nothing
PASS http (pass B): entrances: one per line, tidied, no repeats, at most 60 characters each; Settings shows each kiosk's address
PASS http (pass B): kiosk: opened once at its entrance's address, it names the door and remembers it in a cookie
PASS http (pass B): a sign-in at that kiosk records the entrance
PASS http (pass B): a name not on the list is not recorded, and a kiosk without an entrance shows none
PASS http (pass B): the roll call, both CSVs and the visit page show the door
PASS http (pass B): an entrance sent as a list is ignored, without a warning on the public page
PASS http (pass B): ?entrance= with nothing clears the kiosk's entrance
PASS http (pass B): settings: "Keep staff names off the kiosk" is there, off by default
PASS http (pass B): hidden: the kiosk carries no staff name anywhere, and asks visitors and couriers to type one
PASS http (pass B): a typed surname only one host has, or a full name in any case, finds the host
PASS http (pass B): a first name two hosts share finds nobody and is refused, naming no one; a posted host id is ignored
PASS http (pass B): the public thank-you screen names nobody
PASS http (pass B): host not required: a name that finds nobody is kept with the reason, for reception
PASS http (pass B): deliveries: a typed name finds the host; a posted host id alone is refused
PASS http (pass B): settings: a contractors' document, empty until written
PASS http (pass B): kiosk: the document is there for contractors, with its own box to accept
PASS http (pass B): a contractor who does not accept is not signed in; one who does has the text on the visit, intact; a visitor is not asked
PASS http (pass B): the server log carries no PHP warning or fatal
PASS http: the pass B walk made every check it planned (61)
PASS entrances (customer voice): one per line, trimmed, spaces folded, no repeats whatever the case, 60 characters, 12 at most
PASS entrances: the API and the webhooks carry the visit's entrance
PASS contractors' document (1.2): a contractor is refused until they accept it; accepted, it is kept on the visit with its fingerprint, beside the site rules; a visitor is not asked
PASS mcp roles: viewer_key_cannot_sign_out
PASS mcp roles: member_key_signs_out
PASS api roles: POST /api/visits/{id}/sign-out refuses every role without the permission (403) and admits the rest
== Text messages: the host told by text when a visitor arrives, through the owner's own SMS gateway ==
PASS texts: off by default — the switch, every host's opt-in and the visitor offer all start at 0; nothing reaches a gateway
PASS texts: a gateway address that is not https never counts as on
PASS gateway: JSON by default, fields to/from/text, the key as a Bearer token
PASS gateway: Twilio-style — To/Body form-encoded (no sender field when none is set), key as HTTP Basic
PASS consent: a host with a phone number on file but no opt-in gets no text
PASS consent: the host turns texts on with their own number (refused when it is not one); when is recorded
PASS arrival: the host's text names the business, the visitor and company, the badge, and a stop link — never the purpose
PASS once: the same arrival is never texted twice (claimed by its key before the gateway is called)
PASS kept: the stop token only as its SHA-256, the number only masked and hashed
PASS host page: with staff presence off but texts on, the phone link shows only the texts choice, with the number masked
PASS host page: turning on takes a typed number and an unticked box the host ticks
PASS host page: only the host's own link can turn texts on (the box is required); no administrator screen sets the opt-in
PASS stop: opening the link only asks; the page names the business and shows only part of the number
PASS stop: the button stops that number and turns the host's texts off; the next arrival is not texted
PASS stop: the host turning texts on again with their own number is a new request that lifts their stop
PASS consent: a host who turned texts off keeps the number on file but gets no text (the opt-in is in the query)
PASS failure: a refused or unreachable gateway is written on the text's row, and the sign-in carries on (never thrown)
PASS failure: Settings lists the latest texts with the gateway's answer; the key field never shows the key
PASS cap: past the daily limit nothing more goes today (a public kiosk cannot run up the bill)
PASS visitor: a confirmation only when the owner offers it AND the visitor ticked the box, once, naming the business, the host and a stop link
PASS visitor: a number that pressed stop gets no confirmation later, even when its owner ticks the box again on a new visit
PASS visitor: the page offers it unticked, and only when switched on
PASS retention: old text log rows go, and a visitor's number on an old pre-registration is cleared
PASS backup: the gateway key, a host's texting number and a text's stop token leave the shareable backup
PASS demo: the public demo never sends a text, and its text settings are read-only
————————————————————————————————————————————————————
== 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 Visitora 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 Visitora 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 Visitora, 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 13 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 (28 tables dumped), outside the 4 column(s) kept by ruling (possible legal records)
PASS erase planting · sqlite: another visitor's mail in the log is left as it was
PASS erase planting · sqlite: the erase page (/visitors/1) says what an erase keeps: "Kept: the purpose written on each visit, and an entry about …"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the purpose written on each visit, and an entry about …"
ALL 375 TESTS PASSED (0 failed)