Ownware
Home›Vendra›Test run
Vendra · Test run · run 2026-09-25

Vendra Test run, as shipped in the download

242 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. Weighted-average cost ==
  PASS  after 10@$2: avg = $2.00 (200000 mc) (got 200000, want 200000)
  PASS  after +10@$4: avg = $3.00 (got 300000, want 300000)
  PASS  on hand 20 (got 20, want 20)
  PASS  valuation = 20 x $3 = $60.00 (got 6000, want 6000)

== 2. Sale: pricing, tax after discount, COGS ==
  PASS  quote ok
  PASS  subtotal 5x1000+4x500 = 7000 (got 7000, want 7000)
  PASS  discount 200 line + 300 sale = 500 (got 500, want 500)
  PASS  tax = 172 (after both discounts) (got 172, want 172)
  PASS  total = 7000-500+172 = 6672 (got 6672, want 6672)
  PASS  COGS = 5x300 + 4x300 = 2700 (got 2700, want 2700)

== 3. Checkout: payments, change, stock, ledger ==
  PASS  checkout ok
  PASS  change = 328 (got 328, want 328)
  PASS  widget stock 20-5=15 (got 15, want 15)
  PASS  gadget stock 50-4=46 (got 46, want 46)
  PASS  drawer keeps total (cash net of change) = 6672 (got 6672, want 6672)
  PASS  2 sale movements stamped with sale id (got 2, want 2)
  PASS  ledger == cache (integrity) (got 0, want 0)

== 4. Oversell guard (single-process) ==
  PASS  sell the last 2 ok
  PASS  selling from empty stock rejected
  PASS  rejection names the product
  PASS  stock stays 0 (not negative) (got 0, want 0)

== 5. Payment validation ==
  PASS  underpayment rejected
  PASS  card overpayment rejected (no change from card)
  PASS  split card+cash ok
  PASS  split change = 100 (from cash) (got 100, want 100)
  PASS  split cash kept = 400 (got 400, want 400)

== 5b. Oversize guards (INT-overflow / MySQL strict-mode safety) ==
  PASS  oversize cash payment rejected (change_cents overflow)
  PASS  oversize payment error is clean
  PASS  oversize line/sale total rejected at quote
  PASS  normal sale still quotes fine

== 6. Returns ==
  PASS  return 2 widgets ok
  PASS  refund = 1912 (got 1912, want 1912)
  PASS  widget restocked 14+2=16 (got 16, want 16)
  PASS  over-return rejected (only 3 left)
  PASS  ledger == cache after returns (got 0, want 0)
  PASS  sale marked partial_ref (got 'partial_ref', want 'partial_ref')

== 7. Register reconciliation ==
  PASS  open register
  PASS  second open blocked
  PASS  register sale ok
  PASS  card sale ok
  PASS  in-session cash refund ok
  PASS  refund = 1000 (got 1000, want 1000)
  PASS  expected cash = 10000 (got 10000, want 10000)
  PASS  card total tracked = 1000 (got 1000, want 1000)
  PASS  close ok
  PASS  over/short = -50 (short) (got -50, want -50)
  PASS  double close blocked

== 8. Adjustments + negative-stock setting ==
  PASS  adjust -1 ok
  PASS  adjust below zero rejected when negative stock off
  PASS  adjust below zero allowed when setting on
  PASS  stock now -1 (got -1, want -1)

== 9. Ledger integrity end-to-end ==
  PASS  final integrity check: 0 mismatches (got 0, want 0)
  PASS  ledger populated (>= 12 movements)

== 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: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: api_sale_ok
  PASS  v2: oversell_refused
  PASS  v2: lowstock_crossing
  PASS  v2: lowstock_no_repeat
  PASS  v2: ledger_intact
  PASS  v2: serializers_clean
  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 till fires the same side effects as the API (one code path)
  PASS  v2: API sales reuse Sales::checkout (never a second stock path)
  PASS  v2: API purchases reuse Inventory::receive (weighted-average costing)
  PASS  v2: notifications never break the request
  PASS  v2: dry run and commit share ONE validator
  PASS  v2: no undefined-key ternary re-reads in any controller
  PASS  v2: endpoints taking nested input allowlist their keys in Api::body()
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: dark mode keeps filled amber buttons legible (dark on-accent ink via kit tokens)
  PASS  v2: theme.js + qr.js ship and the layout loads theme.js with a toggle
  PASS  v2: shared cores are present and unforked
  PASS  v2: .htaccess re-allows the /backup.sqlite ROUTE while still denying real .sqlite files
  PASS  v2: API.md ships + sw cache is v2.0.0 + healthz reports 2.0.0
  PASS  v3: version is 3.1.5 everywhere (sw.js, healthz, api_ping, openapi)

