Permora · Test run · run 2026-09-25
Permora Test run, as shipped in the download
328 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.
== 1. money: parse_cents / fmt_cents / clamp (permit fees are integer cents) ==
PASS "185.00" -> 18500 (got 18500, want 18500)
PASS "1,250" -> 125000 (got 125000, want 125000)
PASS "$8" -> 800 (got 800, want 800)
PASS "" -> null (got NULL, want NULL)
PASS "1.239" -> null (3dp) (got NULL, want NULL)
PASS 18500 -> "185.00" (got '185.00', want '185.00')
PASS 0 -> "0.00" (got '0.00', want '0.00')
PASS 15-digit clamps to 2e9 (got 2000000000, want 2000000000)
PASS clamp_cents floors at 0 (got 0, want 0)
PASS clamp_cents caps at 2e9 (got 2000000000, want 2000000000)
== 2. Permits::money + normalizeCurrency + enum normalizers/labels ==
PASS $185.00 (got '$185.00', want '$185.00')
PASS -$45.00 (sign before symbol) (got '-$45.00', want '-$45.00')
PASS EUR symbol (got '€250.00', want '€250.00')
PASS ZAR code prefix (got 'ZAR 200.00', want 'ZAR 200.00')
PASS normalize "usd" -> USD (got 'USD', want 'USD')
PASS normalize junk -> USD (got 'USD', want 'USD')
PASS normalizeTrade upper (got 'electrical', want 'electrical')
PASS normalizeTrade junk -> general (got 'general', want 'general')
PASS tradeLabel hvac -> HVAC (got 'HVAC', want 'HVAC')
PASS permitStatusLabel issued (got 'Issued', want 'Issued')
PASS normalizePermitStatus junk -> applied (got 'applied', want 'applied')
PASS resultLabel partial (got 'Partial', want 'Partial')
PASS normalizeResult FAIL -> fail (got 'fail', want 'fail')
PASS normalizeJobStatus junk -> active (got 'active', want 'active')
== 3. Permits::sumCents: fee totals in PHP, never SQL (overflow-clamped) ==
PASS sum of 3 permits = 60000 (got 60000, want 60000)
PASS empty -> 0 (got 0, want 0)
PASS missing key -> 0 (got 0, want 0)
PASS overflow sum clamps to 2e9 (got 2000000000, want 2000000000)
== 4. DATE MATH — daysUntil: exact across day/month/year/leap/DST boundaries ==
PASS same day -> 0 (got 0, want 0)
PASS tomorrow -> +1 (got 1, want 1)
PASS yesterday -> -1 (got -1, want -1)
PASS month boundary Jul31->Aug01 = 1 (got 1, want 1)
PASS year boundary Dec31->Jan01 = 1 (got 1, want 1)
PASS non-leap Feb28->Mar01 = 1 (2026) (got 1, want 1)
PASS leap Feb28->Mar01 = 2 (2028) (got 2, want 2)
PASS DST spring-forward Mar07->Mar09 = 2 (got 2, want 2)
PASS DST fall-back Oct31->Nov02 = 2 (got 2, want 2)
PASS full year Jan01->Dec31 = 364 (got 364, want 364)
PASS reverse full year = -364 (got -364, want -364)
PASS two years incl leap = 731 (got 731, want 731)
== 5. isOverdue: pending + scheduled strictly BEFORE today (today = not-yet-overdue) ==
PASS scheduled yesterday, pending -> overdue
PASS scheduled today, pending -> NOT overdue
PASS scheduled tomorrow, pending -> not overdue
PASS scheduled yesterday, PASS -> not overdue
PASS scheduled yesterday, FAIL -> not overdue
PASS scheduled long-past, PARTIAL -> not overdue
PASS no scheduled date -> not overdue
== 6. isUpcoming: pending + scheduled on/after today ==
PASS scheduled today, pending -> upcoming
PASS scheduled tomorrow, pending -> upcoming
PASS scheduled yesterday -> NOT upcoming
PASS scheduled ahead but PASS -> not upcoming
PASS no scheduled date -> not upcoming
== 7. expiryDays + expiryState: window classification at every threshold edge (60/30/7) ==
PASS expiryDays today = 0 (got 0, want 0)
PASS expiryDays +7 = 7 (got 7, want 7)
PASS expiryDays -1 = -1 (got -1, want -1)
PASS expiryDays empty = null (got NULL, want NULL)
PASS expiryDays invalid = null (got NULL, want NULL)
PASS no date -> none (got 'none', want 'none')
PASS -1 day -> expired (got 'expired', want 'expired')
PASS 0 days (today) -> critical (got 'critical', want 'critical')
PASS exactly 7 -> critical (got 'critical', want 'critical')
PASS 8 -> warning (got 'warning', want 'warning')
PASS exactly 30 -> warning (got 'warning', want 'warning')
PASS 31 -> notice (got 'notice', want 'notice')
PASS exactly 60 -> notice (got 'notice', want 'notice')
PASS 61 -> ok (got 'ok', want 'ok')
PASS state carries the day count (got array (
'state' => 'critical',
'days' => 3,
), want array (
'state' => 'critical',
'days' => 3,
))
PASS mis-ordered thresholds still classify sanely (got 'warning', want 'warning')
== 8. isExpiryAlert + expiryPhrase wording ==
PASS expired is an alert
PASS critical is an alert
PASS notice is an alert
PASS ok is NOT an alert
PASS none is NOT an alert
PASS phrase none (got 'No expiry date', want 'No expiry date')
PASS phrase expires today (got 'Expires today', want 'Expires today')
PASS phrase in 1 day (got 'Expires in 1 day', want 'Expires in 1 day')
PASS phrase in 2 days (got 'Expires in 2 days', want 'Expires in 2 days')
PASS phrase expired 1 day ago (got 'Expired 1 day ago', want 'Expired 1 day ago')
PASS phrase expired 3 days ago (got 'Expired 3 days ago', want 'Expired 3 days ago')
== 9. Re-inspection flow: a failed/partial inspection spawns a fresh pending re-inspection ==
PASS fail can re-inspect
PASS partial can re-inspect
PASS pass cannot re-inspect
PASS pending cannot re-inspect
PASS re-inspection same permit (got 3, want 3)
PASS re-inspection type copied (got 'Rough-in electrical', want 'Rough-in electrical')
PASS re-inspection is pending (got 'pending', want 'pending')
PASS re-inspection links to source (got 5, want 5)
PASS re-inspection scheduled today (got '2026-07-06', want '2026-07-06')
PASS re-inspection inspector reset (got '', want '')
PASS pass -> no re-inspection (got NULL, want NULL)
== 10. Permits::csvCell: numeric-aware formula-injection guard ==
PASS =SUM neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralised (got '\'+1', want '\'+1')
PASS -SUM neutralised (got '\'-SUM(A1)', want '\'-SUM(A1)')
PASS @cmd neutralised (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralised (got '\' x', want '\' x')
PASS cmd payload neutralised (got '\'=cmd|calc', want '\'=cmd|calc')
PASS CR payload quoted+prefixed (got '"\'
X"', want '"\'
X"')
PASS -45.00 kept numeric (got '-45.00', want '-45.00')
PASS -2 kept numeric (got '-2', want '-2')
PASS 1250 kept numeric (got '1250', want '1250')
PASS plain text untouched (got 'City of Portland', want 'City of Portland')
PASS mid-string = untouched (got 'a=b', want 'a=b')
PASS comma/quote RFC-4180 quoted (got '"Rough-in, ""phase 2"""', want '"Rough-in, ""phase 2"""')
== 11. Permits::toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Permit,AHJ,Fee', want 'Permit,AHJ,Fee')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative fee stays numeric in CSV
== 12. csrf_valid: empty-token bypass rejected (hash_equals('','') is TRUE) ==
PASS empty stored + empty given -> REJECTED
PASS empty stored + any given -> REJECTED
PASS matching token -> accepted
PASS mismatched token -> rejected
== 13. Pdf: produces a valid, non-trivial PDF document ==
PASS starts with %PDF header
PASS ends with %%EOF
PASS has xref table
PASS has a page object
PASS non-trivial length
PASS content stream inflates
PASS long text wraps rather than overflowing
PASS the inspection table reached the page
== 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 version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: csv_dry
PASS v2: csv_commit
PASS v2: expiry_states
PASS v2: expiry_alertset
PASS v2: deadline_rules
PASS v2: permit_serializer
PASS v2: permit_serializer_nocompute
PASS v2: normalizers
PASS v2: backup_tables
PASS v2: migrate() runs on every boot — pre-2.0 this product only built its schema in the installer
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: notifications + webhooks fire on the BROWSER path too, never fatally
PASS v2: API reuses the tested core (no re-derived guards)
PASS v2: deadlines + ?expiring exist and are computed, not stored
PASS v2: dark palette + no hardcoded white backgrounds outside @media print
PASS v2: theme.js + qr.js ship and layout loads theme.js + a toggle
PASS v2: shared modules are copy-identical to the shared core (never forked per app)
PASS v3: API.md ships and the sw cache is v3.1.5
-- Own It 3.0 --
PASS v3: role_cols
PASS v3: v3_settings_cols
PASS v3: v3_tables
PASS v3: v3_columns_typed
PASS v3: upgrade_restores_v3
PASS v3: upgrade_keeps_admin
PASS v3: roles_viewer_readonly
PASS v3: roles_coordinator_scope
PASS v3: roles_admin_all
PASS v3: last_admin_guard
PASS v3: audit_redacts_at_depth
PASS v3: audit_csv
PASS v3: audit_append_only
PASS v3: pdf_renders
PASS v3: ical_token
PASS v3: ical_two_tokens
PASS v3: ical_feed
PASS v3: branding_refuses_svg
PASS v3: branding_accent
PASS v3: branding_ink_is_aa
PASS v3: restore_refuses_other_app
PASS v3: mcp_initialize
PASS v3: mcp_tools_list
PASS v3: mcp_call
PASS v3: mcp_scalar_guard
PASS v3: mcp_unknown_arg
PASS v3: mcp_allowlist
PASS v3: mcp_domain_refusal
PASS v3: mcp_get_405
PASS v3: chain_only_fail_partial
PASS v3: chain_links_back
PASS v3: chain_refuses_from_pass
PASS v3: decide_pending_ok
PASS v3: decide_once_refuses
PASS v3: decide_once_no_write
PASS v3: result_enum_refuses
PASS v3: reinspect_refusal_is_atomic
PASS v3: gdpr_map_is_pii_only
PASS v3: gdpr_refuses_active
PASS v3: gdpr_export_finds_job
PASS v3: gdpr_anonymize_keeps_records
PASS v3: attachments_table
PASS v3: mcp_tool_names_real
PASS v3: mcp_readonly_annotations
PASS v3: backup_hides_bcrypt
PASS v3: backup_hides_token_hash
PASS v3: backup_hides_every_secret
PASS v3: backup_keeps_business_data
PASS v3: mcp_create_permit_sends_permit_created
PASS v3: mcp_verdict_sends_recorded_and_failed
PASS v3: re_recording_the_same_verdict_sends_nothing_more
PASS v3: fresh install · install_from_nothing
PASS v3: fresh install · schema_exists
PASS v3: fresh install · migrate_is_idempotent
PASS v3: fresh install · audit_table_after_boot
PASS v3: fresh install · settings_singleton_after_migrate
PASS v3: fresh install · settings_update_actually_persists
PASS v3: fresh install · settings_singleton_stays_singular
PASS v3: fresh install did not fatal
PASS v3: migrate() runs at boot, after the schemaExists guard
PASS v3: every route is registered BEFORE App::dispatch()
PASS v3: every core in src/ is REQUIRED by index.php (no autoloader here)
PASS v3: routes registered (mcp both verbs, audit, pdf, branding, both ics, gdpr, restore, users)
PASS v3: /permits/bulk is registered before the /permits/{id} patterns
PASS v3: the re-inspection rule is Permits::newReinspection(), not a second copy
PASS v3: MCP records results through the SHARED writer the screen uses
PASS v3: the SCREEN decides through the shared writer, not a second copy of the rule
PASS v3: no privileged route is left on the bare login guard
PASS v3: /backup.sqlite is carved out of the blanket .sqlite deny (dev router AND .htaccess)
PASS v3: requireKey refuses a deactivated user at the SQL layer (u.active = 1)
PASS v3: the admin can still administer, and the viewer still cannot
PASS v3: the decide-once UPDATE carries its own precondition (no lost update)
PASS v3: expiry/overdue come from Permits::, never re-derived with a date compare
PASS v3: the PDF is the core Pdf, and the old line writer is gone
PASS v3: GDPR anonymises jobs only, and only closed ones
PASS v3: every core is byte-identical to the shared core
PASS v3: every UI mutation is audited and role-gated
PASS v3: branding accent reaches the page and the admin nav is role-gated
PASS v3: the uploaded logo is RENDERED, with a URL the /media route can actually serve
PASS v3: both calendar links are RENDERED once after minting, not just stored
PASS v3: permit documents AND inspection photos are rendered on the job page
PASS v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
PASS v3: v3_mcp_spec() provides every key Mcp::handle() reads
PASS v3: every view gets the variables it reads from its controller
PASS v3: every admin-surface handler is permission-gated, not merely login-gated
PASS v3: minting and revoking an API key is admin-only and audited
PASS v3: the shareable backup redacts credentials, not just the SMTP password
PASS v3: password_hash and token_hash are in the redaction list
PASS v3: ONE redaction list — the manual, scheduled and audit paths all call v3_redact()
PASS v3: the restore page states the JSON-vs-.sqlite trade-off
PASS v3: Database::migrate() ensures the settings singleton
PASS v3: the installer writes its settings even though the row already exists
PASS v3: README documents the 3.0 release
PASS v3: API.md documents the MCP surface
PASS v3: deploy kit ships
PASS v3: the deploy healthcheck is the frozen template's, not a hand-written substitute
PASS v3: no REST handler writes on the bare key guard
PASS nav: every sidebar link is highlighted by the page it opens
PASS nav: no page highlights a sidebar item that does not exist
PASS ip: a direct visitor's forged headers are ignored
PASS ip: behind a trusted proxy the forwarded visitor is used
PASS ip: CF-Connecting-IP wins over the X-Forwarded-For chain
PASS ip: the chain is read right-to-left, past trusted hops
PASS ip: a Cloudflare edge is not a trusted proxy by default
PASS ip: CIDR maths (v4 boundaries + v6, no cross-family match)
== UI kit v1.0 · timeline verdict (POST /inspections/{id}/verdict) ==
PASS timeline verdict: the endpoint decides through the shared writer, not a second copy
PASS timeline verdict: pass normalizes to the product vocabulary (got 'pass', want 'pass')
PASS timeline verdict: its label is the product label (got 'Pass', want 'Pass')
PASS timeline verdict: a pass cannot spawn a re-inspection, a fail can
PASS timeline verdict: an unknown verdict is refused by the shared writer (422)
PASS timeline verdict: an already-decided visit is refused (409), never overwritten
PASS timeline verdict: the endpoint answers JSON with the refusal status, not an HTML page
PASS timeline verdict: requires inspection.edit + CSRF
PASS timeline verdict: the verdict is written to the audit trail
PASS timeline verdict: route registered and the timeline posts to it
== v3.1 · permit & inspection documents ==
PASS docs: a permit PDF is recognised by its bytes (got 'application/pdf', want 'application/pdf')
PASS docs: a site photo is still recognised (got 'image/png', want 'image/png')
PASS docs: paper and pictures are distinguished for display
PASS docs: a real PDF is stored
PASS docs: the stored name is minted, so an upload cannot choose its own extension
PASS docs: a non-permitted type is refused by CONTENT, not by name
PASS docs: an oversized file is refused and the cap is stated
PASS docs: uploads go through Attach, not the 512 KB image-only logo helper
PASS docs: the sniffed mime and size are RECORDED on the attachment row
PASS docs: upload demands attachment.upload + CSRF
PASS docs: delete demands attachment.delete + CSRF
PASS docs: media_file serves the RECORDED mime, and still fails closed on anything else
PASS docs: media stays login-gated and nosniff
== v3.1 · calendar feed revoke (P4 gap) ==
PASS feeds: both .ics routes already existed (verified, not rebuilt)
PASS feeds: an empty stored hash refuses every token — which is what revoke relies on
PASS feeds: a live hash accepts only its own token
PASS feeds: revoke CLEARS the hash rather than rotating it
PASS feeds: revoke demands settings.edit + CSRF
PASS feeds: revoking one feed cannot touch the other (the column is chosen from a whitelist)
PASS feeds: revoking twice is harmless — it clears a column, it does not delete or throw
PASS feeds: the revoke is audited and the once-shown URL is dropped from the session
PASS feeds: route registered and the settings page offers it
PASS scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
PASS scope: requireKey reads the key scope and refuses a write on a read-only key
PASS scope: mintKey persists the chosen scope, and anything but read is full access
PASS scope: /mcp hands the key scope to the core, which refuses write tools per tool
PASS scope: the key form lets an administrator mint a read-only key
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
PASS docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended licence
PASS webhooks: the Settings page answers and lists every event it offers
PASS webhooks: the Add, Pause, Resume and Delete buttons each reach a route and do what they say
PASS webhooks: a paused webhook is sent nothing
PASS webhooks: permit.created is sent by the browser action where it happens [302]
PASS webhooks: inspection.recorded is sent by the browser action where it happens [302]
PASS webhooks: inspection.failed is sent by the browser action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: a verdict recorded on a scheduled visit sends inspection.recorded once per verdict
PASS backup gate: the app answered under PHP's built-in server with no PHP error in its log
PASS backup gate: a signed-in viewer gets 403 on /backup.json and /backup.sqlite, and nothing is served
PASS backup gate: a signed-in coordinator gets 403 on /backup.json and /backup.sqlite, and nothing is served
PASS backup gate: the administrator gets both backups (JSON and the SQLite file)
PASS backup gate: the JSON backup leaves out the 2FA seed, SMTP password, webhook secret and SSO secret
PASS backup gate: the scheduled-backup route refuses a missing and a wrong token
PASS backup gate: a signed-in viewer gets 403 on the restore page and on POST /restore/upload, /restore/commit, /restore/token [{"page":403,"posts":{"\/restore\/upload":403,"\/restore\/commit":403,"\/restore\/token":403},"csrf":true}]
PASS backup gate: a signed-in viewer without the token is refused by the scheduled-backup route
PASS backup gate: a signed-in coordinator gets 403 on the restore page and on POST /restore/upload, /restore/commit, /restore/token [{"page":403,"posts":{"\/restore\/upload":403,"\/restore\/commit":403,"\/restore\/token":403},"csrf":true}]
PASS backup gate: a signed-in coordinator without the token is refused by the scheduled-backup route
PASS backup gate: the administrator opens the restore page and is not refused on any restore or backup-token route [{"page":200,"posts":{"\/restore\/upload":302,"\/restore\/commit":302,"\/restore\/token":302}}]
PASS backup gate: the JSON backup carries no reusable credential from any table (11 columns: api_keys.token_hash, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_expiry_hash, settings.ical_inspection_hash, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
PASS backup gate: each planted credential row is in the backup with the cell redacted, not dropped
PASS links: https behind a trusted TLS proxy (X-Forwarded-Proto or CF-Visitor); a direct client cannot claim it; plain http stays http
PASS links: nothing builds a scheme from $_SERVER[HTTPS] alone any more (src, controllers, views)
PASS demo ribbon: every page carries the admin wording (the product has no public page)
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens the card, and that choice holds for the session
PASS demo ribbon: injected just before </body>; output with no </body> (JSON, CSV, PDF) passes through untouched
————————————————————————————————————————————————————
ALL 328 TESTS PASSED (0 failed)
← Back to Permora · Manual · Quickstart · API