Home›Cashora›Test run
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