Cashora · Test run · run 2026-11-07
Cashora Test run, as shipped in the download
413 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. parse_cents: user money string -> integer cents (string math, no float) ==
PASS "12.50" -> 1250 (got 1250, want 1250)
PASS "1,234.56" -> 123456 (got 123456, want 123456)
PASS "-45" -> -4500 (got -4500, want -4500)
PASS "$8" -> 800 (got 800, want 800)
PASS "€250.00" -> 25000 (got 25000, want 25000)
PASS "0" -> 0 (got 0, want 0)
PASS " 10 " -> 1000 (got 1000, want 1000)
PASS "5.5" -> 550 (got 550, want 550)
PASS "" -> null (got NULL, want NULL)
PASS "abc" -> null (got NULL, want NULL)
PASS "1.239" -> null (3 dp rejected) (got NULL, want NULL)
PASS "1.2.3" -> null (got NULL, want NULL)
== 2. fmt_cents: integer cents -> display string (sign-safe) ==
PASS 1250 -> "12.50" (got '12.50', want '12.50')
PASS 123456 -> "1,234.56" (got '1,234.56', want '1,234.56')
PASS -4500 -> "-45.00" (got '-45.00', want '-45.00')
PASS 0 -> "0.00" (got '0.00', want '0.00')
PASS 5 -> "0.05" (got '0.05', want '0.05')
PASS 100 -> "1.00" (got '1.00', want '1.00')
PASS parse/format round-trip 1234567 (got '12,345.67', want '12,345.67')
== 3. clamp_int / clamp_cents: MySQL INT-overflow guards ==
PASS 15-digit int clamps to 2e9 (got 2000000000, want 2000000000)
PASS huge negative clamps to -2e9 (got -2000000000, want -2000000000)
PASS non-numeric -> 0 (got 0, want 0)
PASS in-range passes through (got 150, want 150)
PASS clamp_cents floors at 0 (got 0, want 0)
PASS clamp_cents keeps positive (got 150, want 150)
PASS clamp_cents caps at 2e9 (got 2000000000, want 2000000000)
== 4. Cash::money + normalizeCurrency ==
PASS $1,234.56 (got '$1,234.56', want '$1,234.56')
PASS -$45.00 (sign before symbol) (got '-$45.00', want '-$45.00')
PASS €250.00 (got '€250.00', want '€250.00')
PASS £10.00 (got '£10.00', want '£10.00')
PASS ZAR (no symbol) code prefix (got 'ZAR 200.00', want 'ZAR 200.00')
PASS normalize "usd" -> USD (got 'USD', want 'USD')
PASS normalize "eur" -> EUR (got 'EUR', want 'EUR')
PASS normalize junk -> USD (got 'USD', want 'USD')
== 5. Cash::sumCents: totals in PHP, never SQL (with overflow clamp) ==
PASS sum of 4 rows = 8189 (got 8189, want 8189)
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)
== 6. Cash::balance — running balance of a box (opening - disbursed + replenished) ==
PASS disbursed A = 8189 (got 8189, want 8189)
PASS balance A = 20000 (got 20000, want 20000)
PASS variance A = 0 (got 0, want 0)
PASS disbursed B = 14245 (got 14245, want 14245)
PASS balance B = 6755 (got 6755, want 6755)
PASS variance B = -8245 (got -8245, want -8245)
PASS balance C = 26710 (got 26710, want 26710)
PASS variance C = +1710 (got 1710, want 1710)
== 7. Cash::variance + varianceLabel ==
PASS on float label (got 'On float', want 'On float')
PASS under float label (got 'Under float', want 'Under float')
PASS over float label (got 'Over float', want 'Over float')
PASS variance is balance - float (got -8245, want -8245)
PASS negative balance gets its own status (got 'Overdrawn — check for an unrecorded replenishment', want 'Overdrawn — check for an unrecorded replenishment')
PASS healthy under-float box keeps the routine label (got 'Under float', want 'Under float')
PASS zero balance is not overdrawn (got 'Under float', want 'Under float')
PASS on-float box unchanged (got 'On float', want 'On float')
PASS disbursement_create blocks a payout exceeding the box balance
== 8. Period report maths (opening -> disbursements -> replenishments -> closing) ==
PASS period opening = 8000 (got 8000, want 8000)
PASS period disbursed = 5250 (got 5250, want 5250)
PASS period closing = 6750 (got 6750, want 6750)
PASS closing reconciles to all-time balance (got 6750, want 6750)
== 9. Cash::categoryBreakdown: roll-up, uncategorized bucket, sorted desc ==
PASS three buckets (got 3, want 3)
PASS top bucket is Refreshments 3000 (got array (
'category' => 'Refreshments',
'cents' => 3000,
), want array (
'category' => 'Refreshments',
'cents' => 3000,
))
PASS second is Office supplies 2000 (got array (
'category' => 'Office supplies',
'cents' => 2000,
), want array (
'category' => 'Office supplies',
'cents' => 2000,
))
PASS third is Uncategorized 250 (got array (
'category' => 'Uncategorized',
'cents' => 250,
), want array (
'category' => 'Uncategorized',
'cents' => 250,
))
PASS breakdown totals equal period disbursed (got 5250, want 5250)
PASS unknown category id falls back to label (got 'Category #99', want 'Category #99')
== 10. Cash::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 'Maya Rivera', want 'Maya Rivera')
PASS mid-string = untouched (got 'a=b', want 'a=b')
PASS comma/quote RFC-4180 quoted (got '"Beef, ""batch 2"""', want '"Beef, ""batch 2"""')
== 11. Cash::toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Date,Payee,Amount', want 'Date,Payee,Amount')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative money 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 every wrapped line carries real text
== 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: no_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: backup_tables
PASS v2: upgrade_from_1x
PASS v2: balance_math
PASS v2: serializer_matches_core
PASS v2: overdraw_boundary
PASS v2: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: API reuses the tested core (the overdraw rule and the box snapshot live in ONE place)
PASS v2: the overdraw rule is defined once, in the controller every caller shares
PASS v2: API report reads the breakdown keys the core actually returns
PASS v2: no ternary key re-reads (PLAN item 10)
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
PASS v3: API.md ships and the sw cache is v4.1.3 (App::VERSION)
PASS v2: cli-server base guard present (PLAN item 9)
PASS v2: /backup.sqlite is carved out of the blanket .sqlite block in BOTH dev router and .htaccess
PASS v2: the API serializer is Cashora's, not a copied invoice one
-- 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_custodian_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: 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: cash_balance_math
PASS v3: guard_allows_exact
PASS v3: payout_write_is_atomic
PASS v3: payout_write_leaves_no_open_txn
PASS v3: receipt_column
PASS v3: still_no_email
PASS v3: optouts_documented
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 did not fatal
PASS v3: migrate() runs at boot, after the schemaExists guard
PASS v3: every core in src/ is REQUIRED by index.php (no autoloader here)
PASS v3: routes registered (mcp both verbs, audit, pdf, branding, restore, users)
PASS v3: /entries/bulk is registered before /entries/{id}
PASS v3: no privileged route is left on the bare login guard
PASS v3: every column the API-keys view prints is actually selected
PASS v3: MCP reuses the shared payout writer — the rule is not re-implemented for agents
PASS v3: the payout check and the payout write are ONE critical section, per engine
PASS v3: no payout is written outside the shared writer
PASS v3: balances come from _box_snapshot()/Cash::, never re-derived
PASS v3: the two deliberate opt-outs are stated in code, not merely omitted
PASS v3: still no mailer anywhere (the 2.0 no-email decision held)
PASS v3: bulk edits categories only — amounts are never rewritten in bulk
PASS v3: every core is byte-identical to the shared core
PASS v3: Gdpr.php and Ical.php are deliberately absent, and said so in code
PASS v3: every UI mutation is audited and role-gated
PASS v3: branding accent reaches the page and the audit link is role-gated
PASS v3: the uploaded logo is actually RENDERED in the shell, not just stored
PASS v3: logo URLs keep the {kind} segment the /media route requires
PASS v3: README documents the 3.0 release
PASS v3: API.md documents the MCP surface
PASS v3: deploy kit ships
PASS v3: sw cache bumped to 4.1.3 and versions say 4.1.3 (App::VERSION)
PASS v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
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: box ledger + cash count + dashboard ==
PASS kit: layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
PASS kit: no theme.js; style.css is an app layer with a dark accent; pre-kit scratch file removed
PASS kit: App::asset() is versioned by mtime and the service worker revalidates code
PASS kit: no font or script is fetched from another host (fonts bundled)
PASS routes: POST /boxes/{id}/count is registered, role-gated on entry.create and CSRF-checked
PASS hero: the box view is a LEDGER with a running balance, receipt thumbnails and the count dialog
PASS dashboard: the first-run checklist and needs-your-action are computed from the DATABASE
PASS count/permission: a viewer may not record a count; the custodian and the admin may
PASS count: the ledger expects float minus what was paid out
PASS count/happy: a matching count is recorded as balanced, with expected frozen alongside counted
PASS count/short: a shortfall is recorded as a difference, and the BALANCE is left alone
PASS count/over: more cash than the ledger says is recorded as over, not silently absorbed
PASS count/refused: a blank count is refused with a human sentence and writes nothing
PASS count/zero: an empty tin is a legitimate count, not an error
== v3.1: receipt photos and PDFs ==
PASS P3/happy: a PNG photo is stored under a random name with the sniffed extension
PASS P3/happy: a PDF receipt is accepted — half of real receipts arrive as an emailed PDF
PASS P3/refusal: an SVG is refused however it is named — it can carry script
PASS P3/refusal: an empty file is refused with a human reason
PASS P3/refusal: over the 8 MB photo cap is refused, and the message names the cap
PASS P3/serving: a stored PNG serves as image/png (got 'image/png', want 'image/png')
PASS P3/serving: a stored PDF serves as application/pdf (got 'application/pdf', want 'application/pdf')
PASS P3/serving: an unrecognized file is never served (fails closed)
PASS P3/permission: the delete route is gated on receipt.upload and CSRF-checked
PASS P3/happy: forget() removes the file from disk
PASS P3/refusal: forget() refuses a traversal or a name it did not mint — a delete can never reach outside the receipts directory
PASS P3/idempotency: forgetting an already-forgotten receipt is a no-op, not an error
PASS v3.1: the delete route is registered and the upload replaces rather than orphans
PASS v3.1: removing a receipt never touches the disbursement row itself
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 bulk: re-categorizing two selected entries moves exactly those two, leaves amounts alone and is audited
PASS bulk: a viewer is refused (403) and nothing moves
PASS bulk: the action uses PDO's own transaction, never the Database::begin() Cashora does not have
PASS receipts: the receipts archive is a valid tar that reads back every file byte for byte
PASS receipts: the archive route is registered and an administrator's (settings.edit)
PASS demo seed: one cash count per active box, expected frozen from the ledger, three balanced and one short
PASS demo guard: the hourly reseed runs after the controllers load, so the seed can record counts
PASS webhooks: a payout and a top-up recorded by the AI tool each send entry.recorded
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: entry.recorded is sent by the browser action where it happens [302,302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: a payout and a top-up made on the forms each send entry.recorded
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, /backup.sqlite and /backup/receipts.tar, and nothing is served
PASS backup gate: a signed-in custodian gets 403 on /backup.json, /backup.sqlite and /backup/receipts.tar, and nothing is served
PASS backup gate: the administrator gets the receipt files archive
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 custodian 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 custodian 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 (12 columns: api_keys.token_hash, backup_state.s3_secret, cash_boxes.drop_token, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, settings.llm_api_key, 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: count and close the month ==
PASS 3.2: denominations add up
PASS 3.2: a month closes once it has ended
PASS 3.2: the month figures
PASS 3.2: the reviewer is someone else and a difference needs a reason
PASS 3.2: a close records the count both names and the frozen figures
PASS 3.2: a month closes once
PASS 3.2: a payout dated in the closed month is refused an open month is not
PASS 3.2: the refusal names the way forward
PASS 3.2: every door checks the closed month
PASS 3.2: the month pack names the count both people and the receipts
PASS 3.2: a reopened month takes entries again
PASS 3.2: closing it again updates the same record
PASS 3.2: every close is audited
PASS 3.2: next to close skips closed months
PASS 3.2: index.php loads the month-close rules and the 3.2 controller
PASS 3.2: reopening a month is an administrator's action (settings.edit), and closing a custodian's (entry.create)
== 3.2 pass B: journal, approval limit, receipt reading ==
PASS 3.2 pass B: the journal debits each category and credits the box
PASS 3.2 pass B: quickbooks columns
PASS 3.2 pass B: xero columns one signed amount
PASS 3.2 pass B: every journal balances
PASS 3.2 pass B: a journal that does not balance is refused
PASS 3.2 pass B: mixed currencies and empty months are refused with a reason
PASS 3.2 pass B: no limit by default payouts go straight in
PASS 3.2 pass B: above the limit a clerk payout waits below it does not
PASS 3.2 pass B: an administrator is not held by the limit
PASS 3.2 pass B: the never below zero rule still holds for everyone
PASS 3.2 pass B: a request that the box could not pay is refused at once
PASS 3.2 pass B: approving records it once through the one writer
PASS 3.2 pass B: a box that no longer holds the cash refuses the approval
PASS 3.2 pass B: declining records nothing and keeps the reason
PASS 3.2 pass B: the ai tool meets the same limit
PASS 3.2 pass B: the api answers 202 and an import cannot carry a payout past the limit
PASS 3.2 pass B: approval doors are administrators only with csrf
PASS 3.2 pass B: reading is off by default and needs a key
PASS 3.2 pass B: a proposal fills what it can trust and says what it left
PASS 3.2 pass B: amounts as printed
PASS 3.2 pass B: the prompt offers only the owners categories
PASS 3.2 pass B: a stashed photo belongs to this session and unsaved ones go after a day
PASS 3.2 pass B: reading with openai proposes the fields
PASS 3.2 pass B: the key goes only in the auth header with the default model
PASS 3.2 pass B: reading with anthropic
PASS 3.2 pass B: a refusal never carries the key
PASS 3.2 pass B: a photo not in this session is not read
PASS 3.2 pass B: saving moves the photo into the receipts folder
PASS 3.2 pass B: the key is left out of the shareable backup
PASS 3.2 pass B: the demo sends nothing
PASS 3.2 pass B: the months receipts come as one zip with an index
PASS 3.2 pass B: index loads the journal and the reader; the payout form takes a photo from the camera and posts it
PASS 3.2 pass B: a month like 2026-13 no longer breaks the report (it falls back to this month)
== 3.3: receipts handed in on a box's link ==
PASS 3.3: the schema has the link and the hand ins and migrates twice
PASS 3.3: the form checks who how much when and what for
PASS 3.3: the link is off until made found by its token only and never for an archived box
PASS 3.3: a hand in waits on its box and moves nothing
PASS 3.3: recorded as a payout it points at it and the photo is the receipt
PASS 3.3: a hand in is recorded once
PASS 3.3: above the limit it becomes a request with the photo
PASS 3.3: the backup leaves the link out
PASS 3.3: over HTTP: server up
PASS 3.3: over HTTP: the custodian makes the link and the box page shows it
PASS 3.3: over HTTP: a viewer cannot change the link or see hand ins
PASS 3.3: over HTTP: the link opens a hand in page with no account
PASS 3.3: over HTTP: a hand in needs a photo and the page token and then waits
PASS 3.3: over HTTP: the visitor sees it was handed in
PASS 3.3: over HTTP: the box page lists it and the payout form opens filled in
PASS 3.3: over HTTP: recording it saves the payout with the photo and closes the hand in
PASS 3.3: over HTTP: a recorded hand in cannot be recorded again
PASS 3.3: over HTTP: turning one down records nothing and removes the photo
PASS 3.3: over HTTP: a busy link asks people to wait
PASS 3.3: over HTTP: a new link retires the old and off closes it
PASS 3.3: over HTTP: no php error in the server log
PASS 3.3: index.php loads the hand-in controller; changing the link and turning one down need a role and CSRF; recording goes through the ordinary payout form
== 3.3: the entries import's dry run ==
PASS 3.3 import dry run: the dry run counts only what the import takes and writes nothing
PASS 3.3 import dry run: the dry run gives the imports own reasons
PASS 3.3 import dry run: the import then agrees row for row
PASS 3.3 import dry run: the upload uses the preview and no import skips the row checks
————————————————————————————————————————————————————
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
PASS updates: the updater table holds exactly one state row
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Cashora 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 Cashora 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 Cashora, 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)
ALL 413 TESTS PASSED (0 failed)
← Back to Cashora · Manual · Quickstart · API