Home›Loyalora›Test run
Loyalora · Test run · run 2026-11-07

Loyalora Test run, as shipped in the download

424 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.

[DB: SQLite]

== 1. parse_cents: money a HUMAN typed, in string maths ==
  PASS  "19.99" (got 1999, want 1999)
  PASS  "0.01" (got 1, want 1)
  PASS  "12" (no decimal) (got 1200, want 1200)
  PASS  ".5" (got 50, want 50)
  PASS  "12." (trailing dot) (got 1200, want 1200)
  PASS  a third decimal truncates, never rounds up (got 199, want 199)
  PASS  US grouping "1,234.56" (got 123456, want 123456)
  PASS  EU grouping "1.234,56" (got 123456, want 123456)
  PASS  spaces as grouping "1 234,56" (got 123456, want 123456)
  PASS  a lone separator is the DECIMAL point ("1,234") (got 123, want 123)
  PASS  and the same in the other convention ("1.234") (got 123, want 123)
  PASS  so "1,50" is one fifty (got 150, want 150)
  PASS  and "0.500" is fifty cents (got 50, want 50)
  PASS  and a three-decimal price is NOT read as thousands (got 599, want 599)
  PASS  negative (got -550, want -550)
  PASS  junk is NULL, not zero — "you typed something odd" is not "that is worth nothing" (got NULL, want NULL)
  PASS  empty is NULL (got NULL, want NULL)
  PASS  a lone separator is NULL (got NULL, want NULL)

== 2. pointsForSpend: integer arithmetic that always rounds DOWN ==
  PASS  $24.99 at 1/unit -> 24 (got 24, want 24)
  PASS  $25.00 at 1/unit -> 25 (got 25, want 25)
  PASS  $0.99 at 1/unit -> 0 (got 0, want 0)
  PASS  a zero spend earns nothing (got 0, want 0)
  PASS  a negative spend earns nothing (got 0, want 0)
  PASS  $25.00 at 0.5/unit -> 12 (rounds DOWN, never to 13) (got 12, want 12)
  PASS  $1.99 at 0.5/unit -> 0 (got 0, want 0)
  PASS  $24.99 at 10/unit -> 249 (got 249, want 249)
  PASS  a zero rate earns nothing, and does not divide by zero (got 0, want 0)
  PASS  a stamps program earns nothing from a spend (got 0, want 0)
  PASS  a points program earns nothing from a visit (got 0, want 0)
  PASS  one visit, one stamp (got 1, want 1)
  PASS  three visits, three stamps (got 3, want 3)
  PASS  three stamps a visit, two visits -> 6 (got 6, want 6)
  PASS  negative visits earn nothing (got 0, want 0)
  PASS  a very large spend at a high rate stays a believable integer

== 3. ⭐ balance is a SUM — there is no balance column ==
  PASS  a customer with no entries has a zero balance (got 0, want 0)
  PASS  100 + 50 - 30 - 20 = 100 (got 100, want 100)
  PASS  the SQL sum and a PHP sum over the same rows agree
  PASS  no table anywhere carries a balance column
  PASS  the first card is unchanged (got 100, want 100)
  PASS  the second card has its own (got 7, want 7)
  PASS  and the total is both (got 107, want 107)
  PASS  balanceByProgram splits them the same way
  PASS  balances() gets every customer in ONE query and agrees with balance()
  PASS  balances(programId) narrows to that program

== 4. ⭐ redeem: the check and the write are ONE step ==
  PASS  a reward beyond the balance is refused
  PASS  and the refusal names both numbers
  PASS  the refused redemption wrote NOTHING (got 250, want 250)
  PASS  not even an entry (got 1, want 1)
  PASS  not even a redemption row (got 0, want 0)
  PASS  an affordable reward succeeds
  PASS  the balance drops by exactly the cost (got 150, want 150)
  PASS  the returned balance matches the ledger (got 150, want 150)
  PASS  it wrote ONE redeem entry (got 1, want 1)
  PASS  and ONE redemption row (got 1, want 1)
  PASS  the redemption points at the exact entry that spent the points
  PASS  the redeem entry is NEGATIVE (got -100, want -100)
  PASS  a reward costing EXACTLY the balance succeeds
  PASS  and lands on zero (got 0, want 0)
  PASS  the same reward at zero is then refused
  PASS  and zero is still zero (got 0, want 0)
  PASS  a retired reward cannot be redeemed
  PASS  a reward with no cost is refused rather than redeemed for nothing
  PASS  a reward that does not exist is refused
  PASS  a customer that does not exist is refused
  PASS  after every refusal the balance is untouched (got 500, want 500)