-- Own It 3.0 --
  PASS  v3: v3_tables
  PASS  v3: v3_columns
  PASS  v3: upgrade_from_2x
  PASS  v3: settings_singleton_ensured
  PASS  v3: settings_update_takes_effect
  PASS  v3: roles_cashier_is_pos_only
  PASS  v3: roles_viewer_reads_only
  PASS  v3: roles_manager_stops_at_admin
  PASS  v3: roles_admin_all
  PASS  v3: roles_unknown_grants_nothing
  PASS  v3: last_admin_guard
  PASS  v3: audit_redacts_secrets
  PASS  v3: audit_append_only
  PASS  v3: mcp_tools_present
  PASS  v3: mcp_record_sale_is_not_readonly
  PASS  v3: mcp_write_tool_declares_it
  PASS  v3: mcp_arg_guard
  PASS  v3: mcp_initialize
  PASS  v3: mcp_get_is_405
  PASS  v3: mcp_oversell_refused
  PASS  v3: mcp_legal_sale_decrements
  PASS  v3: ledger_agrees_with_stock
  PASS  v3: mcp_sale_is_audited
  PASS  v3: pdf_renders
  PASS  v3: pdf_money_is_the_products
  PASS  v3: branding_refuses_svg_accepts_png
  PASS  v3: branding_ink_is_AA
  PASS  v3: branding_rejects_bad_accent
  PASS  v3: gdpr_erases_identity_keeps_money
  PASS  v3: restore_refuses_other_product
  PASS  v3: restore_accepts_own
  PASS  v3: oidc_refuses_bad_token
  PASS  v3: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
  PASS  v3: every 3.0 route is registered
  PASS  v3: MCP record_sale reuses Sales::checkout — the oversell guard is never restated
  PASS  v3: the Z-report PDF renders _reports_data and never re-aggregates
  PASS  v3: the PDF layer formats with the product's own money() helper
  PASS  v3: bulk actions loop single-item writes inside ONE transaction (no mass UPDATE)
  PASS  v3: SSO never creates accounts, and local login is untouched
  PASS  v3: the role ladder EXTENDS vendra's existing cashier/admin (nothing loosens)
  PASS  v3: the accent ink is DERIVED at render, so a blank accent_ink can never render below AA
  PASS  v3: a branded accent survives DARK mode (the dark block outranks :root — brand both)
  PASS  v3: migrate() ensures the settings singleton (a missing row silently swallowed every save)
  PASS  v3: the barcode scanner is local-only (no CDN, no upload) and degrades gracefully
  PASS  v3: camera scans land on the SAME add path as the laser scanner
  PASS  v3: core Mcp is byte-identical to the shared core
  PASS  v3: core AuditLog is byte-identical to the shared core
  PASS  v3: core Pdf is byte-identical to the shared core
  PASS  v3: core BackupRestore is byte-identical to the shared core
  PASS  v3: core Branding is byte-identical to the shared core
  PASS  v3: core RolesKit is byte-identical to the shared core
  PASS  v3: core Gdpr is byte-identical to the shared core
  PASS  v3: core Oidc is byte-identical to the shared core
  PASS  v3/C2: the ladder is a GATE, not a word list — each role grants exactly its own seat
  PASS  v3/C2: the catalog screen is gated on catalog.view, NOT product.view (the cashier holds that one)
  PASS  v3/C2: every page the ladder promises is permission-gated, so a role is not decorative
  PASS  v3/C2: a read-only seat cannot work the till — the POS is gated, not just signed-in
  PASS  v3/C2: signing in lands you on a page your role can actually open
  PASS  v3/C2: no controller decides access by comparing the role NAME (the ladder is a map, not a string)
  PASS  v3/C2: the sidebar asks the same permission the route asks (menu and URL cannot disagree)
  PASS  v3/C2: every role the save path accepts is offered in the UI (an unassignable role is not a feature)
  PASS  v3/C2: an MCP line/payment field must be a single value — a nested one silently became qty 1
  PASS  v3/C2: v3_mcp_scalar refuses an array/object and passes a scalar through
  PASS  v3/C2: a minted invite link is actually shown to the operator, once
  PASS  C2R: api_sale_create guards scalars inside lines/payments with v3_mcp_scalar
  PASS  C2R: api_sale_create is gated on the sale.create permission, like its MCP twin
  PASS  C2R: api_purchase_create guards its nested line scalars too
  PASS  C2R: an absent payment method still defaults to cash through the 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 v1.0 · POS live tender preview (/pos/quote) ==
  PASS  quote preview: happy path prices the cart
  PASS  quote preview: total matches the engine math (got 2478, want 2478)
  PASS  quote preview: discount total = line + spread sale discount (got 250, want 250)
  PASS  quote preview: zero quantity refused by the engine
  PASS  quote preview: endpoint returns 422 on a refused cart
  PASS  quote preview: endpoint requires sale.create + CSRF
  PASS  quote preview: route registered and the till calls it

