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

Permora Test run, as shipped in the download

457 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. 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 neutralized (got '\'=SUM(A1)', want '\'=SUM(A1)')
  PASS  +1 neutralized (got '\'+1', want '\'+1')
  PASS  -SUM neutralized (got '\'-SUM(A1)', want '\'-SUM(A1)')
  PASS  @cmd neutralized (got '\'@cmd', want '\'@cmd')
  PASS  leading TAB neutralized (got '\'	x', want '\'	x')
  PASS  cmd payload neutralized (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 v4.1.3

-- 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 anonymizes 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 recognized by its bytes (got 'application/pdf', want 'application/pdf')
  PASS  docs: a site photo is still recognized (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  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  docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended license
  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 (14 columns: api_keys.token_hash, backup_state.s3_secret, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_expiry_hash, settings.ical_inspection_hash, settings.alerts_secret, updater_state.license_api_key, 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

== 3.2 · permit alerts that go out ==
  PASS  alerts: OFF on a fresh install — a run sends nothing and writes nothing [sqlite]
  PASS  alerts: the permits listed are the ones in a window or expired, worst first (not an ok one, not a closed job's) [sqlite]
  PASS  alerts: inspections tomorrow and overdue inspections are listed (a passed one never is) [sqlite]
  PASS  alerts: an active job with a live permit and no inspection booked is listed [sqlite]
  PASS  alerts: the preview shows exactly who gets what, and writes nothing [sqlite]
  PASS  alerts: the office digest goes once, claimed with its reference (demo: recorded, no socket) [sqlite]
  PASS  alerts: a second run the same day sends nothing [sqlite]
  PASS  alerts: after it went, the preview says "already sent today", not "goes out today" [sqlite]
  PASS  alerts: with job leads on, each lead gets their own jobs' items only, and a closed job's lead nothing [sqlite]
  PASS  alerts: the email names what is on it and carries its own stop link [sqlite]
  PASS  alerts: a stop link is signed per address (case does not matter) [sqlite]
  PASS  alerts: an address that said stop is never written to again; the others still are [sqlite]
  PASS  alerts: through a real SMTP conversation, the digest leaves to the office with the permits on it [sqlite]
  PASS  alerts: a refused send is recorded as failed and gives its claim back [sqlite]
  PASS  alerts: nothing on it, no email [sqlite]
  PASS  door: the alerts preview opens for an admin and sends nothing
  PASS  door: a coordinator cannot open the preview or switch alerts on (settings.edit)
  PASS  door: /cron/alerts without the token is refused
  PASS  door: Settings carries the alerts card
  PASS  door: a stop link with a wrong token is a 404
  PASS  door: the stop-link secret is minted once, 48 hex characters
  PASS  door: a valid stop link shows a button and changes nothing on its own (a link scanner's GET is harmless)
  PASS  door: pressing it stops the alerts for that address
  PASS  door: a job saves its lead and the lead's email

== 3.2 pass B · pull-by, AHJ comments, internal checks, jobs from Jobora ==
  PASS  pull-by: soon, today, passed, far off, and "pulled" once issued [sqlite]
  PASS  pull-by: a permit to pull soon or late joins the daily alerts until it is pulled [sqlite]
  PASS  comments: what the AHJ asked for, a real date received (not ahead), a real follow-up date [sqlite]
  PASS  comments: an open comment whose follow-up day has come joins the alerts, with the AHJ named; a later one waits [sqlite]
  PASS  comments: addressed once, with what was done, and it leaves the alerts [sqlite]
  PASS  checks: a saved list copied onto an inspection; tomorrow's alert says how many are still open [sqlite]
  PASS  checks: answered, they leave the alert; the inspector's result is never blocked by them [sqlite]
  PASS  alerts: the digest names permits to pull and AHJ follow-ups [sqlite]
  PASS  jobora: only a plain http(s) address, no credentials, query or fragment [sqlite]
  PASS  jobora: reads /api/jobs with the key, keeps live jobs only, and says so when the key is refused [sqlite]
  PASS  jobora: each job comes in once, with its number, title, client and site [sqlite]
  PASS  door: the permit form saves the pull-by date
  PASS  door: a viewer cannot record an AHJ comment; a coordinator can
  PASS  door: the comment is addressed with what was done, and the permit page shows both
  PASS  door: checks are added and ticked on the inspection, and the job page shows how many are done
  PASS  door: only an administrator sets the Jobora connection and the check lists
  PASS  door: with no Jobora connection, the import page says what to set and fetches nothing
  PASS  backup: the Jobora key is kept out of the JSON backup (it is on the redaction list)

== 3.3 · permit deposits held by the AHJ, and the reminder to get them back ==
  PASS  deposits: none, held (work not finished), waiting, request the final, ask for the refund, refunded; chased N days after the finish [sqlite]
  PASS  deposits: only a passed inspection named "final" counts as the final passed (a failed final or a passed rough-in does not) [sqlite]
  PASS  deposits: a deposit still held after the chase day joins the daily alerts, a closed job's too; not before, not with none, not once refunded [sqlite]
  PASS  deposits: the "days after the work is finished" setting moves the chase day; a job not finished is never chased [sqlite]
  PASS  deposits: once the final has passed, the line says the money has not come back: ask for the refund [sqlite]
  PASS  deposits: the email has its own heading and count, and the job's lead gets their own deposit [sqlite]
  PASS  deposits: the page counts what is still held and how much, the ones to chase first, refunded last [sqlite]
  PASS  deposits: a refund is recorded once, never on a permit with no deposit or with a bad date, and it leaves the alerts [sqlite]
  PASS  deposits: the API gives each permit its deposit, paid and refunded dates, and each job its work-finished date [sqlite]
  PASS  door: the permit form saves the deposit and the day it was paid
  PASS  door: the job form saves the day the work finished
  PASS  door: /deposits lists a closed job's deposit to get back, with the total held; a viewer sees it and no refund button
  PASS  door: a viewer cannot record the refund; a date ahead is refused; a coordinator records it, once
  PASS  door: the job page shows the deposit as refunded
  PASS  door: an administrator sets how many days after the finish a deposit is chased (1 to 365)

== 3.3.1 · the jobs import's preview runs the import's own checks ==
  PASS  import preview: it counts exactly what the import brings in, names the same skipped rows, and writes nothing [sqlite]
  PASS  import preview: an impossible expiry date, the same job at the same site twice, and a row with no name are each named [sqlite]
  PASS  import: a job comes in with its permit and expiry, and a row with an impossible date is not imported without it [sqlite]
  PASS  import preview: the upload step uses the import's own handler in normal mode, never the dry-run flag [sqlite]

————————————————————————————————————————————————————

== 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 Permora 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 Permora 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 Permora, 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 2 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 (24 tables dumped)
ALL 457 TESTS PASSED (0 failed)

← Back to Permora · Manual · Quickstart · API