Consigna · Test run · run 2026-09-25
Consigna Test run, as shipped in the download
319 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.
[DB: SQLite]
== 1. split(): penny-exact integer split ==
PASS 999c at 6000bp -> consignor 599 (got array (
0 => 599,
1 => 400,
), want array (
0 => 599,
1 => 400,
))
PASS 999c at 5000bp rounds half up (got array (
0 => 500,
1 => 499,
), want array (
0 => 500,
1 => 499,
))
PASS 997c (prime) at 3333bp (got array (
0 => 332,
1 => 665,
), want array (
0 => 332,
1 => 665,
))
PASS 9999c at 5000bp (got array (
0 => 5000,
1 => 4999,
), want array (
0 => 5000,
1 => 4999,
))
PASS 7919c (prime) at 6667bp (got array (
0 => 5280,
1 => 2639,
), want array (
0 => 5280,
1 => 2639,
))
PASS 1c at 5000bp -> penny to consignor (got array (
0 => 1,
1 => 0,
), want array (
0 => 1,
1 => 0,
))
PASS 1c at 4999bp -> penny to shop (got array (
0 => 0,
1 => 1,
), want array (
0 => 0,
1 => 1,
))
PASS 0% split: shop keeps everything (got array (
0 => 0,
1 => 12345,
), want array (
0 => 0,
1 => 12345,
))
PASS 100% split: consignor gets everything (got array (
0 => 12345,
1 => 0,
), want array (
0 => 12345,
1 => 0,
))
PASS zero-price sale splits to zeros (got array (
0 => 0,
1 => 0,
), want array (
0 => 0,
1 => 0,
))
PASS bp above 10000 is clamped (got array (
0 => 1000,
1 => 0,
), want array (
0 => 1000,
1 => 0,
))
PASS negative bp is clamped to 0 (got array (
0 => 0,
1 => 1000,
), want array (
0 => 0,
1 => 1000,
))
PASS large sale: 19999999c at 3333bp (got array (
0 => 6666000,
1 => 13333999,
), want array (
0 => 6666000,
1 => 13333999,
))
PASS invariant: consignor + shop == sale across 110 prime/bp combos
== 2. effectiveBp: override falls back to consignor default ==
PASS no override -> consignor default (got 6000, want 6000)
PASS override wins over default (got 7000, want 7000)
PASS override of 0 is a real override (got 0, want 0)
PASS override clamped to 10000 (got 10000, want 10000)
PASS negative default clamped to 0 (got 0, want 0)
== 3. cents parsing / formatting (never floats) ==
PASS parse "24.99" (got 2499, want 2499)
PASS parse "1,250" (got 125000, want 125000)
PASS parse "$8" (got 800, want 800)
PASS parse "0.5" = 50c (got 50, want 50)
PASS parse "-3.25" (got -325, want -325)
PASS parse garbage -> null (got NULL, want NULL)
PASS parse empty -> null (got NULL, want NULL)
PASS fmt_cents 1234567 (got '12,345.67', want '12,345.67')
PASS fmt_cents 5 (got '0.05', want '0.05')
PASS fmt_cents negative (got '-3.25', want '-3.25')
PASS fmt_bp 6000 -> 60% (got '60%', want '60%')
PASS fmt_bp 6250 -> 62.5% (got '62.5%', want '62.5%')
PASS round-trip: fmt then parse (got 987654321, want 987654321)
== 4. CSV export: formula-injection guard, numeric-aware ==
PASS header row (got 'A,B', want 'A,B')
PASS formula =SUM is quoted with apostrophe
PASS negative money -5.00 NOT corrupted
PASS +1234x (non-numeric) is guarded
PASS -42 (numeric) NOT corrupted
PASS @cmd is guarded
PASS leading TAB is guarded
PASS comma cell is RFC-4180 quoted
PASS embedded quotes doubled
PASS CRLF line endings
== 5. CSRF: empty stored token must never validate ==
PASS empty stored + empty given -> reject (got false, want false)
PASS empty stored + any given -> reject (got false, want false)
PASS mismatched tokens -> reject (got false, want false)
PASS matching non-empty tokens -> accept (got true, want true)
== 6. Oversize-input guards (MySQL-strict safety) ==
PASS clip trims to width (got 190, want 190)
PASS clip leaves short strings (got 'Walnut desk', want 'Walnut desk')
PASS clip is multibyte-safe (got 120, want 120)
PASS clamp_int caps 15-digit price (got 2000000000, want 2000000000)
PASS clamp_int floors huge negative (got -2000000000, want -2000000000)
PASS clamp_int zeroes garbage (got 0, want 0)
PASS clamp_bp caps at 10000 (got 10000, want 10000)
PASS clamp_bp floors at 0 (got 0, want 0)
PASS clamp_bp keeps sane values (got 6000, want 6000)
PASS clamp_cents floors at 0 (got 0, want 0)
PASS valid_ymd accepts real date (got true, want true)
PASS valid_ymd rejects Feb 30 (got false, want false)
PASS valid_ymd rejects garbage (got false, want false)
== 7. Sales state machine (SQLite throwaway DB) ==
PASS nextSku follows highest tail (got 'T-0006', want 'T-0006')
PASS sale records on available item
PASS sale used consignor default 6000bp (got 6000, want 6000)
PASS 999c @6000bp -> consignor 599 (got 599, want 599)
PASS 999c @6000bp -> shop 400 (got 400, want 400)
PASS penny-exact: 599 + 400 == 999
PASS item flipped to sold (got 'sold', want 'sold')
PASS double sale refused (got false, want false)
PASS sale price may differ from list
PASS override bp snapshotted (7500) (got 7500, want 7500)
PASS 4200c @7500bp -> consignor 3150 (got 3150, want 3150)
PASS 4200c @7500bp -> shop 1050 (got 1050, want 1050)
PASS cannot sell a returned item (got false, want false)
PASS negative sale price refused (got false, want false)
PASS item 3 still available after refusals (got 'available', want 'available')
PASS bad sale date refused (got false, want false)
== 8. Payout ledger: balance can never go negative ==
PASS Ann earned across two sales (got 3749, want 3749)
PASS Ann balance == earned (no payouts yet) (got 3749, want 3749)
PASS payout exceeding balance by 1c is refused (got false, want false)
PASS zero payout refused (got false, want false)
PASS negative payout refused (got false, want false)
PASS payout within balance accepted
PASS balance after 2000c payout (got 1749, want 1749)
PASS second over-payout (1750 > 1749) refused (got false, want false)
PASS payout of exactly the full balance accepted
PASS balance drained to zero (got 0, want 0)
PASS 1c payout on zero balance refused (got false, want false)
PASS third sale recorded
PASS 10000c @6000bp -> consignor 6000 (got 6000, want 6000)
PASS balance rises after new sale (got 6000, want 6000)
PASS paid total is tracked (got 3749, want 3749)
PASS 9999c @5000bp -> consignor 5000 (round half up) (got 5000, want 5000)
PASS 9999c @5000bp -> shop 4999 (got 4999, want 4999)
PASS total owed across shop == Ann 6000 + Bob 5000 (got 11000, want 11000)
== 9. Undo sale ==
PASS undo unpaid sale accepted
PASS item back to available after undo (got 'available', want 'available')
PASS Bob balance back to zero (got 0, want 0)
PASS undoing a gone sale refused (got false, want false)
PASS drain payout accepted
PASS undo refused when consignor already paid out against it (got false, want false)
== 10. Item status transitions ==
PASS available -> returned allowed
PASS status stored (got 'returned', want 'returned')
PASS returned -> available allowed
PASS available -> expired allowed
PASS expired -> donated allowed
PASS 'sold' cannot be set directly (got false, want false)
PASS sold item status is locked (got false, want false)
PASS unknown status refused (got false, want false)
== 11. Statement: date-range filtering ==
PASS March statement has 2 sales (got 2, want 2)
PASS March consignor shares sum (got 3749, want 3749)
PASS April statement has 1 sale (got 1, want 1)
PASS range boundaries are inclusive (got 1, want 1)
PASS day before is excluded (got 0, want 0)
PASS March payouts count (got 2, want 2)
PASS March payouts sum (got 3749, want 3749)
PASS payouts outside range excluded (got 0, want 0)
== 12. Expiry policy ==
PASS 90-day expiry from intake (got '2026-04-01', want '2026-04-01')
PASS expiry disabled at 0 days (got NULL, want NULL)
PASS month-end rollover (got '2026-03-02', want '2026-03-02')
== 13. Auth ==
PASS password hash verifies
PASS wrong password does not verify
PASS hash is not the plaintext
== 14. Settings + clamped writes land safely ==
PASS 9000-char shop name landed clipped to 190 (got 190, want 190)
PASS default_split_bp clamped to 10000 (got 10000, want 10000)
PASS expiry_days clamped to 3650 (got 3650, want 3650)
PASS adversarial oversize item inserts without error
PASS 9000-char title stored clipped to 190 (got 190, want 190)
PASS 40-char SKU clip (got 40, want 40)
PASS 15-digit price clamped to 2e9 (got 2000000000, want 2000000000)
PASS oversize override clamped to 10000 (got 10000, want 10000)
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
----------------------------------------------------
== PWA ==
PASS manifest.json is valid JSON
PASS manifest has required fields
PASS manifest URLs are RELATIVE (subpath-install safe)
PASS manifest icons exist on disk (192 + 512 + apple-touch)
PASS sw.js never caches non-GET
PASS sw.js never caches page navigations (auth HTML can not enter cache storage)
PASS sw.js cache name carries the version (old caches purged on activate)
PASS offline fallback page ships
PASS every app layout links the manifest + registers the SW
-- Own It 2.0 --
PASS v2: tables
PASS v2: totp_cols
PASS v2: smtp_cols
PASS v2: settings_singleton
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: events
PASS v2: rate
PASS v2: backup_tables
PASS v2: split_exact_sweep
PASS v2: split_round_half_up
PASS v2: sale_ok
PASS v2: sold_once
PASS v2: payout_over_refused
PASS v2: payout_exact_allowed
PASS v2: payout_on_zero_refused
PASS v2: next_sku
PASS v2: serializers_clean
PASS v2: ledger_reconciles
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: migrate() runs on boot (1.x installs gain the new tables)
PASS v2: cli-server base guard (extension-bearing routes work under php -S)
PASS v2: login is rate-limited and 2FA intercepts it
PASS v2: the screen fires the same side effects as the API (one code path)
PASS v2: API sales and payouts reuse Consign:: (never a second money path)
PASS v2: API intake reuses the auto-SKU and expiry helpers
PASS v2: the ledger endpoint reuses the statement queries
PASS v2: multi-entity import shares ONE staged dry-run/commit driver
PASS v2: dry run and commit share ONE validator per entity
PASS v2: notifications never break the request
PASS v2: no undefined-key ternary re-reads in any controller
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2: dark mode carries its own accent with dark on-accent ink (kit tokens)
PASS v2: theme.js + qr.js ship and the layout loads theme.js with a toggle
PASS v2: shared cores are present
PASS v2: .htaccess re-allows the /backup.sqlite ROUTE while still denying real .sqlite files
PASS v2: dev router denies .sqlite FILES but not the backup route
PASS v3: API.md ships and the sw cache is v3.1.6
PASS v3: version is 3.1.6 everywhere (healthz, api_ping, openapi)
PASS v3: the 3.0 check ran clean (no PHP warning, no fatal)
PASS v3: PENNY-EXACT SPLITS SURVIVE MCP — 1c/3c/3333c/9999c round-trip 1=>1+0 3=>2+1 3333=>2000+1333 9999=>5999+4000 100000=>60000+40000 7=>4+3
PASS v3: no penny is created or lost across the whole ledger
PASS v3: a viewer key is refused record_sale
PASS v3: the refused sale wrote nothing
PASS v3: MCP exposes NO payout tool (cash leaving the till stays human)
PASS v3: settlement_preview reports the ledger balance, not a period sum
PASS v3: previewing a settlement records no payout
PASS v3: the portal token is stored hashed, never in the clear
PASS v3: a portal token verifies and a wrong one does not
PASS v3: every column the anonymise map names really exists
PASS v3: erasure touches no money column
PASS v3: the iCal feed uses the description key the core reads
PASS v3: index.php calls Database::migrate() at boot
PASS v3: no literal route is shadowed by an earlier {id} route
PASS v3: all 9 shared cores are byte-identical to the reference copy
PASS v3: the deploy kit ships both files, fully filled
PASS v3: the deploy kit names THIS product and carries the fixed healthcheck
PASS v3: the brand accent is applied in :root AND html[data-theme=dark]
PASS v3: an uploaded logo and the brand name reach the app chrome
PASS v3: GET /mcp is routed to the unauthenticated probe
PASS v3: the GET probe runs no key check and exposes no tools
PASS v3: GET reaches the core and gets 405 + Allow: POST
PASS v3: MCP records sales through Consign::recordSale(), never its own INSERT
PASS v3: no new surface re-implements the split (only Consign::split may do that arithmetic)
PASS v3: the settlement PDF reads the ledger balance rather than summing its own rows
PASS v3: the public portal contains no write statement at all
PASS v3: the portal resolves a real accent even when branding is unset
PASS v3: both backup doors use one redaction list
PASS v3: the redaction list covers every credential, including the portal token
PASS v3: every write handler is permission-gated
PASS v3: no call to a function this product does not define
PASS C2: no sensitive handler is login-only — all are role-gated
PASS C2: both backup doors call the ONE shared redaction list
PASS C2: Api::requireKey refuses a deactivated user (u.active)
PASS C2: every App:: method the controllers call actually exists
PASS C2: Api::base() does not double an already-absolute base
== UI kit v1.0 · payout run ==
PASS run fixture: consignor one owed 60% of 10000 (got 6000, want 6000)
PASS run fixture: consignor two owed 50% of 5000 (got 2500, want 2500)
PASS payout run: both lines accepted
PASS payout run: consignor one settled (got 0, want 0)
PASS payout run: consignor two settled (got 0, want 0)
PASS payout run: two rows written with the shared note (got 2, want 2)
PASS payout run: a settled consignor cannot be paid again
PASS payout run: an over-balance line aborts the whole run before writing
PASS payout run: every line goes through the guarded writer, not a raw INSERT
PASS payout run: requires payout.record + CSRF
PASS payout run: the run is written to the audit trail
PASS payout run: route registered and the hero form posts to it
== v3.1 · portal statement PDF ==
PASS statement: one builder serves both doors
PASS statement: the portal door renders no PDF of its own
PASS statement: the output is a PDF
PASS statement: it names the consignor it belongs to
PASS statement: a period filter narrows the same builder
PASS statement: a consignor with no sales still gets a valid PDF
PASS statement: one resolver serves every portal door
PASS statement: an empty token resolves to nobody
PASS statement: a garbage token resolves to nobody
PASS statement: a live token resolves to its own consignor (got 1, want 1)
PASS statement: a revoked token stops resolving
PASS statement: the portal refusal is a 404 that does not say which case it was
PASS statement: the staff download still demands consignor.view
PASS statement: the portal URL carries no consignor id — only the token decides whose data it is
PASS statement: the portal statement is marked noindex and never cached by a robot
PASS statement: both doors are audited, the portal one distinguishably
PASS statement: route registered BEFORE the catch-all token route, and the portal offers it
== fix wave: every REST write checks the key holder's role ==
PASS api: the viewer key is refused POST /api/items (403, needs item.edit)
PASS api: the member key passes the role check on POST /api/items
PASS api: the admin key passes the role check on POST /api/items
PASS api: the viewer key is refused POST /api/sales (403, needs sale.record)
PASS api: the member key passes the role check on POST /api/sales
PASS api: the viewer key is refused POST /api/payouts (403, needs payout.record)
PASS api: the member key is refused POST /api/payouts (403, needs payout.record)
PASS api: the admin key passes the role check on POST /api/payouts
PASS api: every /api/* write route checks the key holder's role (no key-only writes)
== fix wave: a viewer cannot read the audit trail ==
PASS audit: the viewer is refused /audit (403)
PASS audit: an admin still opens /audit
PASS audit: the viewer is refused /audit.csv (403)
PASS audit: an admin still opens /audit.csv
PASS audit: the sidebar shows the viewer no audit link
PASS audit: the viewer still opens /
PASS audit: the viewer still opens /consignors
PASS audit: the viewer still opens /items
PASS audit: the viewer still opens /sales
PASS audit: the viewer still opens /payouts
PASS audit: every *.view permission the code asks for is granted to the viewer, except audit.view
== fix wave: every backup and restore route is administrator-only ==
PASS backup: a signed-in viewer gets 403 on GET /backup.json
PASS backup: a signed-in viewer gets 403 on GET /backup.sqlite
PASS backup: a signed-in viewer gets 403 on GET /restore
PASS backup: a signed-in viewer gets 403 on POST /restore/dry-run
PASS backup: a signed-in viewer gets 403 on POST /restore/commit
PASS backup: a signed-in viewer gets 403 on POST /restore/token
PASS backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
PASS backup: a signed-in member gets 403 on GET /backup.json
PASS backup: a signed-in member gets 403 on GET /backup.sqlite
PASS backup: a signed-in member gets 403 on GET /restore
PASS backup: a signed-in member gets 403 on POST /restore/dry-run
PASS backup: a signed-in member gets 403 on POST /restore/commit
PASS backup: a signed-in member gets 403 on POST /restore/token
PASS backup: a signed-in member without the token gets nothing from GET /backup/scheduled
PASS backup: an admin still opens the restore page
== fix wave: the SMTP password is never written into the page ==
PASS smtp: a typed password is saved
PASS smtp: the settings page does not contain the stored password
PASS smtp: saving with the password field left blank keeps the stored password
== fix wave: consignors hear about their sales and payouts ==
PASS consignor switches exist and start off
PASS consignor note is off by default
PASS consignor note skips a consignor with no address
PASS consignor note a dead mail server breaks nothing
PASS consignor hears a sale with price share and balance
PASS consignor hears a payout with the remaining balance
PASS consignor with no address still sells
PASS agent sale gets the same side effects
PASS settings: the two consignor switches are on the settings page
PASS ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)
== fix wave: the webhook buttons work ==
PASS webhooks: Add creates the endpoint (no 404)
PASS webhooks: Pause switches it off (no 404)
PASS webhooks: pressing it again switches it back on
PASS webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
PASS webhooks: Delete removes it (no 404)
-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
PASS demo ribbon: on the consignor portal (/portal/…) it says what the page is, not "signed in as the admin"
PASS demo ribbon: an administrator's page keeps the admin wording
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
PASS demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
PASS backup: the JSON download carries none of the 10 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, consignors.portal_token_hash, api_keys.token_hash, webhooks.secret, invites.token_hash)
————————————————————————————————————————————————————
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)
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
319/319 PASS, 0 FAIL