== 5. adjust: the correction path, held to the same floor ==
  PASS  an adjustment without a reason is refused
  PASS  an adjustment of zero is refused — it would say nothing
  PASS  neither wrote anything (got 100, want 100)
  PASS  a positive adjustment lands
  PASS  and moves the balance (got 150, want 150)
  PASS  a negative adjustment lands
  PASS  and moves it back (got 100, want 100)
  PASS  an adjustment that would go below zero is refused
  PASS  and the refusal names the balance
  PASS  the refused adjustment wrote nothing (got 100, want 100)
  PASS  an adjustment straight TO zero is allowed
  PASS  and lands on zero (got 0, want 0)

== 6. entries are IMMUTABLE ==
  PASS  no code path anywhere UPDATEs or DELETEs an entry
  PASS  there is exactly ONE writer of entries — Loyalty::addEntry
  PASS  an unknown entry kind falls back to adjust rather than being stored as junk

== 7. affordable(): what they can have, and what the next one needs ==
  PASS  two are affordable at 120 (got 2, want 2)
  PASS  the dearest affordable is listed first (got 100, want 100)
  PASS  the nearest out of reach is the next one (got 'C', want 'C')
  PASS  and it names exactly how many short (got 130, want 130)
  PASS  at zero nothing is affordable (got 0, want 0)
  PASS  and the next one is the cheapest (got 'A', want 'A')
  PASS  short by its full cost (got 50, want 50)
  PASS  a big balance affords everything (got 4, want 4)
  PASS  and there is no next (got NULL, want NULL)
  PASS  a balance EXACTLY equal to a cost affords it (got 2, want 2)
  PASS  no rewards at all is not a crash (got 0, want 0)

== 8. running balance: how the number got here ==
  PASS  history comes back newest-first (got 10, want 10)
  PASS  and the newest running balance is the current balance (got 130, want 130)
  PASS  the oldest row shows the first entry alone (got 100, want 100)
  PASS  the running balance never skips a step (got 120, want 120)
  PASS  every step equals the one before it plus that entry
  PASS  history is ordered by WHEN IT HAPPENED, not by which row was written first
  PASS  and the running balance follows that same order

== 9. labels: the register says what it does, in words ==
  PASS  1 point per unit (got '1 point per unit spent, rounded down', want '1 point per unit spent, rounded down')
  PASS  half a point (got '0.5 points per unit spent, rounded down', want '0.5 points per unit spent, rounded down')
  PASS  ten points (got '10 points per unit spent, rounded down', want '10 points per unit spent, rounded down')
  PASS  one stamp a visit (got '1 stamp per visit', want '1 stamp per visit')
  PASS  three a visit (got '3 stamps per visit', want '3 stamps per visit')
  PASS  the default name for a points program (got 'point', want 'point')
  PASS  and for a stamps program (got 'stamp', want 'stamp')
  PASS  an operator name wins (got 'star', want 'star')
  PASS  singular at one (got 'point', want 'point')
  PASS  plural at zero (got 'points', want 'points')
  PASS  plural at two (got 'points', want 'points')

== 10. csv_cell: numeric-aware formula-injection guard ==
  PASS  a leading = is neutralized (got '\'=1+1', want '\'=1+1')
  PASS  a leading + is neutralized (got '\'+cmd', want '\'+cmd')
  PASS  a leading @ is neutralized (got '\'@SUM', want '\'@SUM')
  PASS  a NEGATIVE NUMBER is left alone — every redemption in this ledger is one (got '-500', want '-500')
  PASS  a hyphenated word is still neutralized (got '\'-cmd', want '\'-cmd')
  PASS  ordinary text is untouched (got 'Mira Halvorsen', want 'Mira Halvorsen')
  PASS  a comma is quoted (got '"a,b"', want '"a,b"')
  PASS  a quote is doubled (got '"say ""hi"""', want '"say ""hi"""')

