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

← Back to Consigna · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →