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