Membora · Test run · run 2026-11-07
Membora Test run, as shipped in the download
419 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. moneyToCents: symbols, US/EU, ints, floats, junk ==
PASS "$49.00" (got 4900, want 4900)
PASS "59" (got 5900, want 5900)
PASS "1,250.00" (got 125000, want 125000)
PASS EU "1.250,00" (got 125000, want 125000)
PASS EU decimal "12,50" (got 1250, want 1250)
PASS int 130 (major) (got 13000, want 13000)
PASS float 15.5 (got 1550, want 1550)
PASS "0" (got 0, want 0)
PASS empty -> null (got NULL, want NULL)
PASS "N/A" -> null (got NULL, want NULL)
PASS null -> null (got NULL, want NULL)
== 2. centsToDecimal + money formatting ==
PASS 5900 -> "59.00" (got '59.00', want '59.00')
PASS 5 -> "0.05" (got '0.05', want '0.05')
PASS null -> "" (got '', want '')
PASS money 5900 $ (got '$59.00', want '$59.00')
PASS money null -> — (got '—', want '—')
PASS symbol USD (got '$', want '$')
PASS symbol EUR (got '€', want '€')
== 3. date math: daysBetween, addDays, addMonths ==
PASS daysBetween same (got 0, want 0)
PASS daysBetween +10 (got 10, want 10)
PASS daysBetween past (-3) (got -3, want -3)
PASS addDays +30 (got '2026-08-04', want '2026-08-04')
PASS addDays -1 (got '2026-07-04', want '2026-07-04')
PASS addMonths +1 (got '2026-08-15', want '2026-08-15')
PASS addMonths +12 (annual) (got '2027-07-15', want '2027-07-15')
PASS addMonths clamps Jan31 (got '2026-02-28', want '2026-02-28')
PASS monthKey (got '2026-07', want '2026-07')
== 4. effectiveStatus: active stays / lapses, frozen & canceled hold ==
PASS active, future end -> active (got 'active', want 'active')
PASS active, end today -> active (got 'active', want 'active')
PASS active, end passed -> expired (got 'expired', want 'expired')
PASS active, open-ended -> active (got 'active', want 'active')
PASS frozen holds past end (got 'frozen', want 'frozen')
PASS canceled holds (got 'cancelled', want 'cancelled')
PASS stored expired stays expired (got 'expired', want 'expired')
PASS no membership -> none (got 'none', want 'none')
== 5. votingGuard: THE constitution's rule ==
PASS in good standing -> vote recorded
PASS suspended -> no vote
PASS resigned -> no vote
PASS status-lapsed -> no vote
PASS date-lapsed active -> no vote, WITH the date
PASS no subscription at all -> no vote
PASS refusal explains they may still attend + count toward quorum
PASS life member -> always votes
PASS honorary member -> always votes
PASS ends today -> still votes (boundary)
PASS ended yesterday -> no vote (boundary)
== 7. status transitions ==
PASS active -> frozen
PASS frozen -> active
PASS active -> canceled
PASS active -> expired
PASS expired -> active (renew)
PASS canceled -> active (no)
PASS canceled -> anything (no)
PASS active -> active (no)
== 8. termDates: what a fresh subscription term looks like per rate ==
PASS monthly end = +1 month (got '2026-08-05', want '2026-08-05')
PASS annual end = +12 months (got '2027-07-05', want '2027-07-05')
PASS life never ends (got NULL, want NULL)
PASS honorary never ends (got NULL, want NULL)
PASS unknown rate falls back to annual (got '2027-07-05', want '2027-07-05')
PASS start date is preserved (got '2026-07-05', want '2026-07-05')
PASS Jan 31 +1 month lands in February (got '2026-02-28', want '2026-02-28')
== 9. isExpiring + daysUntil (window detection) ==
PASS active ending in 5d -> expiring
PASS active ending in 14d (boundary) -> expiring
PASS active ending in 15d -> NOT expiring
PASS active ending today -> expiring
PASS already past end -> NOT expiring
PASS frozen -> NOT expiring
PASS open-ended (pack) -> NOT expiring
PASS daysUntil future (got 14, want 14)
PASS daysUntil past (got -4, want -4)
PASS daysUntil open (got NULL, want NULL)
== 10. isOverdue + arrearsTotal (boundary at the due date) ==
PASS due yesterday unpaid -> overdue
PASS due today unpaid -> NOT overdue
PASS due tomorrow unpaid -> NOT overdue
PASS paid past-due -> NOT overdue
PASS arrears sums only overdue-unpaid (got 7400, want 7400)
== 11. revenueInMonth ==
PASS revenue July = paid-in-July only (got 20400, want 20400)
PASS revenue June (got 5900, want 5900)
PASS revenue empty month (got 0, want 0)
== 12. CSV export: headers + RFC-4180 escaping ==
PASS members header (got 'Name,Member No,Email,Phone,Joined,Standing,Rate,Start,Ends,Covered by,Type', want 'Name,Member No,Email,Phone,Joined,Standing,Rate,Start,Ends,Covered by,Type')
PASS CRLF line endings
PASS comma/quote name is quoted+escaped
PASS member number exported
PASS a rate that never ends exports an empty Ends cell
PASS payments header (got 'Member,Amount,Due Date,Paid,Paid Date,Method', want 'Member,Amount,Due Date,Paid,Paid Date,Method')
PASS paid=1 -> yes
PASS paid=0 -> no
== CSV formula-injection guard ==
PASS Membership =-formula neutralized (got '\'=1+2', want '\'=1+2')
PASS Membership @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
PASS Membership leading-TAB neutralized (got '\' X', want '\' X')
PASS Membership -text neutralized (got '\'-abc', want '\'-abc')
PASS Membership negative number kept (got '-45.00', want '-45.00')
PASS Membership plain value untouched (got 'Safe Name', want 'Safe Name')
PASS Membership quoting still applies (got '"\'=a,b"', want '"\'=a,b"')
== security hardening: clampCents / safe_back_path / str_clip ==
PASS clampCents caps MySQL INT overflow (got 999999999, want 999999999)
PASS clampCents floors negative price at 0 (got 0, want 0)
PASS clampCents min=1 keeps payments positive (got 1, want 1)
PASS clampCents passes a normal price through (got 4999, want 4999)
PASS clampCents null stays null (got NULL, want NULL)
PASS safe_back_path allows member page (got 'members/12', want 'members/12')
PASS safe_back_path allows arrears page (got 'payments/arrears', want 'payments/arrears')
PASS safe_back_path blocks absolute URL (got 'payments', want 'payments')
PASS safe_back_path blocks protocol-relative (got 'payments', want 'payments')
PASS safe_back_path blocks backslash trick (got 'payments', want 'payments')
PASS safe_back_path blocks leading slash (got 'payments', want 'payments')
PASS safe_back_path blocks null/empty (got 'payments', want 'payments')
PASS str_clip clips to column length (got 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', want 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
PASS str_clip leaves short strings alone (got 'Fit Club', want 'Fit Club')
== 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/v3: tables
PASS v2/v3: totp_cols
PASS v2/v3: smtp_cols
PASS v2/v3: mint
PASS v2/v3: revoke
PASS v2/v3: totp
PASS v2/v3: recovery
PASS v2/v3: delivery
PASS v2/v3: rate
PASS v2/v3: csv_dry
PASS v2/v3: backup_tables
PASS v2/v3: attendance_is_one_row_per_member
PASS v2/v3: quorum_counts_present_only
PASS v2/v3: quorum_follows_a_correction
PASS v2/v3: withdrawn_attendance_stops_counting
PASS v2/v3: quorum_zero_means_none_required
PASS v2/v3: voting_guard_allows_good_standing
PASS v2/v3: voting_guard_refuses_lapsed
PASS v2/v3: import_preview_matches_commit
PASS v2/v3: import_no_partial_write
PASS v2/v3: import_preview_writes_nothing
PASS v2/v3: import_commit_writes_one
PASS v2/v3: tx_skeleton
PASS v2/v3: tx_rollback
PASS v2/v3: gdpr_map_columns_exist
PASS v2/v3: audit_redacts
PASS v2/v3: roll_row_matches_the_voting_guard
PASS v2/v3: roll_row_carries_rate_and_state
PASS v2/v3: attendance_ignores_standing
PASS v2/v3: re_marking_updates_never_duplicates
PASS v2/v3: a_correction_can_undo_quorum
PASS v2/v3: cancelled_meeting_refuses_attendance
PASS v2/v3: capacity_of_one_admits_exactly_one
PASS v2/v3: apologies_never_take_a_seat
PASS v2/v3: roll_json_delegates_never_reimplements
PASS v2/v3: roll_search_is_read_only_and_gated
PASS v2/v3: decide_refuses_without_quorum
PASS v2/v3: vote_asks_the_voting_guard
PASS v2/v3: expiry_columns_exist
PASS v2/v3: expiry_is_off_until_the_owner_turns_it_on
PASS v2/v3: expiry_uses_the_products_own_window_rule
PASS v2/v3: expiry_skips_a_member_with_nowhere_to_write
PASS v2/v3: expiry_happy_path_actually_reaches_the_member
PASS v2/v3: expiry_notice_points_at_the_secretary_not_a_card_form
PASS v2/v3: expiry_is_once_per_membership_per_end_date
PASS v2/v3: expiry_ledger_refuses_a_duplicate_row
PASS v2/v3: expiry_releases_the_slot_when_mail_fails
PASS v2/v3: expiry_mail_failure_never_throws
PASS v2/v3: expiry_cron_has_no_rule_of_its_own
PASS v2/v3: expiry_digest_is_a_separate_switch
PASS healthz + api_ping + openapi report 4.1.3
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v3: exactly ONE attendance write exists in the whole product
PASS v3: that write lives in record_attendance, inside a transaction, upserting on the unique index
PASS v3: every attendance door calls the ONE path (roll call, meeting form, API, MCP)
PASS v3: the attendance path never consults standing
PASS v3: exactly ONE definition of quorum
PASS v3: the shared path fires the attendance webhook, and the DECISION webhook is separate
PASS v2: no PHP-8 ternary key re-reads (PLAN item 10)
PASS v2: core modules stay dependency-free (no product helpers inside them)
PASS v2: dark palette + zero hardcoded white surfaces
PASS v2: theme.js + qr.js ship and layout loads theme.js
PASS v2: no themed background paired with an un-overridden hardcoded color (dark-mode trap)
PASS v2: dark overrides ship for every pill/alert that hardcodes a light palette
PASS v2: palette tokens are WIRED, not just declared (label/chip/hover-bg)
PASS v3: Database declares the full core-documented skeleton
PASS v3: API.md ships + sw cache is v4.1.3
PASS v3: no call to a function this product does not define
PASS v3: no sensitive handler is login-only — all are role-gated
PASS v3: API-key + webhook + backup handlers require settings.edit
PASS v3: both backup doors call the ONE shared redaction list
PASS v3: the backup redaction list names every secret column
PASS v3: the roll call the dashboard sends you to is in the sidebar
PASS v3: with no meeting, the roll call points at the page that creates one
PASS C2: the subject-access export takes the same standing as anonymize
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
== F2: the meeting record, minutes afterward, webhooks that arrive, a club's own words ==
PASS meeting record: roll, quorum on the day, tallies, mover/seconder and minutes, as CSV and PDF, from a real database — MEETING-RECORD-OK
PASS meeting record: PDF and CSV are routed behind meeting.view; minutes save is routed behind meeting.edit + CSRF
PASS meeting record: the meeting's roll page offers the minutes form and both downloads
PASS webhooks: the events offered for subscription are exactly the events the app fires (got array (
0 => 'attendance.refused',
1 => 'meeting.attendance',
2 => 'motion.decided',
3 => 'payment.recorded',
), want array (
0 => 'attendance.refused',
1 => 'meeting.attendance',
2 => 'motion.decided',
3 => 'payment.recorded',
))
PASS webhooks: an endpoint saved under the old name meeting.attended now receives meeting.attendance
PASS webhooks: every attendance refusal (canceled, closed and sealed, full, lost the race) fires attendance.refused
PASS renewal email: asks the member to reply or speak to the membership secretary, not "at the desk on your next visit"
PASS dashboard: the arrears link says what it does ("See arrears"), since nothing is sent to anybody
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 (70 checked)
PASS webhooks: mark_paid_marks_it_paid
PASS webhooks: mark_paid_sends_payment_recorded
PASS webhooks: already_paid_sends_nothing_new
PASS webhooks: mark_paid_route_goes_through_the_helper
PASS webhooks: mark_paid_emails_the_club
== 1.1: renewals that collect, reminders, the sealed meeting record ==
PASS 1.1: w3_renewal_notice_names_the_price
PASS 1.1: w3_renewal_notice_carries_the_link_and_the_bank_details
PASS 1.1: w3_without_link_or_bank_details_it_points_at_the_secretary
PASS 1.1: w3_follow_up_goes_to_the_member_who_has_not_renewed
PASS 1.1: w3_a_renewed_member_gets_no_follow_up
PASS 1.1: w3_the_follow_up_goes_once
PASS 1.1: w3_follow_up_can_be_switched_off
PASS 1.1: w3_send_reminder_reaches_the_member_with_amount_and_how_to_pay
PASS 1.1: w3_a_failed_reminder_is_not_counted
PASS 1.1: w3_a_paid_charge_gets_no_reminder
PASS 1.1: w3_arrears_has_send_reminder
PASS 1.1: w3_close_refuses_while_a_motion_is_open
PASS 1.1: w3_close_refuses_a_meeting_that_has_not_happened
PASS 1.1: w3_close_seals_the_record_with_its_sha256
PASS 1.1: w3_a_closed_meeting_cannot_be_closed_twice
PASS 1.1: w3_csv_and_pdf_carry_the_seal
PASS 1.1: w3_a_sealed_meeting_refuses_attendance_changes
PASS 1.1: w3_minutes_and_motions_of_a_sealed_meeting_are_refused
PASS 1.1: w3_a_changed_record_no_longer_matches_its_seal
PASS 1.1: w3_close_route_is_the_meeting_editors
PASS 1.1: w3b_fields_parse_by_type_and_one_per_label
PASS 1.1: w3b_field_values_are_checked
PASS 1.1: w3b_a_removed_fields_value_is_kept_not_dropped
PASS 1.1: w3b_fields_are_columns_in_the_members_csv_formula_safe
PASS 1.1: w3b_registration_needs_both_member_number_and_email
PASS 1.1: w3b_registration_respects_capacity_through_the_one_roll_path
PASS 1.1: w3b_a_members_cancel_link_frees_the_place
PASS 1.1: w3b_only_meetings_opened_for_registration_take_it
PASS 1.1: w3b_the_events_door_is_rate_limited_and_off_until_opened
PASS 1.1: w3b_card_token_is_stable_and_findable
PASS 1.1: w3b_card_shows_live_standing_and_the_qr
PASS 1.1: w3b_a_lapsed_members_card_says_so_the_same_day
PASS 1.1: w3b_new_card_kills_the_old_link_and_the_token_leaves_backups
PASS 1.1: w3o_an_unlinked_member_has_no_subscription
PASS 1.1: w3o_a_linked_member_is_covered_by_the_leads_membership
PASS 1.1: w3o_households_are_one_level_and_never_override_an_own_subscription
PASS 1.1: w3o_the_members_csv_names_the_household_lead
PASS 1.1: w3o_writes_never_reach_the_leads_term_from_a_covered_member
PASS 1.1: w3o_when_the_lead_lapses_the_household_does_too
PASS 1.1: w3o_leaving_deletes_nothing_and_keeps_the_paid_to_date
PASS 1.1: w3o_household_doors_are_routed_and_gated
PASS 1.1: w3o_a_paid_charge_gets_a_receipt
PASS 1.1: w3o_a_charge_still_due_gets_a_payment_request_with_how_to_pay
PASS 1.1: w3o_the_receipt_door_is_routed_and_needs_payment_view
PASS 1.1: w3r_an_organisation_holds_the_membership_that_covers_its_people
PASS 1.1: w3r_export_with_no_filter_is_everyone
PASS 1.1: w3r_export_filters_by_type_rate_and_lapsing
PASS 1.1: w3r_export_filters_by_arrears_and_by_a_club_field
PASS 1.1: w3r_the_csv_says_who_is_an_organisation
PASS 1.1: w3r_screens_carry_type_and_the_export_form
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
== fix wave: every REST write checks the key holder's role ==
PASS api: a viewer's key is refused POST /api/members (403, needs member.edit)
PASS api: a member's key passes the role check on POST /api/members
PASS api: a admin's key passes the role check on POST /api/members
PASS api: a viewer's key is refused POST /api/attendance (403, needs meeting.attend)
PASS api: a member's key passes the role check on POST /api/attendance
PASS api: a admin's key passes the role check on POST /api/attendance
PASS api: a viewer's key is refused POST /api/members/{id}/payments (403, needs payment.record)
PASS api: a member's key passes the role check on POST /api/members/{id}/payments
PASS api: a admin's key passes the role check on POST /api/members/{id}/payments
PASS api: every /api/* write route checks the key holder's role (no key-only writes)
PASS backup JSON: an administrator gets a backup, with every credential column planted (17)
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
== dues by card, renewal reminders by text ==
PASS dues/texts: pay_off_by_default
PASS dues/texts: pay_ready_with_a_test_key
PASS dues/texts: pay_due_renewal_when_inside_the_window
PASS dues/texts: pay_due_unpaid_charge_comes_first
PASS dues/texts: pay_due_nothing_when_not_due
PASS dues/texts: pay_checkout_asks_for_exactly_the_dues
PASS dues/texts: pay_checkout_goes_to_stripe_with_the_clubs_key
PASS dues/texts: pay_checkout_refuses_when_nothing_is_due
PASS dues/texts: pay_checkout_fails_softly_when_stripe_is_unreachable
PASS dues/texts: pay_signature_real_hmac_is_accepted
PASS dues/texts: pay_signature_wrong_secret_is_refused
PASS dues/texts: pay_signature_tampered_body_is_refused
PASS dues/texts: pay_signature_stale_event_is_refused
PASS dues/texts: pay_signature_missing_header_is_refused
PASS dues/texts: pay_webhook_bad_signature_is_400_and_records_nothing
PASS dues/texts: pay_webhook_renewal_renews_the_membership
PASS dues/texts: pay_webhook_records_one_paid_payment_with_the_session
PASS dues/texts: pay_webhook_same_event_twice_records_nothing
PASS dues/texts: pay_webhook_second_event_for_the_same_session_records_nothing
PASS dues/texts: pay_async_sequence_records_one_payment_and_one_renewal
PASS dues/texts: pay_webhook_logs_every_delivery
PASS dues/texts: pay_webhook_charge_is_marked_paid
PASS dues/texts: pay_webhook_other_currency_records_nothing
PASS dues/texts: pay_webhook_other_amount_records_nothing
PASS dues/texts: pay_webhook_unconfirmed_payment_waits
PASS dues/texts: pay_webhook_unknown_member_records_nothing
PASS dues/texts: pay_webhook_other_event_types_are_ignored
PASS dues/texts: pay_webhook_after_a_desk_renewal_pays_its_open_charge
PASS dues/texts: pay_renewal_email_links_to_the_card_page
PASS dues/texts: pay_renewal_email_has_no_card_line_when_off
PASS dues/texts: pay_card_page_shows_what_is_due_and_the_button
PASS dues/texts: pay_card_page_has_no_button_when_off
PASS dues/texts: pay_keys_are_left_out_of_the_shareable_backup
PASS dues/texts: pay_webhook_reads_the_raw_body
PASS dues/texts: sms_off_by_default
PASS dues/texts: sms_only_members_who_opted_in_get_a_text
PASS dues/texts: sms_text_names_the_club_and_carries_the_stop_link
PASS dues/texts: sms_default_mapping_is_json_to_from_text_with_a_bearer_key
PASS dues/texts: sms_never_the_same_reminder_twice
PASS dues/texts: sms_second_reminder_goes_once_three_days_before
PASS dues/texts: sms_quiet_hours_hold_texts_until_the_window
PASS dues/texts: sms_inside_both_windows_only_the_nearer_text
PASS dues/texts: sms_each_renewal_period_texts_again
PASS dues/texts: sms_stop_withdraws_consent
PASS dues/texts: sms_no_text_after_the_stop
PASS dues/texts: sms_gateway_failure_is_logged_and_not_fatal
PASS dues/texts: sms_failed_text_is_tried_again_next_run
PASS dues/texts: sms_twilio_mapping_is_form_to_from_body_with_basic
PASS dues/texts: sms_consent_gate_is_in_the_selection_query
PASS dues/texts: sms_page_load_run_sends_a_few_per_load
PASS dues/texts: sms_page_load_run_keeps_the_send_window
PASS dues/texts: sms_page_load_run_stops_at_a_refusal_and_waits_an_hour
PASS dues/texts: sms_page_load_run_never_throws
PASS dues/texts: sms_dashboard_runs_the_page_load_batch
PASS dues/texts: sms_window_is_validated
————————————————————————————————————————————————————
== 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 Membora 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 Membora 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 Membora, 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 9 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 (27 tables dumped)
PASS erase planting · sqlite: the member page offers Anonymize (the route that keeps the books) and says Remove deletes the history
ALL 419 TESTS PASSED (0 failed)
← Back to Membora · Manual · Quickstart · API