== 11. csrf_valid: the empty-token bypass (hash_equals('','') is TRUE) ==
  PASS  an unset session token can never match
  PASS  an unset session token rejects a supplied one
  PASS  a real token matches itself
  PASS  a real token rejects a wrong one

== 12. Pdf: produces a valid, non-trivial document ==
  PASS  starts with the PDF magic
  PASS  ends with the EOF marker
  PASS  is not a stub
  PASS  page placeholders are resolved, never printed literally

== 13. roles: the rung that matters is redeem vs adjust ==
  PASS  the ladder has one definition (got array (
  0 => 'viewer',
  1 => 'clerk',
  2 => 'manager',
  3 => 'admin',
), want array (
  0 => 'viewer',
  1 => 'clerk',
  2 => 'manager',
  3 => 'admin',
))
  PASS  a viewer reads and writes nothing
  PASS  a clerk earns and redeems at the counter
  PASS  ⭐ but a clerk may NOT adjust — the only entry that creates points from nothing
  PASS  a manager may adjust, and edit the scheme
  PASS  a manager may NOT reach settings, the team or the API
  PASS  an admin may do everything
  PASS  an unknown role gets nothing
  PASS  every rung survives being normalized through the product list
  PASS  ⭐ a normalize WITHOUT the product list would silently lose the clerk rung
  PASS  no caller normalizes without the product list
  PASS  the last admin cannot be demoted
  PASS  the last admin cannot be switched off (the save hands the guard "disabled" when Active is unticked)

== 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
  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  the layout links the manifest + registers the SW

== client_ip ==
  PASS  a direct visitor's forged headers are ignored
  PASS  behind a trusted proxy the forwarded visitor is used
  PASS  CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  the chain is read right-to-left, past trusted hops
  PASS  a Cloudflare edge is not a trusted proxy by default
  PASS  CIDR maths (v4 boundaries + v6, no cross-family match)

