Supplia · Test run · run 2026-11-07
Supplia Test run, as shipped in the download
495 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-11-07 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 neutralized (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralized (got '\'+1', want '\'+1')
PASS -SUM neutralized (got '\'-SUM(A1)', want '\'-SUM(A1)')
PASS @cmd neutralized (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralized (got '\' x', want '\' x')
PASS cmd payload neutralized (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 v4.1.3
== 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 4.1.3
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 license: every font ships the SIL OFL text for its own family beside it
PASS license: 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 license
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 (13 columns: api_keys.token_hash, backup_state.s3_secret, feed_tokens.token_hash, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.llm_api_key, updater_state.license_api_key, 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
== 3.2: renewal reminders that go out ==
PASS 3.2: steps parse
PASS 3.2: the decision date is the notice deadline first
PASS 3.2: a step goes on its own day
PASS 3.2: before the first step it waits and says when
PASS 3.2: an install that was off sends only the step it is in
PASS 3.2: a sent step is not sent again and the next is named
PASS 3.2: a date in the past is never reminded
PASS 3.2: the plan picks what is due today
PASS 3.2: the owner gets it else the team address
PASS 3.2: the message names the deadline the renewal and the link
PASS 3.2: off by default nothing is sent
PASS 3.2: switched on each due step goes once
PASS 3.2: the claim is unique
PASS 3.2: moving the date starts the steps again
PASS 3.2: the daily cap holds the rest
PASS 3.2: a refused send is logged and its claim freed
PASS 3.2: the contract form keeps a valid owner address only
PASS 3.2: the check left no demo marker behind (a stray .demo-mode turns an install into the public demo)
PASS 3.2: index.php loads the reminder rules and the 3.2 controller
PASS 3.2: the dashboard runs the lazy reminder batch, and the scheduler route checks its token
== 3.2 pass B: search across stored contracts, key dates read from the file ==
PASS 3.2 pass B: a browser pdf with subset fonts reads as words
PASS 3.2 pass B: pdfs this catalogue writes read back
PASS 3.2 pass B: object streams and word gaps in tj arrays
PASS 3.2 pass B: not a pdf or encrypted reads as nothing
PASS 3.2 pass B: stored files are indexed once
PASS 3.2 pass B: search finds words inside a stored pdf in any order and case
PASS 3.2 pass B: search covers contracts suppliers and file notes
PASS 3.2 pass B: every word must appear
PASS 3.2 pass B: highlighting escapes every piece
PASS 3.2 pass B: reading is off by default and needs a key
PASS 3.2 pass B: a proposal keeps only what it can trust
PASS 3.2 pass B: reading sends the text layer not the file with the key only in the header
PASS 3.2 pass B: the proposal is laid over the form with what changes
PASS 3.2 pass B: a scan goes as the file
PASS 3.2 pass B: ollama reads text but is told plainly it cannot take a scanned pdf
PASS 3.2 pass B: a refusal never carries the key and every read is audited
PASS 3.2 pass B: only a file on a contract can fill it in
PASS 3.2 pass B: the doors and the confirm step
PASS 3.2 pass B: the key is left out of the backup and the demo sends nothing
PASS 3.2 pass B: the next 120 days in four bands on the decision date
PASS 3.2 pass B: index loads the reader and the contract reader; both uploads index the file and a delete drops its text
PASS api roles: POST /api/vendors refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/vendors/{id}/contracts refuses every role without the permission (403) and admits the rest
== 3.3: supplier documents that expire ==
PASS 3.3 documents: off by default documents are not reminded
PASS 3.3 documents: only supplier level dated documents of active suppliers are watched
PASS 3.3 documents: switched on the due document goes today to the team and the next waits
PASS 3.3 documents: the message names the document the supplier the date and the link
PASS 3.3 documents: each due step goes once and the log names the document
PASS 3.3 documents: a document and a contract with the same id never share a claim
PASS 3.3 documents: a renewed date starts the steps again
PASS 3.3 documents: the 120 day horizon shows documents on their expiry date
PASS 3.3 documents: the settings switch is saved and audited
PASS 3.3 documents: the supplier page lists and files its documents
PASS 3.3 documents: the check left no demo marker behind
== 3.3: the team's copy of a reminder ==
PASS 3.3 team copy: off by default no copy
PASS 3.3 team copy: switched on an owned contract copies the team and only then
PASS 3.3 team copy: the owner and the team each get it once logged to whom
PASS 3.3 team copy: the copy does not use up the daily cap
PASS 3.3 team copy: the settings switch is saved and audited and the preview names the copy
PASS 3.3 team copy: through the mailer the owner and the team each receive it
PASS 3.3 team copy: the check left no demo marker behind
== 3.3: the imports' dry run ==
PASS 3.3 import dry run: the suppliers dry run writes nothing
PASS 3.3 import dry run: the suppliers import agrees
PASS 3.3 import dry run: the contracts dry run counts only what the import takes and writes nothing
PASS 3.3 import dry run: the dry run gives the imports own reasons
PASS 3.3 import dry run: the contracts import then agrees row for row
PASS 3.3 import dry run: the upload uses the preview and no import skips the row checks
————————————————————————————————————————————————————
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
SKIP updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /updates is updates_page()
PASS updates: route post /updates/key is updates_key_save()
PASS updates: route post /updates/check is updates_check()
PASS updates: route post /updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Supplia with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (4 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Supplia with records refuses and deletes nothing
PASS A2: backup: written, sent to the bucket (a signed PUT), and read back
PASS A2: backup: the bucket secret is not in the backup it protects
PASS A2: backup: the owner's "keep" count is honored (2 kept)
PASS A2: switched off: with automatic backups off, a backup is never due however old the last one
PASS A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
PASS A2: an offsite endpoint must be https for an owner (plain http only in tests)
PASS A2: all 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /sample-data/remove is sample_data_remove()
PASS A2: route post /restore/backups is backups_save()
PASS A2: route post /restore/backup-now is backup_now()
PASS A2: route get /health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: "Load demo data" outside the public demo goes through SampleData (only into an empty Supplia, removable)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS erase planting · sqlite: the subject is created and a marker is planted in all 3 personal columns
PASS erase planting · sqlite: the app's own erase runs through its route and reports no failure
PASS erase planting · sqlite: no planted marker survives anywhere (21 tables dumped)
ALL 495 TESTS PASSED (0 failed)
← Back to Supplia · Manual · Quickstart · API