Cashora · Test run · run 2026-09-25
Cashora Test run, as shipped in the download
272 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 with php tests/run.php. Run it yourself after you download.
== 1. 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, uncategorised 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 Uncategorised 250 (got array (
'category' => 'Uncategorised',
'cents' => 250,
), want array (
'category' => 'Uncategorised',
'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 neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralised (got '\'+1', want '\'+1')
PASS -SUM neutralised (got '\'-SUM(A1)', want '\'-SUM(A1)')
PASS @cmd neutralised (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralised (got '\' x', want '\' x')
PASS cmd payload neutralised (got '\'=cmd|calc', want '\'=cmd|calc')
PASS CR payload quoted+prefixed (got '"\'
X"', want '"\'
X"')
PASS -45.00 kept numeric (got '-45.00', want '-45.00')
PASS -2 kept numeric (got '-2', want '-2')
PASS 1250 kept numeric (got '1250', want '1250')
PASS plain text untouched (got '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 v3.1.5 (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 3.1.5 and versions say 3.1.5 (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 unrecognised 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 licence: every font ships the SIL OFL text for its own family beside it
PASS licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
PASS docs: README and QUICKSTART say PHP 8.1+, and the README names the Single or Extended licence
PASS bulk: re-categorising 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 (8 columns: api_keys.token_hash, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
PASS backup gate: each planted credential row is in the backup with the cell redacted, not dropped
PASS links: https behind a trusted TLS proxy (X-Forwarded-Proto or CF-Visitor); a direct client cannot claim it; plain http stays http
PASS links: nothing builds a scheme from $_SERVER[HTTPS] alone any more (src, controllers, views)
PASS demo ribbon: every page carries the admin wording (the product has no public page)
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens the card, and that choice holds for the session
PASS demo ribbon: injected just before </body>; output with no </body> (JSON, CSV, PDF) passes through untouched
————————————————————————————————————————————————————
ALL 272 TESTS PASSED (0 failed)
← Back to Cashora · Manual · Quickstart · API