== v3.1 · emailed receipt (P1) ==
  PASS  receipt: carries the code, the total and the change
  PASS  receipt: uses the operator tax name and footer
  PASS  receipt: leaks NO cost, margin or internal note
  PASS  receipt: deterministic — the same input builds the same bytes (got 'Test Store

Receipt ABC12345
2026-08-20 10:00:00 UTC
----------------------------------------
2 x Widget                        $18.90
   discount                       -$2.00
----------------------------------------
Subtotal                          $20.00
Discounts                         -$2.00
Sales Tax                          $0.90
TOTAL                             $18.90
Paid — Cash                       $20.00
Change                             $1.10

Thanks!

Keep this email as your receipt.
', want 'Test Store

Receipt ABC12345
2026-08-20 10:00:00 UTC
----------------------------------------
2 x Widget                        $18.90
   discount                       -$2.00
----------------------------------------
Subtotal                          $20.00
Discounts                         -$2.00
Sales Tax                          $0.90
TOTAL                             $18.90
Paid — Cash                       $20.00
Change                             $1.10

Thanks!

Keep this email as your receipt.
')
  PASS  receipt: every money line is the same display width (multi-byte safe) (got 1, want 1)
  PASS  receipt: notify_receipt ships OFF (got 0, want 0)
  PASS  receipt: with the toggle off nothing is sent
  PASS  receipt: a refused-by-toggle send writes no outbox row (got 0, want 0)
  PASS  receipt: with the toggle on it completes without SMTP (demo)
  PASS  receipt: the attempt is recorded in the outbox (got 1, want 1)
  PASS  receipt: a malformed address is refused
  PASS  receipt: the refusal is visible in the outbox (got 1, want 1)
  PASS  receipt: the resend endpoint demands sale.create + CSRF
  PASS  receipt: the automatic path is idempotent (already-sent short-circuit)
  PASS  receipt: an explicit resend forces a fresh send
  PASS  receipt: a mail failure never rolls back the sale — the send happens after checkout committed
  PASS  receipt: route registered
  PASS  receipt: src/Mail.php is loaded by the app bootstrap, not only by this harness

== v3.1 · day-close (Z-report) email ==
  PASS  day-close: carries the expected-vs-counted pair and the over/short verdict
  PASS  day-close: says in words whether the drawer was over, short or exact
  PASS  day-close: notify_zreport ships OFF (got 0, want 0)
  PASS  day-close: the subject is one-per-session (its own idempotency key)
  PASS  day-close: a session already reported is not reported twice
  PASS  day-close: an open (not yet closed) session is refused
  PASS  day-close: closing the register cannot be undone by a mail failure
  PASS  scope: api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  scope: requireKey reads the key scope and refuses a write on a read-only key
  PASS  scope: mintKey persists the chosen scope, and anything but read is full access
  PASS  scope: /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  scope: the key form lets an administrator mint a read-only key
  PASS  licence: every font ships the SIL OFL text for its own family beside it
  PASS  licence: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause

== F2: fixes ==
  PASS  F2 security: the settings page never echoes the stored SMTP password into its HTML
  PASS  F2 security: a blank SMTP password keeps the stored one; only the remove box clears it
  PASS  F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
  PASS  F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
  PASS  F2 export: a sale line lands in the sales CSV with qty, line total and the cost the till captured
  PASS  F2 export: the date range filters (a range with no sales has no rows)
  PASS  F2 export: a product name that starts with = exports as text, not a formula
  PASS  F2 export: negative money stays a number
  PASS  F2 export: the stock CSV carries stock, average cost and value at cost
  PASS  F2 export: /sales.csv needs report.view and /stock.csv needs catalog.view (a cashier gets neither)
  PASS  F2 security: a signed-in non-admin (viewer, cashier, manager) gets 403 on every backup and restore route
  PASS  F2 security: the probe is real — an administrator is let through to the backup download
  PASS  F2 invite: with SMTP set up, the invitation is delivered over a real SMTP conversation, carrying the link
  PASS  F2 invite: with no SMTP it says so and sends nothing (the link is still shown once)
  PASS  F2 invite: a refusing mail server is reported, never thrown
  PASS  F2 invite: the invite handler emails it and still shows the link once
  PASS  F2 URL: with an absolute base_url the SSO redirect_uri carries the host once (it was doubled)
  PASS  F2 URL: without base_url the SSO redirect_uri is still built from the request host
  PASS  F2 routes: every POST form in the views reaches a POST route (36 checked)
  PASS  F2 webhooks: the events offered in Settings (sale.recorded, stock.low) are exactly the events the code sends
  PASS  F2b ribbon: Vendra has no public page, so its pages keep the admin wording (/, /pos)
  PASS  F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
  PASS  F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
  PASS  F2b ribbon: boot() installs it only behind the .demo-mode marker
  PASS  F2b security: /backup/scheduled refuses a signed-in viewer, cashier, manager with no token or a wrong one (403)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, invites.token_hash)
  PASS  F2b backup: the JSON download carries none of them
  PASS  F2b backup: the scheduled backup carries none of them
  PASS  F2b backup: both backup doors use the one list, v3_backup_redact()

================================================
  242 passed, 0 failed
================================================

← Back to Vendra · 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 →