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