Supplia · Test run · run 2026-09-25
Supplia Test run, as shipped in the download
360 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. money: parse_cents / fmt_cents / clamp (contract values are integer cents) ==
PASS "12,000.00" -> 1200000 (got 1200000, want 1200000)
PASS "1,250" -> 125000 (got 125000, want 125000)
PASS "$8" -> 800 (got 800, want 800)
PASS "" -> null (got NULL, want NULL)
PASS "1.239" -> null (3dp) (got NULL, want NULL)
PASS 1200000 -> "12,000.00" (got '12,000.00', want '12,000.00')
PASS 0 -> "0.00" (got '0.00', want '0.00')
PASS 15-digit clamps to 2e9 (got 2000000000, want 2000000000)
PASS clamp_cents floors at 0 (got 0, want 0)
PASS clamp_cents caps at 2e9 (got 2000000000, want 2000000000)
== 2. Supply::money + normalizeCurrency + enum normalizers/labels ==
PASS $12,000.00 (got '$12,000.00', want '$12,000.00')
PASS -$45.00 (sign before symbol) (got '-$45.00', want '-$45.00')
PASS EUR symbol (got '€250.00', want '€250.00')
PASS ZAR code prefix (got 'ZAR 200.00', want 'ZAR 200.00')
PASS normalize "usd" -> USD (got 'USD', want 'USD')
PASS normalize junk -> USD (got 'USD', want 'USD')
PASS normalizeCategory upper (got 'it_software', want 'it_software')
PASS normalizeCategory junk -> other (got 'other', want 'other')
PASS categoryLabel logistics (got 'Logistics & Shipping', want 'Logistics & Shipping')
PASS categoryLabel junk -> Other (got 'Other', want 'Other')
PASS normalizeVendorStatus junk -> active (got 'active', want 'active')
PASS vendorStatusLabel inactive (got 'Inactive', want 'Inactive')
PASS normalizeBaseStatus CLOSED -> closed (got 'closed', want 'closed')
PASS normalizeBaseStatus expiring -> active (never stored) (got 'active', want 'active')
PASS normalizeBaseStatus junk -> active (got 'active', want 'active')
PASS contractStatusLabel expiring (got 'Expiring', want 'Expiring')
PASS contractStatusLabel expired (got 'Expired', want 'Expired')
PASS contractStatusLabel junk -> Active (got 'Active', want 'Active')
== 3. sumCents + rollup: by-category spend, in PHP integer cents (overflow-clamped) ==
PASS sumCents of 3 = 504000 (got 504000, want 504000)
PASS sumCents empty -> 0 (got 0, want 0)
PASS sumCents missing key -> 0 (got 0, want 0)
PASS sumCents overflow clamps to 2e9 (got 2000000000, want 2000000000)
PASS rollup it_software = 144000 (got 144000, want 144000)
PASS rollup facilities = 450000 (got 450000, want 450000)
PASS rollup logistics = 240000 (got 240000, want 240000)
PASS rollup has exactly 3 groups (got 3, want 3)
PASS rollup grand total reconciles (got 834000, want 834000)
PASS rollup per-group overflow clamps to 2e9 (got 2000000000, want 2000000000)
== 4. DATE MATH — daysUntil: exact across day/month/year/leap/DST boundaries ==
PASS same day -> 0 (got 0, want 0)
PASS tomorrow -> +1 (got 1, want 1)
PASS yesterday -> -1 (got -1, want -1)
PASS month boundary Jul31->Aug01 = 1 (got 1, want 1)
PASS year boundary Dec31->Jan01 = 1 (got 1, want 1)
PASS non-leap Feb28->Mar01 = 1 (2026) (got 1, want 1)
PASS leap Feb28->Mar01 = 2 (2028) (got 2, want 2)
PASS DST spring-forward Mar07->Mar09 = 2 (got 2, want 2)
PASS DST fall-back Oct31->Nov02 = 2 (got 2, want 2)
PASS full year Jan01->Dec31 = 364 (got 364, want 364)
PASS reverse full year = -364 (got -364, want -364)
PASS two years incl leap = 731 (got 731, want 731)
== 5. daysToDate + expiryState: window classification at every threshold edge (60/30/7) ==
PASS daysToDate today = 0 (got 0, want 0)
PASS daysToDate +7 = 7 (got 7, want 7)
PASS daysToDate -1 = -1 (got -1, want -1)
PASS daysToDate empty = null (got NULL, want NULL)
PASS daysToDate invalid = null (got NULL, want NULL)
PASS no date -> none (got 'none', want 'none')
PASS -1 day -> expired (got 'expired', want 'expired')
PASS 0 days (today) -> critical (got 'critical', want 'critical')
PASS exactly 7 -> critical (got 'critical', want 'critical')
PASS 8 -> warning (got 'warning', want 'warning')
PASS exactly 30 -> warning (got 'warning', want 'warning')
PASS 31 -> notice (got 'notice', want 'notice')
PASS exactly 60 -> notice (got 'notice', want 'notice')
PASS 61 -> ok (got 'ok', want 'ok')
PASS state carries the day count (got array (
'state' => 'critical',
'days' => 3,
), want array (
'state' => 'critical',
'days' => 3,
))
PASS mis-ordered thresholds still classify sanely (got 'warning', want 'warning')
== 6. isExpiryAlert + expiryPhrase (end date) + renewalPhrase wording ==
PASS expired is an alert
PASS critical is an alert
PASS notice is an alert
PASS ok is NOT an alert
PASS none is NOT an alert
PASS expiry phrase none (got 'No end date', want 'No end date')
PASS expiry phrase expires today (got 'Expires today', want 'Expires today')
PASS expiry phrase in 1 day (got 'Expires in 1 day', want 'Expires in 1 day')
PASS expiry phrase in 2 days (got 'Expires in 2 days', want 'Expires in 2 days')
PASS expiry phrase expired 1 day ago (got 'Expired 1 day ago', want 'Expired 1 day ago')
PASS expiry phrase expired 3 days ago (got 'Expired 3 days ago', want 'Expired 3 days ago')
PASS renewal phrase none (got 'No renewal date', want 'No renewal date')
PASS renewal phrase renews today (got 'Renews today', want 'Renews today')
PASS renewal phrase in 1 day (got 'Renews in 1 day', want 'Renews in 1 day')
PASS renewal phrase in 5 days (got 'Renews in 5 days', want 'Renews in 5 days')
PASS renewal phrase was 1 day ago (got 'Renewal was 1 day ago', want 'Renewal was 1 day ago')
PASS renewal phrase was 12 days ago (got 'Renewal was 12 days ago', want 'Renewal was 12 days ago')
== 7. STATUS DERIVATION: active/expiring/expired/closed from base + end date vs today ==
PASS closed + future end -> closed (got 'closed', want 'closed')
PASS closed + past end -> closed (got 'closed', want 'closed')
PASS closed + no end -> closed (got 'closed', want 'closed')
PASS active + no end -> active (got 'active', want 'active')
PASS active + end yesterday -> expired (got 'expired', want 'expired')
PASS active + end today -> expiring (not expired) (got 'expiring', want 'expiring')
PASS active + end +7 (critical) -> expiring (got 'expiring', want 'expiring')
PASS active + end +30 (warning) -> expiring (got 'expiring', want 'expiring')
PASS active + end +60 (notice edge) -> expiring (got 'expiring', want 'expiring')
PASS active + end +61 (beyond notice) -> active (got 'active', want 'active')
PASS active + end far future -> active (got 'active', want 'active')
PASS boundary end today != expired (got true, want true)
PASS boundary end yesterday == expired (got 'expired', want 'expired')
PASS junk base + expired end -> expired (got 'expired', want 'expired')
== 8. Supply::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 cmd payload neutralised (got '\'=cmd|calc', want '\'=cmd|calc')
PASS CR payload quoted+prefixed (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 'Northwind Software Ltd', want 'Northwind Software Ltd')
PASS mid-string = untouched (got 'a=b', want 'a=b')
PASS comma/quote RFC-4180 quoted (got '"Net 30, ""monthly"""', want '"Net 30, ""monthly"""')
== 9. Supply::toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Vendor,Terms,Value', want 'Vendor,Terms,Value')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative value stays numeric in CSV
== 10. 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
== 11. safe_url: only http/https references are linkifiable ==
PASS https url kept (got 'https://drive.example.com/x.pdf', want 'https://drive.example.com/x.pdf')
PASS http url kept (got 'http://intranet/contract', want 'http://intranet/contract')
PASS javascript scheme rejected (got '', want '')
PASS plain filing ref rejected (got '', want '')
PASS empty -> empty (got '', want '')
== 12. 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
== 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: rate
PASS v2: csv_dry
PASS v2: csv_suppliers
PASS v2: csv_contracts_matched
PASS v2: derived_status
PASS v2: status_not_stored
PASS v2: contract_serializer
PASS v2: contract_serializer_nocompute
PASS v2: normalizers
PASS v2: backup_tables
PASS v2: migrate() runs on every boot — pre-2.0 this product only built its schema in the installer
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: notifications + webhooks fire on the BROWSER path too, never fatally
PASS v2: API reuses the tested core (no re-derived guards)
PASS v2: renewing-within filter exists and never re-derives its own windows
PASS v2: dark palette + no hardcoded white backgrounds outside @media print
PASS v2: theme.js + qr.js ship and layout loads theme.js + a toggle
PASS v2: shared modules are copy-identical to the shared core (never forked per app)
PASS v3: API.md ships + sw cache is v3.1.5
== Own It 3.0 ==
PASS v3: v3_tables
PASS v3: users_role_column
PASS v3: upgrade_dropped_ok
PASS v3: migrate_restores_v3_tables
PASS v3: upgrade_keeps_admin_rights
PASS v3: role_viewer_cannot_write
PASS v3: role_member_can_write
PASS v3: role_admin_wildcard
PASS v3: unknown_role_fails_closed
PASS v3: last_admin_guard
PASS v3: shared_writer_creates
PASS v3: writer_audits
PASS v3: writer_refuses_nameless
PASS v3: mcp_spec_uses_core_keys
PASS v3: mcp_five_tools
PASS v3: mcp_descriptions_state_writes
PASS v3: mcp_category_is_enum
PASS v3: mcp_enum_matches_product
PASS v3: argguard_refuses_nested
PASS v3: argguard_refuses_unknown
PASS v3: argguard_accepts_valid
PASS v3: audit_append_only
PASS v3: audit_redacts_secrets
PASS v3: spend_matches_core_sum
PASS v3: pdf_engine_renders
PASS v3: legacy_pdf_still_renders
PASS v3: two_distinct_pdf_classes
PASS v3: pdf_uses_product_money_helper
PASS v3: pdf_does_no_arithmetic
PASS v3: pdf_exports_are_audited
PASS v3: ical_token_roundtrip
PASS v3: ical_empty_hash_refused
PASS v3: feed_covers_contracts_and_documents
PASS v3: feed_uids_are_unique_and_stable
PASS v3: feed_is_crlf_only
PASS v3: feed_every_event_has_uid
PASS v3: feed_skips_undated_rows
PASS v3: sup_documents_can_belong_to_a_contract
PASS v3: sup_both_upload_doors_share_one_store
PASS v3: sup_contract_upload_is_gated_and_scoped
PASS v3: sup_a_renamed_text_file_is_not_a_contract
PASS v3: sup_contract_files_are_scoped_to_their_contract
PASS v3: sup_vendor_documents_are_untouched_by_contract_files
PASS v3: sup_removing_a_file_leaves_the_contract_alone
PASS v3: sup_serving_a_document_re_checks_the_bytes
PASS v3: sup_no_screen_still_denies_file_storage
PASS v3: sup314_notice_moves_the_radar
PASS v3: sup314_notice_is_read_from_the_form_and_clamped
PASS v3: sup314_feed_has_the_notice_date_and_skips_closed
PASS v3: sup314_api_reports_notice_and_cancel_by
PASS v3: sup_feed_covers_renewals_not_only_endings
PASS v3: sup_feed_carries_no_contacts_and_no_money
PASS v3: sup_feed_token_is_stored_hashed_and_checked_by_hash
PASS v3: sup_a_revoked_feed_token_is_out_of_the_live_set
PASS v3: sup_feed_route_refuses_a_bad_token
PASS v3: sup_the_feed_finally_has_a_door
PASS v3: sup_the_feed_url_is_built_once
PASS v3: gdpr_export_returns_full_rows
PASS v3: gdpr_anonymize_clears_contact
PASS v3: gdpr_keeps_company_row
PASS v3: gdpr_keeps_contract_history
PASS v3: gdpr_reports_rows_touched
PASS v3: gdpr_audit_is_count_only
PASS v3: restore_refuses_other_app
PASS v3: restore_refuses_non_backup
PASS v3: restore_parses_own_backup
PASS v3: diff_writes_nothing
PASS v3: diff_reports_updates
PASS v3: commit_applies_preview
PASS v3: redacted_never_overwrites
PASS v3: scheduled_backup_writes
PASS v3: scheduled_backup_redacts
PASS v3: prune_keeps_retention
PASS v3: commit_requires_preview_token
PASS v3: restore_paths_are_audited
PASS v3: branding_accepts_real_png
PASS v3: branding_returns_plain_filename
PASS v3: branding_content_type_from_bytes
PASS v3: branding_refuses_disguised_script
PASS v3: branding_refuses_svg
PASS v3: accent_validates_hex
PASS v3: ink_on_accent_is_AA
PASS v3: fixture_vendor_has_contracts
PASS v3: remove_archives_when_history_exists
PASS v3: remove_deletes_when_no_history
PASS v3: remove_audits_both_outcomes
PASS v3: bulk_reuses_removal_rule
PASS v3: bulk_reuses_vendor_writer
PASS v3: bulk_is_transactional
PASS v3: saved_view_pages_allowlisted
PASS v3: sso_unconfigured_is_null
PASS v3: sso_configured_when_complete
PASS v3: sso_partial_config_refused
PASS v3: sso_rejects_garbage_token
PASS v3: sso_failures_return_to_local_login
PASS v3: sso_does_not_autocreate_accounts
PASS v3: sso_uses_same_session_creation
PASS v3: scorecard_has_no_ontime_metric
PASS v3: scorecard_page_reuses_function
PASS v3: invite_token_roundtrip
PASS v3: invite_expiry_enforced
PASS v3: invite_lookup_finds_live
PASS v3: invite_lookup_rejects_bad
PASS v3: invite_is_single_use
PASS v3: last_admin_cannot_be_demoted
PASS v3: role_change_calls_the_guard
PASS v3: invite_accept_needs_no_session
PASS v3: db_files_cleaned
PASS v3: /healthz answers BEFORE the install guard (deploy-kit healthcheck)
PASS v3: healthz reports whether the install has run
PASS v3: every v3 route has its view template
PASS v3: deploy kit filled for this product
PASS v3: every version surface reports 3.1.5
PASS v3: migrate() runs at boot after the schemaExists guard
PASS v3: /mcp route registered and API-key guarded first
PASS v3: audit viewer is read-only (no edit/delete route exists)
PASS v3: audit view reads the core's `at` column, never created_at
PASS v3: requireKey refuses a deactivated user's key (u.active = 1 in the lookup)
PASS v3: legacy PDF writer moved aside, core engine present, both load
PASS v3: all 9 shared cores are present in src/
PASS v3: cores byte-identical to the shared core (catalog root reachable)
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: the brand accent is written for BOTH themes (dark re-declares --accent and outranks :root)
PASS v3: ONE redaction list covers the download AND the scheduled backup
PASS v3: no backup writer carries a redaction list of its own
PASS v3: migrate() ensures the settings singleton (an UPDATE … WHERE id = 1 needs a row)
PASS v3: GET /mcp answers the transport hint BEFORE the key check (settings relay item 3)
PASS v3: the deploy kit ships INSIDE app/ (package.sh only packages app/ + the root docs)
PASS v3: every api-settings key/webhook action is admin-gated (_sup_require('*')), not merely logged in
== 3.1: renewal radar ==
PASS extend: +12 months moves the end date a year out (got '2027-08-01', want '2027-08-01')
PASS extend: the renewal date moves by the same span (the notice window keeps its shape) (got '2027-07-20', want '2027-07-20')
PASS extend: month-end clamps (31 Jan +1 month is the last day of February, never 3 March) (got '2026-02-28', want '2026-02-28')
PASS extend: a leap February clamps to the 29th (got '2028-02-29', want '2028-02-29')
PASS extend: a LAPSED contract restarts from today, never back-dated into "extended and expired"
PASS extend: a nonsense span is refused with a sentence, not clamped silently
PASS extend: a contract with no end date is refused (there is nothing to push out)
PASS the extend endpoint delegates to Supply::extendDates and computes no dates of its own
PASS the extend endpoint gates, CSRF-checks and audits before/around the write
PASS the extend endpoint answers a JS-less form with a redirect, not a raw JSON body
PASS the radar buckets by the product's own configured windows, inventing no numbers
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)
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
PASS notice: 60 days before a 31 Dec renewal is 1 Nov (got '2026-11-01', want '2026-11-01')
PASS notice: with no renewal date it counts back from the end date (got '2026-11-01', want '2026-11-01')
PASS notice: across a leap day, 1 day before 1 Mar 2028 is 29 Feb (got '2028-02-29', want '2028-02-29')
PASS notice: no notice period, or no date to count back from, gives no deadline
PASS notice: phrase on the day (got 'Last day to give notice is today', want 'Last day to give notice is today')
PASS notice: phrase after the day (got 'Notice deadline passed 3 days ago', want 'Notice deadline passed 3 days ago')
PASS notice: the contract form asks for the notice period, and the column migrates in
PASS backup: the JSON and the raw .sqlite backup downloads take the admin permission
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: supplier.created is sent by the browser action where it happens [302]
PASS webhooks: contract.created is sent by the browser action where it happens [302]
PASS webhooks: contract.closed is sent by the browser action where it happens [302]
PASS webhooks: every event the Settings page offers is sent somewhere
PASS webhooks: saving an already-closed contract again does not send contract.closed twice
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, /api-settings/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/api-settings\/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, /api-settings/backup-token [{"page":403,"posts":{"\/restore":403,"\/restore\/commit":403,"\/api-settings\/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,"\/api-settings\/backup-token":302}}]
PASS backup gate: the JSON backup carries no reusable credential from any table (10 columns: api_keys.token_hash, feed_tokens.token_hash, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_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 360 TESTS PASSED (0 failed)
← Back to Supplia · Manual · Quickstart · API