Assetora · Test run · run 2026-09-25
Assetora Test run, as shipped in the download
321 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 / fmt_cents: money <-> integer cents (string math, no float) ==
PASS "24000" -> 2400000 (got 2400000, want 2400000)
PASS "1,234.56" -> 123456 (got 123456, want 123456)
PASS "$8" -> 800 (got 800, want 800)
PASS "1.239" -> null (3dp) (got NULL, want NULL)
PASS 2400000 -> "24,000.00" (got '24,000.00', want '24,000.00')
PASS 7000 -> "70.00" (got '70.00', want '70.00')
PASS clamp_cents floors at 0 (got 0, want 0)
PASS clamp_int caps 15-digit (got 2000000000, want 2000000000)
== 2. Depreciation::normalize* + money + rate parser ==
PASS method: junk -> straight_line (got 'straight_line', want 'straight_line')
PASS method: REDUCING -> reducing_balance (got 'reducing_balance', want 'reducing_balance')
PASS status: junk -> active (got 'active', want 'active')
PASS status: DISPOSED -> disposed (got 'disposed', want 'disposed')
PASS methodLabel reducing (got 'Reducing balance', want 'Reducing balance')
PASS money $1,234.56 (got '$1,234.56', want '$1,234.56')
PASS money -$45.00 (sign before symbol) (got '-$45.00', want '-$45.00')
PASS money ZAR code prefix (got 'ZAR 200.00', want 'ZAR 200.00')
PASS parseRateBps "25" -> 2500 (got 2500, want 2500)
PASS parseRateBps "18.75%" -> 1875 (got 1875, want 1875)
PASS parseRateBps "2.5" -> 250 (got 250, want 250)
PASS parseRateBps ">100%" clamps to 10000 (got 10000, want 10000)
PASS parseRateBps "abc" -> null (got NULL, want NULL)
PASS fmtRatePct 1875 -> "18.75%" (got '18.75%', want '18.75%')
== 3. monthsInService: completed calendar months (anniversary method) ==
PASS Jan15 -> Feb15 = 1 (got 1, want 1)
PASS Jan15 -> Feb14 = 0 (not yet a month) (got 0, want 0)
PASS Jan15 -> Mar15 = 2 (got 2, want 2)
PASS same day = 0 (got 0, want 0)
PASS as-of before acq = 0 (got 0, want 0)
PASS 2020-05-15 -> 2026-03-20 = 70 (got 70, want 70)
PASS 2022-03-10 -> 2026-07-07 = 51 (got 51, want 51)
PASS full 5 years = 60 (got 60, want 60)
PASS Jan31 -> Feb28 = 1 (clamped, non-leap) (got 1, want 1)
PASS Jan31 -> Feb27 = 0 (not yet) (got 0, want 0)
PASS Jan31 -> Feb29 = 1 (leap year) (got 1, want 1)
PASS Jan31 -> Feb28 = 0 in a LEAP year (Feb 29 exists, not reached) (got 0, want 0)
PASS Jan30 -> Feb28 = 1 (clamped) (got 1, want 1)
PASS Mar31 -> Apr30 = 1 (30-day month clamp) (got 1, want 1)
PASS Jan31 -> Mar31 = 2 (day exists again) (got 2, want 2)
PASS clamp pair: Jan-28 and Jan-31 both = 1 month at Feb-28 close (got true, want true)
== 4. base: cost - salvage, floored, salvage capped at cost ==
PASS 100000 - 7000 = 93000 (got 93000, want 93000)
PASS salvage > cost -> 0 (got 0, want 0)
PASS negative salvage -> full cost base (got 5000, want 5000)
== 5. straight-line accumulated + per-period: FINAL period absorbs the remainder ==
PASS SL accum m0 = 0 (got 0, want 0)
PASS SL accum m1 = 13285 (got 13285, want 13285)
PASS SL accum m6 = 79710 (got 79710, want 79710)
PASS SL accum m7 = 93000 (= base exactly) (got 93000, want 93000)
PASS SL accum past life stays base (got 93000, want 93000)
PASS SL period k1 = 13285 (got 13285, want 13285)
PASS SL final period k7 absorbs remainder = 13290 (got 13290, want 13290)
PASS SL periods sum to base (got 93000, want 93000)
PASS SL book at end == salvage exactly (got 7000, want 7000)
== 6. reducing-balance step + accum: integer cents, round-half-up, salvage floor ==
PASS rbStep 10% of 100000 = 10000 (got 10000, want 10000)
PASS rbStep 25% of 12345 rounds to 3086 (.25 down) (got 3086, want 3086)
PASS rbStep 25% of 12346 rounds up to 3087 (.50 up) (got 3087, want 3087)
PASS rbStep clamps to salvage floor (got 500, want 500)
PASS rbStep rate 0 -> 0 (got 0, want 0)
PASS rbStep no room (book==salvage) -> 0 (got 0, want 0)
PASS RB accum m1 = 10000 (got 10000, want 10000)
PASS RB book m1 = 90000 (got 90000, want 90000)
PASS RB book m2 = 81000 (got 81000, want 81000)
PASS RB book m3 = 72900 (got 72900, want 72900)
PASS RB life cap: 2 periods only (got 81000, want 81000)
PASS RB long run book >= salvage
PASS RB long run accum = cost - book
== 7. asset-level accumFor / bookFor (disposal-aware) ==
PASS van months = 36 (got 36, want 36)
PASS van accum @36mo = 1679976 (got 1679976, want 1679976)
PASS van book @36mo = 1520024 (got 1520024, want 1520024)
PASS van book at end of life = salvage (got 400000, want 400000)
PASS disposed asset accum pinned at disposal date (got 1679976, want 1679976)
PASS disposed asset book pinned at disposal date (got 1520024, want 1520024)
== 8. disposal gain / loss = proceeds - book value at disposal (exact) ==
PASS forklift book at disposal = 845880 (got 845880, want 845880)
PASS forklift gain = 900000 - 845880 = 54120 (got 54120, want 54120)
PASS forklift gainLoss label = Gain (got 'Gain', want 'Gain')
PASS RB disposal book = 81000 (got 81000, want 81000)
PASS RB disposal gain = 4000 (got 4000, want 4000)
PASS RB disposal loss = -31000 (got -31000, want -31000)
PASS loss label (got 'Loss', want 'Loss')
PASS break-even label (got 'Break-even', want 'Break-even')
== 9. schedule: length, month labels, sum, final book == salvage (SL) ==
PASS schedule has 7 rows (got 7, want 7)
PASS row1 depreciation = 13285 (got 13285, want 13285)
PASS row7 depreciation = 13290 (remainder absorbed) (got 13290, want 13290)
PASS row7 accumulated = 93000 (got 93000, want 93000)
PASS row7 book = salvage 7000 (got 7000, want 7000)
PASS row1 month label = 2024-02 (got '2024-02', want '2024-02')
PASS schedule depreciation sums to base (got 93000, want 93000)
PASS disposed schedule last row flagged disposal
PASS disposed schedule final book = book at disposal (got 845880, want 845880)
== 10. periodDep: depreciation recognised WITHIN a report period ==
PASS dep in 2025 = 110000 (got 110000, want 110000)
PASS dep in 2026 = 10000 (got 10000, want 10000)
PASS two years of dep sum to base (got 120000, want 120000)
== 11. categoryRollup: excludes disposed, sums reconcile to PHP per-asset sum ==
PASS rollup has 2 categories (disposed excluded) (got 2, want 2)
PASS rollup total book reconciles to per-asset PHP sum (got 225000, want 225000)
PASS rollup total cost reconciles (got 350000, want 350000)
PASS rollup sorted by cost desc (IT 200000 first)
== 12. PRIME SWEEP: SL end-of-life exactness + book never below salvage ==
PASS SL prime sweep: end-of-life accum == base (all cases) (got true, want true)
== 13. PRIME SWEEP: reducing-balance book monotone-down and never below salvage ==
PASS RB sweep: monotone, floored at salvage, accum reconciles (got 0, want 0)
== 14. 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 -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 'Delivery van', want 'Delivery van')
PASS mid-string = untouched (got 'a=b', want 'a=b')
PASS comma/quote RFC-4180 quoted (got '"Rack, ""v2"""', want '"Rack, ""v2"""')
== 15. toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Asset,Cost,Book', want 'Asset,Cost,Book')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative money stays numeric
== 16. 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
== 17. 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 long line wraps to 2+ chunks
== 18. App::view param-collision regression (the /report crash) ==
PASS payload key 'data' reaches the view (was dropped by the $data param collision)
PASS payload key 'tpl' reaches the view (the certora template_form collision class)
PASS payload key 'layout' reaches the view
== 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: serializer_matches_core
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 (never re-derives validation or depreciation)
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 + sw cache is v3.1.5
PASS v2: cli-server base guard present (PLAN item 9)
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_restores_v3_tables
PASS v3: update_on_a_missing_settings_row_silently_does_nothing
PASS v3: migrate_ensures_the_settings_singleton
PASS v3: asset_disposed_is_declared
PASS v3: asset_disposed_actually_fires
PASS v3: dispose_sets_the_status_and_date
PASS v3: dispose_reports_the_engines_gain_loss
PASS v3: dispose_is_audited
PASS v3: dispose_refuses_an_already_disposed_asset
PASS v3: refused_dispose_changed_nothing
PASS v3: mcp_totals_match_the_products_engine
PASS v3: mcp_detail_matches_the_engine
PASS v3: mcp_create_uses_the_forms_refusal
PASS v3: mcp_create_writes_the_asset
PASS v3: mcp_refuses_an_unknown_method_rather_than_coercing
PASS v3: unknown_role_gets_the_most_restrictive
PASS v3: empty_role_is_treated_as_legacy_admin
PASS v3: member_cannot_dispose
PASS v3: member_can_still_scan
PASS v3: viewer_cannot_write
PASS v3: last_admin_cannot_be_demoted
PASS v3: member_does_not_see_dispose_asset
PASS v3: member_calling_dispose_is_refused
PASS v3: member_dispose_wrote_nothing
PASS v3: review_date_is_the_end_of_the_last_scheduled_month
PASS v3: review_date_is_null_for_a_disposed_asset
PASS v3: assets_hold_no_personal_data
PASS v3: sighting_removes_an_asset_from_the_discrepancy_list
PASS v3: a_disposed_asset_is_not_a_discrepancy
PASS v3: a_second_sighting_of_the_same_asset_is_refused_by_the_schema
PASS v3: scan_resolves_a_label_url
PASS v3: scan_resolves_a_tag
PASS v3: scan_resolves_nothing_for_junk
PASS v3: register_pdf_engine_renders
PASS v3: label_sheet_renders
PASS v3: brand_css_brands_the_light_theme
PASS v3: brand_css_brands_the_dark_theme_too
PASS v3: brand_dark_accent_is_legible_on_the_dark_canvas
PASS v3: unbranded_install_emits_no_brand_css
PASS v3: scheduled_backup_writes_a_dated_file
PASS v3: scheduled_backup_redacts_credential_material
PASS v3: scheduled_backup_prunes_to_the_retention_count
PASS v3: audit_records_the_disposal
PASS v3: audit_detail_carries_no_secret
PASS v3: db_files_cleaned
PASS qr: qr_decodes_back_to_the_payload
PASS qr: qr_reed_solomon_parity_is_valid
PASS qr: qr_format_block_names_the_real_mask
PASS qr: qr_covers_versions_1_to_10
PASS qr: qr_refuses_an_over_long_payload
PASS qr: golden matrices unchanged (a refactor that alters output fails here)
PASS v3: the layout INJECTS the brand CSS (a helper nothing calls brands nothing)
PASS v3: the branding form exists and posts to the branding route
PASS v3: the logo is rendered, not merely stored
PASS v3: saved views are RENDERED on the register, not just writable
PASS v3: the register offers bulk actions
PASS v3: photos are rendered and uploadable on the asset page
PASS v3: the calendar feed and the scheduled backup are reachable (helpers with a route)
PASS v3: GET /mcp answers the transport hint BEFORE the key check
PASS v3: ONE disposal writer — every door goes through _asset_dispose_apply()
PASS v3: bulk owns NO write SQL of its own for disposal — it loops the shared writer
PASS v3: the PDFs and the feed read Depreciation:: rather than doing arithmetic
PASS v3: no view posts to a route that does not exist
PASS v3: every v3 route has a function to answer it
PASS v3: the deploy kit is filled and ships inside app/ (where package.sh will find it)
PASS v3: the nine cores are byte-identical to the reference copy (or skipped when unavailable)
PASS v3: README documents 3.0 and API.md gains the MCP section
PASS v3: the audit viewer reads the column the core actually writes
PASS v3: MCP domain refusals THROW McpRefusal, never return an error array
PASS v3: UI mutations + api-settings are role-gated, not merely logged in
PASS v3: no ungated write route beyond the by-design baseline (router-derived)
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: asset record + book-value timeline + period close ==
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 /period-close is registered, gated on settings.write and CSRF-checked
PASS hero: the asset record draws the book-value timeline from the SAME schedule the tables use
PASS dashboard: the first-run checklist and needs-your-action are computed from the DATABASE
PASS close/permission: a viewer and a member cannot close the register; an admin can
PASS close/happy: the close records the assets, the totals and the depreciation booked in the period
PASS close/frozen: what the register said is STORED, not recomputed on read
PASS close/refused: closing through a date already closed is refused, and names the date
PASS close/refused: a period cannot be closed into the future
PASS close/refused: a non-date is refused with a human sentence
PASS close/period figure: the second close books only the depreciation SINCE the first
PASS close/does not lock: the register still computes and still allows a correction afterwards
== v3.1: depreciation journal ==
PASS P6/happy: the journal debits each category and credits accumulated depreciation once
PASS P6/balances: debits equal credits to the cent
PASS P6/refusal: if the categories no longer add up to what the close FROZE, it refuses and says why
PASS P6/refusal: a period that booked no depreciation has no journal, and says so
PASS P6/format: the QuickBooks journal header is exact (got 'Journal No,Journal Date,Account,Debits,Credits,Description', want 'Journal No,Journal Date,Account,Debits,Credits,Description')
PASS P6/format: the Xero manual-journal header is exact (got 'Narration,Date,Description,AccountCode,Amount', want 'Narration,Date,Description,AccountCode,Amount')
PASS P6/format: Xero takes ONE signed amount per line — the credit is negative
PASS P6/format: journal amounts carry NO thousands separator and no injection prefix
PASS P6/format: a negative journal amount survives as a number, never as escaped text
PASS P6/format: amount() writes plain decimals (got '-1500.00', want '-1500.00')
PASS P6/happy: the generic journal puts the debit and credit in separate columns and dates every line
PASS P6/happy: the operator's own account names are used when set
PASS P6/edge: with nothing set it falls back to names a bookkeeper recognises, never to a made-up code
PASS P6/permission: the journal route is admin-gated (settings.write), like the close itself
PASS P6/idempotency: the journal is built from the close's FROZEN total, never recomputed from live assets
== 3.1.4: disposals in a closed period ==
PASS disposal/probe: Q1 closes with both assets and $800 of depreciation
PASS disposal/probe: Q2 books the disposed van's April–May $400 too ($1,000, not $600)
PASS disposal/probe: Q2 holds one asset — the van left the register on its disposal date
PASS disposal/probe: Q1's journal still builds after the later disposal (it used to refuse)
PASS disposal/journal: Q2's journal carries the full $1,000 of depreciation and balances
PASS disposal/journal: the van's disposal posts proceeds, accumulated depreciation, loss and cost out
PASS disposal/journal: the QuickBooks and Xero presets carry the disposal lines too
PASS disposal/journal: the operator's own disposal account names are used when set
PASS disposal/refusal: a disposal reinstated after the close makes the journal refuse, and says why
PASS disposal/legacy: a pre-3.1.4 close still exports as closed, and reports the $400 it left out
PASS disposal/report: the report shows a pre-3.1.4 close's left-out depreciation beside it
PASS close/held: an asset acquired after the close date is not counted as held at it
PASS demo seed: two closes, and the H1 2026 journal builds with both disposals in it
PASS demo seed: one finished stock-take with exactly one active asset not sighted
PASS demo reset: closes and stock-takes go with the register they describe
PASS demo guard: the hourly reseed runs after the controllers load, so the seed can close periods
PASS perm/reinstate: undoing a disposal takes the disposal permission and is audited
PASS perm/import: importing assets takes asset.write (the form and the POST)
PASS perm/backup: both backup downloads (JSON and the raw .sqlite with password hashes) are admin-only
PASS mcp: the AI connection names the server as the REST API does (assetora), on GET and POST alike
PASS form: the reducing-balance rate says it is MONTHLY and converts a typical annual rate
PASS perm/roles: a viewer has neither asset.write nor asset.dispose
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: every MCP tool declares whether it reads or writes, so a read-only key can see something
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 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: asset.created is sent by the browser action where it happens [302]
PASS webhooks: asset.disposed is sent by the browser action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
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 member 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, /restore/commit, /restore/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/restore\/backup-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 member gets 403 on the restore page and on POST /restore, /restore/commit, /restore/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/restore\/backup-token":403},"csrf":true}]
PASS backup gate: a signed-in member 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":302,"\/restore\/commit":302,"\/restore\/backup-token":302}}]
PASS backup gate: the JSON backup carries no reusable credential from any table (9 columns: api_keys.token_hash, invites.token_hash, settings.oidc_client_secret, settings.backup_token_hash, settings.feed_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 321 TESTS PASSED (0 failed)