== ⭐ concurrency (real parallel processes) ==
  PASS  the balance starts at exactly 300 (three redemptions' worth)
  raced 6 processes: 3 succeeded, 3 refused for lack of points, 0 other
  PASS  exactly three redemptions succeeded — not four, not two
  PASS  every other racer was refused for lack of points, not by an error
  PASS  the balance landed on exactly zero
  PASS  the balance NEVER went below zero
  PASS  one redeem entry per success
  PASS  one redemption row per redeem entry
  PASS  the points spent equal the points that existed
  PASS  one point short: not one of four racers got through
  PASS  and the short balance is untouched
  PASS  a negative adjustment cannot take a balance below zero
  PASS  the refused adjustment wrote nothing
  PASS  the concurrency proof ran and passed

-- Own It 2.0 --
  PASS  v2: tables
  PASS  v2: totp_cols
  PASS  v2: smtp_cols
  PASS  v2: notify_defaults_off
  PASS  v2: loyalty_tables
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: upgrade_from_1x
  PASS  v2: serializer_matches_core
  PASS  v2: entries_survive_a_customer_anonymise

-- Own It 3.0 --
  PASS  v3: v3_tables
  PASS  v3: v3_columns
  PASS  v3: upgrade_restores_v3_tables
  PASS  v3: migrate_ensures_the_settings_singleton
  PASS  v3: mcp_card_matches_the_engine
  PASS  v3: mcp_refuses_an_unknown_customer
  PASS  v3: every_read_tool_is_annotated_readOnly
  PASS  v3: write_tools_are_not_annotated_readOnly
  PASS  v3: a_read_only_key_still_sees_the_read_tools
  PASS  v3: mcp_write_hits_the_same_refusal
  PASS  v3: mcp_write_actually_writes
  PASS  v3: mcp_has_no_adjust_tool
  PASS  v3: mcp_cannot_set_a_points_figure
  PASS  v3: customer_created_fires
  PASS  v3: points_earned_fires
  PASS  v3: reward_redeemed_fires
  PASS  v3: brand_css_brands_the_light_theme
  PASS  v3: brand_css_brands_the_dark_theme_too
  PASS  v3: brand_dark_accent_is_legible_on_the_dark_canvas
  PASS  v3: unbranded_install_emits_no_brand_css
  PASS  v3: scheduled_backup_writes_a_dated_file
  PASS  v3: scheduled_backup_redacts_credential_material
  PASS  v3: scheduled_backup_prunes_to_the_retention_count
  PASS  v3: a_dry_run_writes_nothing
  PASS  v3: the_report_and_the_import_agree_exactly
  PASS  v3: the_import_wrote_exactly_what_it_promised
  PASS  v3: a_bound_int_threshold_would_have_matched_nothing
  PASS  v3: the_panel_never_compares_a_bound_string_to_a_sum
  PASS  v3: points_are_never_measured_against_another_programmes_reward
  PASS  v3: gdpr_export_carries_the_ledger
  PASS  v3: anonymise_blanks_the_person_and_keeps_the_points
  PASS  v3: audit_records_the_writes
  PASS  v3: audit_detail_carries_no_secret
  PASS  v3: db_files_cleaned

== wiring ==
  PASS  every route has a function to answer it
  PASS  every function the controllers CALL actually exists
  PASS  every view the controllers render exists on disk
  PASS  no view posts to a route that does not exist
  PASS  no POST route shares a path with a real directory
  PASS  index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
  PASS  healthz answers BEFORE the installer redirect, on a bare box and a schemaless one
  PASS  2FA intercepts login + login is rate-limited
  PASS  the API reuses the tested core (never re-derives a balance)
  PASS  no ternary key re-reads (PLAN item 10)
  PASS  a screen that renders a credential is gated on READ, not only on write
  PASS  every UI mutation is role-gated, not merely logged in
  PASS  no ungated write route beyond the by-design baseline (router-derived)

== UI kit ==
  PASS  layout loads the kit css+js, groups the nav (>=3 groups), keeps the theme toggle and a who-line
  PASS  style.css is an app layer with a dark accent, and no theme.js survives
  PASS  no font or script is fetched from another host (fonts bundled)
  PASS  every structured empty state uses an icon the kit actually masks
  PASS  no shipped asset is dead weight — every js/css file is actually loaded
  PASS  every table uses the kit's WRAPPER contract, not class="tbl" on the table itself
  PASS  no view leans on a CSS class the app layer never defines

== the hero + the honest limits ==
  PASS  hero: the card shows the balance, what they can afford, and what the next one needs
  PASS  hero: an affordable reward is redeemable in place, behind a confirm
  PASS  hero: the full immutable history is on the same page, with a running balance
  PASS  hero: the earn form asks what HAPPENED, never a points figure
  PASS  hero: the activity form offers programs in the customer's own order, not alphabetical
  PASS  the dashboard computes its first-run checklist from the DATABASE
  PASS  ⚠️ the product says out loud that it is not a marketing tool
  PASS  ⚠️ and there is no bulk-send machinery anywhere to contradict it

== API-key scope ==
  PASS  api_keys gains a scope column defaulting to full — an upgrade never loosens
  PASS  requireKey reads the key scope and refuses a write on a read-only key
  PASS  mintKey persists the chosen scope, and anything but read is full access
  PASS  /mcp hands the key scope to the core, which refuses write tools per tool
  PASS  every MCP read tool declares readOnly, so a read-only key can see something
  PASS  the key form lets an administrator mint a read-only key
  PASS  the key-create route passes the chosen scope through to mintKey
  PASS  README documents the release and API.md gains the MCP section
  PASS  the deploy kit is filled and ships inside app/
  PASS  every shared core this product ships is byte-identical to the reference copy
  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

== F2: the balance email exists; the SMTP password is write-only ==
  PASS  balance email: both switches on, an address and SMTP — it may send (got '', want '')
  PASS  balance email: the master switch off refuses in words (got 'Mail is switched off in Settings.', want 'Mail is switched off in Settings.')
  PASS  balance email: the balance switch off refuses in words (got 'Balance emails are switched off in Settings.', want 'Balance emails are switched off in Settings.')
  PASS  balance email: a customer with no address is refused, not attempted (got 'This customer has no email address on file.', want 'This customer has no email address on file.')
  PASS  balance email: a demo install never sends (got 'Demo mode — no mail leaves this server.', want 'Demo mode — no mail leaves this server.')
  PASS  balance email: SMTP not configured is refused, not faked as sent (got 'SMTP is not configured.', want 'SMTP is not configured.')
  PASS  balance email: first name, the balance from the ledger, what it reaches now and what is next
  PASS  balance email: carries no other customer, no history lines, no link
  PASS  balance email: routed, permission-gated, CSRF-checked, refusal first, then the logged send
  PASS  balance email: the customer card offers the button only when both switches are on and there is an address
  PASS  balance email: a refused SMTP connection is recorded as failed in the outbox, and nothing throws
  PASS  mail: no dead operator-notice helper is left to promise a send (it had no caller)
  PASS  security: the settings page never prints the stored SMTP password, and a blank post keeps it
  PASS  demo ribbon: below 600px it starts as the small pill unless the visitor opened it
  PASS  backups: every non-admin role gets 403 on every backup, restore and backup-token route
  PASS  backups: an administrator still gets the JSON backup (got 'GATE 200', want 'GATE 200')
  PASS  forms: every POST form on a screen reaches a registered route (43 checked)
  PASS  webhooks: import_writes_the_customer
  PASS  webhooks: import_opening_balance_is_an_adjustment
  PASS  webhooks: import_sends_customer_created
  PASS  webhooks: import_commit_goes_through_the_writer
  PASS  backup JSON: an administrator gets a backup, with every credential column planted (12)
  PASS  backup JSON: the download carries none of them
  PASS  backup JSON: the scheduled backup carries none of them
  PASS  backup JSON: the download and the scheduled writer read the one redaction list
  PASS  security page: the backup card says every credential is redacted, not only the SMTP password

== 1.1: the customer's card, the counter, the till ==
  PASS  1.1: qr_decodes_back_to_the_payload
  PASS  1.1: qr_reed_solomon_parity_is_valid
  PASS  1.1: qr_format_block_names_the_real_mask
  PASS  1.1: qr_covers_versions_1_to_10
  PASS  1.1: qr_refuses_an_over_long_payload
  PASS  1.1: the QR matches the golden symbols verified against an independent decoder (as in Assetora)
  PASS  1.1: w3_card_gets_a_short_code_and_a_token
  PASS  1.1: w3_the_card_is_stable
  PASS  1.1: w3_the_counter_finds_by_code_as_typed
  PASS  1.1: w3_the_counter_finds_by_the_scanned_qr_url
  PASS  1.1: w3_junk_finds_nobody
  PASS  1.1: w3_a_new_card_kills_the_old_one
  PASS  1.1: w3_the_qr_is_the_card_page_and_decodes
  PASS  1.1: w3_the_card_token_leaves_the_backup
  PASS  1.1: w3_the_card_page_shows_first_name_balance_and_activity
  PASS  1.1: w3_the_card_page_shows_nothing_else
  PASS  1.1: w3_an_inactive_customers_card_page_is_gone
  PASS  1.1: w3_wrong_card_links_are_rate_limited
  PASS  1.1: w3_the_balance_email_carries_the_card_page
  PASS  1.1: w3_a_sale_earns_by_the_programmes_rate
  PASS  1.1: w3_the_same_sale_posted_twice_records_once
  PASS  1.1: w3_a_sale_without_ref_card_or_amount_is_refused
  PASS  1.1: w3_a_spend_that_earns_nothing_records_nothing
  PASS  1.1: w3_with_two_spend_programmes_the_till_names_one
  PASS  1.1: w3_the_sales_door_is_role_checked_and_routed
  PASS  1.1: w3_counter_and_print_routes_exist
  PASS  1.1: w3b_tiers_parse_sort_and_drop_bad_lines
  PASS  1.1: w3b_below_the_threshold_earns_the_plain_rate
  PASS  1.1: w3b_the_tier_counts_only_the_last_12_months
  PASS  1.1: w3b_a_tier_multiplies_what_is_earned_at_the_one_door
  PASS  1.1: w3b_the_till_reports_the_points_after_the_tier
  PASS  1.1: w3b_the_mcp_and_api_report_points_after_the_tier
  PASS  1.1: w3b_the_customers_page_shows_their_tier
  PASS  1.1: w3b_items_earn_a_bonus_each_by_the_rules
  PASS  1.1: w3b_item_quantities_are_capped_and_junk_ignored
  PASS  1.1: w3b_a_till_sale_with_items_earns_spend_plus_bonus
  PASS  1.1: w3b_the_counter_adds_the_item_bonus_too
  PASS  1.1: w3b_the_programme_keeps_only_the_lines_that_count
  PASS  1.1: w3b_every_entry_records_its_shop
  PASS  1.1: w3b_the_mcp_tool_takes_items_and_the_shop
  PASS  1.1: w3b_an_unknown_or_closed_shop_is_refused
  PASS  1.1: w3b_the_ledger_and_its_export_show_the_shop
  PASS  1.1: w3b_a_refund_takes_the_sales_points_back_once
  PASS  1.1: w3b_a_refund_never_takes_a_balance_below_zero
  PASS  1.1: w3b_a_refund_of_an_unknown_sale_is_refused
  PASS  1.1: w3b_a_refunded_sale_does_not_count_toward_the_tier
  PASS  1.1: w3b_the_refund_door_is_role_checked_routed_and_documented
  PASS  1.1: w3b_enrolment_is_off_until_switched_on
  PASS  1.1: w3b_self_enrolment_makes_a_new_card_at_once
  PASS  1.1: w3b_self_enrolment_reveals_nobody_on_file
  PASS  1.1: w3b_staff_see_the_same_email_match
  PASS  1.1: w3b_self_enrolment_refuses_no_name_or_a_bad_email
  PASS  1.1: w3b_the_enrol_form_is_rate_limited_with_a_honeypot
  PASS  1.1: w3b_new_screens_are_routed_and_gated
  PASS  1.1: w3r_the_till_redeems_a_reward_by_card_code
  PASS  1.1: w3r_the_same_till_reference_redeems_once
  PASS  1.1: w3r_a_short_balance_is_refused_and_the_reference_can_be_used_again
  PASS  1.1: w3r_an_unknown_card_no_reference_or_no_reward_is_refused
  PASS  1.1: w3r_the_till_redeem_door_is_routed_and_needs_the_redeem_role

== fix wave: every REST write checks the key holder's role ==
  PASS  api: a viewer's key is refused POST /api/customers (403, needs customer.edit)
  PASS  api: a clerk's key is refused POST /api/customers (403, needs customer.edit)
  PASS  api: a manager's key passes the role check on POST /api/customers
  PASS  api: a admin's key passes the role check on POST /api/customers
  PASS  api: a viewer's key is refused POST /api/customers/{id}/earn (403, needs entry.earn)
  PASS  api: a clerk's key passes the role check on POST /api/customers/{id}/earn
  PASS  api: a manager's key passes the role check on POST /api/customers/{id}/earn
  PASS  api: a viewer's key is refused POST /api/customers/{id}/redeem (403, needs entry.redeem)
  PASS  api: a clerk's key passes the role check on POST /api/customers/{id}/redeem
  PASS  api: a admin's key passes the role check on POST /api/customers/{id}/redeem
  PASS  api: every /api/* write route checks the key holder's role (no key-only writes)

————————————————————————————————————————————————————

== 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)
  PASS  updates: the updater table holds exactly one state row
  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 Loyalora 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 (0 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 Loyalora 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: under the standard keep count (14) every backup is 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: 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 6 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 (23 tables dumped), outside the 1 column(s) kept by ruling (possible legal records)
  PASS  erase planting · sqlite: the subject-access export, taken before the erase, shows every value the erase removes (6)
  PASS  erase planting · sqlite: a read-only viewer cannot download a customer's subject-access export: HTTP 403
  PASS  erase planting · sqlite: the erase page (/customers/1/edit) says what an erase keeps: "Kept: the audit trail's lines that this customer was added o…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: the audit trail's lines that this customer was added o…"
ALL 424 TESTS PASSED (0 failed)

← Back to Loyalora · Manual · Quickstart · API