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)