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