Ownware
Home›Visitora›Test run
Visitora · Test run · run 2026-09-25

Visitora Test run, as shipped in the download

138 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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-09-25 00:19:00', want '2026-09-25 00:19:00')
  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  normalise: 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  normalise: a leading + survives (got '+447700900111', want '+447700900111')
  PASS  normalise: the "(0)" in +44 (0)7700 … means drop that zero, so one person stays one row (got '+447700900111', want '+447700900111')
  PASS  normalise: a bare leading 0 is NOT dropped — without a country code it is part of the number (got '07700900111', want '07700900111')
  PASS  normalise: 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-09-25 00:19:00', want '2026-09-25 00:19:00')
  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 cancelled pass is cancelled 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  licence: every font ships the SIL OFL text for its own family beside it
  PASS  licence: 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 => 'visit.signed_in',
  1 => 'visit.signed_out',
), want array (
  0 => 'visit.signed_in',
  1 => 'visit.signed_out',
))
  PASS  webhooks: the kiosk sign-in sends visit.signed_in; every sign-out door (kiosk, desk, REST, MCP, bulk) 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 (36 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 (12)
  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

————————————————————————————————————————————————————
ALL 138 TESTS PASSED (0 failed)

← Back to Visitora · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →