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

Leavora Test run, as shipped in the download

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

== 0. weekday anchor (0=Sun..6=Sat), UTC-anchored ==
  PASS  2026-01-05 is Monday(1) (got 1, want 1)
  PASS  2026-01-07 is Wednesday(3) (got 3, want 3)
  PASS  2026-01-10 is Saturday(6) (got 6, want 6)
  PASS  2026-01-11 is Sunday(0) (got 0, want 0)

== 1. parseDays / fmtDays: day amounts as INTEGER CENTIDAYS (1 day = 100) ==
  PASS  "20" -> 2000 (got 2000, want 2000)
  PASS  "1.5" -> 150 (got 150, want 150)
  PASS  "0.5" -> 50 (got 50, want 50)
  PASS  "20.25" -> 2025 (got 2025, want 2025)
  PASS  "0" -> 0 (got 0, want 0)
  PASS  " 12 " -> 1200 (got 1200, want 1200)
  PASS  "" -> null (got NULL, want NULL)
  PASS  "abc" -> null (got NULL, want NULL)
  PASS  "1.239" -> null (3dp rejected) (got NULL, want NULL)
  PASS  "-5" -> null (negative rejected) (got NULL, want NULL)
  PASS  2000 -> "20" (got '20', want '20')
  PASS  150 -> "1.5" (got '1.5', want '1.5')
  PASS  50 -> "0.5" (got '0.5', want '0.5')
  PASS  2025 -> "20.25" (got '20.25', want '20.25')
  PASS  2005 -> "20.05" (got '20.05', want '20.05')
  PASS  100 -> "1" (got '1', want '1')
  PASS  0 -> "0" (got '0', want '0')
  PASS  round-trip 12.5 (got '12.5', want '12.5')
  PASS  daysLabel 100 = "1 day" (got '1 day', want '1 day')
  PASS  daysLabel 2000 = "20 days" (got '20 days', want '20 days')
  PASS  daysLabel 50 = "0.5 days" (got '0.5 days', want '0.5 days')

== 2. date engine: add_days / add_months / days_inclusive (UTC-anchored, DST-safe) ==
  PASS  add_days +5 (got '2026-01-10', want '2026-01-10')
  PASS  add_days -1 (got '2025-12-31', want '2025-12-31')
  PASS  add_days across month (got '2026-02-01', want '2026-02-01')
  PASS  add_months +1 from day1 (got '2026-02-01', want '2026-02-01')
  PASS  add_months +12 = next year (got '2027-01-01', want '2027-01-01')
  PASS  add_months +11 (Dec1) (got '2026-12-01', want '2026-12-01')
  PASS  days_inclusive same day = 1 (got 1, want 1)
  PASS  days_inclusive Mon..Fri = 5 (got 5, want 5)
  PASS  days_inclusive reversed = 0 (got 0, want 0)
  PASS  days_inclusive full year 2026 = 365 (got 365, want 365)
  PASS  valid_ymd rejects 2026-02-30 (got false, want false)
  PASS  valid_ymd accepts 2026-02-28 (got true, want true)

== 3. workingDays: weekends + holidays excluded, boundaries, custom weekend ==
  PASS  single Wed = 1 (got 1, want 1)
  PASS  single Sat = 0 (got 0, want 0)
  PASS  single Sun = 0 (got 0, want 0)
  PASS  Mon..Fri = 5 (got 5, want 5)
  PASS  Fri..Mon (spans weekend)=2 (got 2, want 2)
  PASS  Mon..Sun (full week) = 5 (got 5, want 5)
  PASS  Mon..next-Mon (8 days) = 6 (got 6, want 6)
  PASS  end < start = 0 (got 0, want 0)
  PASS  Mon..Fri with Wed holiday = 4 (got 4, want 4)
  PASS  Mon..Fri, holidays Wed + Sat = 4 (got 4, want 4)
  PASS  holiday map form works (got 4, want 4)
  PASS  Fri+Sat weekend: Mon..Fri = 4 (got 4, want 4)
  PASS  Fri+Sat weekend: Sunday works = 1 (got 1, want 1)

== 4. workingDays across DST boundaries (America/New_York 2026) — count is exact ==
  PASS  DST spring: 2026-03-06 is Friday(5) (got 5, want 5)
  PASS  DST spring Fri..Mon (06..09) working = 2 (got 2, want 2)
  PASS  DST spring calendar days = 4 (got 4, want 4)
  PASS  DST spring across the jump (08..09) = 2 days (got 2, want 2)
  PASS  DST fall: 2026-10-30 is Friday(5) (got 5, want 5)
  PASS  DST fall Fri..Mon (10-30..11-02) working = 2 (got 2, want 2)
  PASS  DST fall calendar days = 4 (got 4, want 4)
  PASS  DST fall across the jump (11-01..11-02) = 2 days (got 2, want 2)
  PASS  2028 (leap) full year = 366 (got 366, want 366)
  PASS  leap Feb 2028 has 29 days (got 29, want 29)

== 5. requestCentidays: whole days x100; single-day half = 50; multi-day half ignored ==
  PASS  Mon..Fri = 500 centidays (got 500, want 500)
  PASS  single Wed = 100 (got 100, want 100)
  PASS  single Wed half = 50 (got 50, want 50)
  PASS  multi-day half flag ignored = 500 (got 500, want 500)
  PASS  weekend-only span = 0 (got 0, want 0)
  PASS  span minus holiday = 400 (got 400, want 400)

== 6. overlap / double-request detection (inclusive shared day = clash) ==
  PASS  touching ranges overlap (share 01-09)
  PASS  adjacent ranges do NOT overlap
  PASS  contained range overlaps
  PASS  clash against approved found
  PASS  no clash against a REJECTED range
  PASS  excludeId ignores the edited row
  PASS  clear window: no clash

== 7. leave-year boundaries (Jan-1 start and Apr-1 fiscal start) ==
  PASS  yearStart(2026,1,1) (got '2026-01-01', want '2026-01-01')
  PASS  yearStart clamps day to 28 (got '2026-02-28', want '2026-02-28')
  PASS  yearLabel Jan1: 2026-06-15 -> 2026 (got 2026, want 2026)
  PASS  yearLabel fiscal Apr1: 2026-03-15 -> 2025 (got 2025, want 2025)
  PASS  yearLabel fiscal Apr1: 2026-04-01 -> 2026 (got 2026, want 2026)
  PASS  yearLabel fiscal Apr1: 2026-12-31 -> 2026 (got 2026, want 2026)

== 8. accrual: annual (up front) vs monthly (progressive) vs none; start-date prorated ==
  PASS  annual @ Jan1 = 2000 (got 2000, want 2000)
  PASS  annual @ Jun15 = 2000 (got 2000, want 2000)
  PASS  monthly @ Jan1 = 166 (1/12 floored) (got 166, want 166)
  PASS  monthly @ Mar31 = 500 (3/12) (got 500, want 500)
  PASS  monthly @ Jun30 = 1000 (6/12) (got 1000, want 1000)
  PASS  monthly @ Dec31 = 2000 (12/12 exact — no drift) (got 2000, want 2000)
  PASS  none accrues nothing (got 0, want 0)
  PASS  annual, Apr-1 hire @ Apr1 = 1500 (9/12 up front) (got 1500, want 1500)
  PASS  annual, Apr-1 hire before start = 0 (got 0, want 0)
  PASS  monthly, Apr-1 hire @ Dec31 = 1500 (9/12) (got 1500, want 1500)
  PASS  monthly, Apr-1 hire @ Jun30 = 500 (Apr,May,Jun) (got 500, want 500)
  PASS  annual, fiscal Apr-1 year @ start = 2000 (got 2000, want 2000)

== 9. carryover: capped at the boundary, floored at 0, disabled when cap=0 ==
  PASS  leftover 800, cap 500 -> 500 (capped) (got 500, want 500)
  PASS  leftover 300, cap 500 -> 300 (under cap) (got 300, want 300)
  PASS  leftover -100 -> 0 (floored) (got 0, want 0)
  PASS  leftover 400, cap 0 -> 0 (no carryover) (got 0, want 0)
  PASS  exactly at cap -> cap (got 500, want 500)

== 10. balanceAsOf: carry-in + accrued - taken - pending, walked across year boundaries ==
  PASS  A carry_in = 0 (got 0, want 0)
  PASS  A accrued = 2000 (got 2000, want 2000)
  PASS  A entitlement = 2000 (got 2000, want 2000)
  PASS  A taken = 500 (got 500, want 500)
  PASS  A pending = 200 (got 200, want 200)
  PASS  A available = 1300 (got 1300, want 1300)
  PASS  A available == entitlement - taken - pending
  PASS  B carry_in = 100 (got 100, want 100)
  PASS  B accrued = 2000 (got 2000, want 2000)
  PASS  B entitlement = 2100 (got 2100, want 2100)
  PASS  B available = 1400 (got 1400, want 1400)
  PASS  C carry_in stays capped at 500 (got 500, want 500)
  PASS  C entitlement = 2500 (got 2500, want 2500)
  PASS  C available = 2500 (got 2500, want 2500)
  PASS  D accrued = 800 (4/12 of 2400) (got 800, want 800)
  PASS  D available = 400 (800-300-100) (got 400, want 400)
  PASS  E current leave-year label = 2026 (got 2026, want 2026)
  PASS  E year_start = 2026-04-01 (got '2026-04-01', want '2026-04-01')
  PASS  E taken (2026 only) = 100 (got 100, want 100)

== 11. Leave::sumCentidays: totals in PHP, never SQL (with overflow clamp) ==
  PASS  sum = 700 (got 700, want 700)
  PASS  empty = 0 (got 0, want 0)
  PASS  overflow clamps to 2e9 (got 2000000000, want 2000000000)
  PASS  clamp_centidays floors at 0 (got 0, want 0)

== 12. Leave::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  -1 kept numeric (got '-1', want '-1')
  PASS  0.5 kept numeric (got '0.5', want '0.5')
  PASS  12 kept numeric (got '12', want '12')
  PASS  plain text untouched (got 'Ava Chen', want 'Ava Chen')
  PASS  mid-string = untouched (got 'a=b', want 'a=b')
  PASS  comma/quote RFC-4180 quoted (got '"Reid, ""urgent"""', want '"Reid, ""urgent"""')

== 13. Leave::toCsv: header + rows, RFC-4180, CRLF ==
  PASS  header row (got 'Employee,Days,Status', want 'Employee,Days,Status')
  PASS  row count (header+2) (got 3, want 3)
  PASS  CRLF line endings
  PASS  comma field quoted
  PASS  negative number stays numeric in CSV

== 14. 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

== 15. 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: no_rival_login_table
  PASS  v2: upgrade_from_1x
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events_are_leavoras
  PASS  v2: rate
  PASS  v2: csv_dry
  PASS  v2: backup_tables
  PASS  v2: working_days_skip_weekend_and_holiday
  PASS  v2: centidays_keep_half_days_exact
  PASS  v2: booking_uses_shared_rules
  PASS  v2: weekend_only_refused
  PASS  v2: booking_overlap_refused
  PASS  v2: pending_pair_can_both_await
  PASS  v2: second_approval_refused
  PASS  v2: rejection_never_blocked
  PASS  v2: no_double_decision
  PASS  v2: serializer_matches_core
  PASS  v2: balances_match_core
  PASS  v2: index.php calls Database::migrate() at boot — the ONLY upgrade path for an existing install
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: API reuses the tested core (booking rules, the decision guard, the balance maths)
  PASS  v2: each rule is defined ONCE, in the controller both callers share
  PASS  v2: refusal sentences are PLAIN TEXT (the view escapes; e() here double-escapes O'Brien)
  PASS  v2: the API never re-derives a day count — Leave:: is the only arithmetic
  PASS  v3: every leave transition fires its webhook through the one helper
  PASS  v3: no door writes a decision without the shared writer
  PASS  v2: the import does NOT invent a per-employee allowance (Leavora has no such column)
  PASS  v2: no ternary key re-reads (PLAN item 10)
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: dark ink flip spares ghost/danger buttons; native controls follow the theme
  PASS  v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
  PASS  v2: API.md ships + sw cache is v4.1.3 (App::VERSION)
  PASS  v2: cli-server base guard present (PLAN item 9)
  PASS  v2: /backup.sqlite is carved out of the blanket .sqlite block in BOTH dev router and .htaccess

== Own It 3.0 ==
  PASS  v3: feed_ui_is_rendered
  PASS  v3: feed_url_is_shown_once
  PASS  v3: feed_token_roundtrips
  PASS  v3: feed_revoke_takes_it_out_of_the_live_set
  PASS  v3: feed_body_is_a_valid_calendar
  PASS  v3: selfservice_employee_role_exists
  PASS  v3: selfservice_employee_cannot_decide
  PASS  v3: selfservice_employee_cannot_read_team
  PASS  v3: selfservice_employee_can_act_on_self
  PASS  v3: selfservice_unknown_role_still_closed
  PASS  v3: selfservice_guard_allows_own_pages
  PASS  v3: selfservice_guard_blocks_admin_pages
  PASS  v3: selfservice_invite_is_bound_to_the_employee
  PASS  v3: selfservice_account_resolves_to_its_own_employee
  PASS  v3: selfservice_staff_account_has_no_employee_scope
  PASS  v3: selfservice_email_fallback_resolves
  PASS  v3: selfservice_request_uses_the_shared_engine
  PASS  v3: selfservice_request_hits_the_same_overlap_guard
  PASS  v3: selfservice_employee_id_comes_from_the_session
  PASS  v3: selfservice_decisions_are_still_admin_only
  PASS  v3: selfservice_reissue_kills_the_previous_link
  PASS  v3: selfservice_invite_is_admin_gated_and_supersedes
  PASS  v3: selfservice_invite_refuses_the_impossible
  PASS  v3: counterparty_mail_is_off_by_default
  PASS  v3: counterparty_mail_needs_an_address
  PASS  v3: counterparty_mail_survives_no_smtp
  PASS  v3: counterparty_mail_is_gated_and_swallows
  PASS  v3: counterparty_mail_says_only_what_they_know
  PASS  v3: counterparty_mail_fires_from_the_shared_writer
  PASS  v3: v3_tables
  PASS  v3: users_role_column
  PASS  v3: migrate_restores_v3_tables
  PASS  v3: role_member_cannot_decide
  PASS  v3: decide_json_route_and_delegation
  PASS  v3: decide_json_refusals_mapped
  PASS  v3: decide_json_admin_gated_before_csrf
  PASS  v3: role_viewer_read_only
  PASS  v3: unknown_role_fails_closed
  PASS  v3: request_validator_accepts_good
  PASS  v3: pending_can_be_decided
  PASS  v3: decided_request_refuses_again
  PASS  v3: decision_is_audited
  PASS  v3: overlap_refused
  PASS  v3: balances_csv_matches_the_balance_engine
  PASS  v3: balances_csv_is_active_staff_only_and_formula_safe
  PASS  v3: seed_holidays_are_computed_for_the_year
  PASS  v3: seed_has_no_zero_day_request_and_no_us_holidays
  PASS  v3: available_can_go_negative
  PASS  v3: available_zero_is_not_negative
  PASS  v3: carryover_of_deficit_is_zero
  PASS  v3: validator_does_not_check_balance
  PASS  v3: mcp_submit_adds_no_balance_rule
  PASS  v3: mcp_spec_uses_core_keys
  PASS  v3: mcp_six_tools
  PASS  v3: mcp_descriptions_state_writes
  PASS  v3: mcp_decide_is_admin_gated
  PASS  v3: mcp_decide_enum_enforced
  PASS  v3: argguard_refuses_nested
  PASS  v3: argguard_refuses_unknown
  PASS  v3: mcp_reuses_form_validator
  PASS  v3: mcp_reuses_decide_guard
  PASS  v3: mcp_does_no_arithmetic
  PASS  v3: audit_append_only
  PASS  v3: pdf_engine_renders
  PASS  v3: pdf_uses_product_engine
  PASS  v3: pdf_does_no_arithmetic
  PASS  v3: team_pdf_omits_attachments
  PASS  v3: feed_scope_is_per_token
  PASS  v3: feed_uses_shared_query
  PASS  v3: feed_omits_reason
  PASS  v3: gdpr_blocks_while_pending
  PASS  v3: gdpr_allows_when_settled
  PASS  v3: gdpr_keeps_leave_history
  PASS  v3: gdpr_clears_identity
  PASS  v3: attachment_visibility_is_restricted
  PASS  v3: attachment_audit_omits_filename
  PASS  v3: bulk_reuses_decide_guard
  PASS  v3: bulk_is_transactional
  PASS  v3: branding_uses_plain_filename
  PASS  v3: restore_reads_parse_contract
  PASS  v3: restore_requires_preview_token
  PASS  v3: sso_unconfigured_is_null
  PASS  v3: sso_failures_return_to_local_login
  PASS  v3: sso_does_not_autocreate
  PASS  v3: invite_expiry_enforced
  PASS  v3: last_admin_guard_wired
  PASS  v3: healthz_before_install_guard
  PASS  v3: deploy_kit_filled
  PASS  v3: all_v3_views_present
  PASS  v3: db_files_cleaned
  PASS  v3: BACKUP: both downloads are administrators-only, and only administrators are shown them
  PASS  v3: MCP: the probe, the tool listing and the REST API all name the server "leavora"
  PASS  v3: migrate() runs at boot after the schemaExists guard
  PASS  v3: every version surface reports the same version (4.1.3 = App::VERSION)
  PASS  v3: /mcp route registered
  PASS  v3: all 9 shared cores present in src/
  PASS  v3: legacy PDF writer moved aside, core engine present
  PASS  v3: cores byte-identical to the shared core
  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: audit view reads the core's `at` column, never created_at
  PASS  v3: demo seeder settings UPDATE carries a closed weekend_days literal
  PASS  v3: every api-settings key/webhook action is admin-gated (_lv_require('*')), not merely logged in
  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  demo ribbon: every path renders the card with the admin wording (no public page)
  PASS  demo ribbon: below 600px it starts as the small pill; a tap opens it and the choice holds for the session
  PASS  demo ribbon: a response with no </body> (CSV, JSON, PDF) passes through untouched
  PASS  forms: every POST form on a screen reaches a registered route (59 checked)
  PASS  backups: every non-admin role (viewer, member, employee) gets 403 on every backup, restore and backup-token route
  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 the JSON backup leaves out webhook URLs too (ruling 1, 2026-09-25)
  PASS  security page: the backup card says every credential is redacted, not only the SMTP password
  PASS  leave rules: easter_computus
  PASS  leave rules: england_2026_matches_gov_uk
  PASS  leave rules: sunday_christmas_substitutes_on_tuesday
  PASS  leave rules: scotland_2023_second_january_and_substitute
  PASS  leave rules: northern_ireland_boyne_substitute
  PASS  leave rules: us_federal_observed_dates
  PASS  leave rules: ireland_st_brigid_rule
  PASS  leave rules: germany_and_france_counts
  PASS  leave rules: unknown_pack_is_empty
  PASS  leave rules: holiday_index_is_per_location
  PASS  leave rules: pack_loads_once_and_per_location
  PASS  leave rules: location_holidays_count_only_there
  PASS  leave rules: cap_refuses_naming_the_day
  PASS  leave rules: cap_counts_working_days_only
  PASS  leave rules: cap_does_not_stop_sickness
  PASS  leave rules: no_cap_no_refusal
  PASS  leave rules: cap_allows_a_rejection
  PASS  leave rules: raising_the_cap_frees_the_approval
  PASS  leave rules: approved_sickness_counts_towards_the_cap
  PASS  leave rules: blackout_refuses_booking
  PASS  leave rules: blackout_does_not_stop_sickness
  PASS  leave rules: department_blackout_is_that_department_only
  PASS  leave rules: blackout_refuses_a_later_approval
  PASS  leave rules: sick_types_marked_on_upgrade
  PASS  leave extras: toil_credits_are_the_accrual
  PASS  leave extras: toil_accrues_nothing_by_calendar
  PASS  leave extras: other_types_unchanged
  PASS  leave extras: credits_reach_the_balance
  PASS  leave extras: chat_hook_gets_one_line_without_the_reason
  PASS  leave extras: signed_hook_still_gets_the_document
  PASS  leave extras: off_list_names_and_type_only
  PASS  leave extras: off_today_sends_once_a_day
  PASS  leave extras: off_today_is_subscribable
  PASS  leave extras: routes_are_gated
  PASS  leave extras: runner_is_cli_only
  PASS  3.3 leave change: a_booked_change_is_checked_like_approval
  PASS  3.3 leave change: same_dates_are_not_a_change
  PASS  3.3 leave change: a_change_is_not_a_clash_with_itself
  PASS  3.3 leave change: a_rejected_request_cannot_be_changed
  PASS  3.3 leave change: manager_change_keeps_it_approved
  PASS  3.3 leave change: the_change_is_audited_from_and_to
  PASS  3.3 leave change: a_refused_change_leaves_it_alone
  PASS  3.3 leave change: pending_is_changed_in_place_still_pending
  PASS  3.3 leave change: approved_change_is_asked_not_applied
  PASS  3.3 leave change: the_manager_sees_the_ask
  PASS  3.3 leave change: my_leave_shows_the_ask_and_the_form
  PASS  3.3 leave change: staff_cannot_touch_someone_elses
  PASS  3.3 leave change: staff_cannot_decide_their_own_change
  PASS  3.3 leave change: leave_already_over_is_not_changed_by_staff
  PASS  3.3 leave change: approving_the_change_applies_it
  PASS  3.3 leave change: the_person_is_told_was_and_now
  PASS  3.3 leave change: declining_keeps_the_booked_dates
  PASS  3.3 leave change: withdrawing_clears_the_ask
  PASS  3.3 leave change: changed_is_a_webhook_event
  PASS  3.3 leave change: request_form_shows_booked_holidays_and_blackouts
  PASS  3.3 leave change: request_form_hands_the_same_facts_to_the_script
  PASS  3.3 date style: a_new_install_starts_in_the_uk_style
  PASS  3.3 date style: the_old_schema_had_no_style
  PASS  3.3 date style: an_upgraded_install_keeps_the_us_style
  PASS  3.3 date style: settings_saves_it_and_refuses_anything_else
  PASS  3.3 date style: a_save_without_the_field_leaves_it
  PASS  3.3 date style: the_settings_page_offers_both
  PASS  3.3 date style: short_dates_were_already_day_first
  PASS  leave extras: the web server refuses the cron/ folder like bin/
  PASS  api roles: POST /api/requests refuses every role without the permission (403) and admits the rest
  PASS  api roles: POST /api/requests/{id}/approve refuses every role without the permission (403) and admits the rest
  PASS  api roles: POST /api/requests/{id}/reject refuses every role without the permission (403) and admits the rest
  PASS  api roles: GET /api/requests (a staff login key is refused) refuses every role without the permission (403) and admits the rest
  PASS  api roles: POST /mcp (a staff login key is refused) refuses every role without the permission (403) and admits the rest

————————————————————————————————————————————————————
  PASS  3.3 import dry run: the_upload_reaches_the_dry_run
  PASS  3.3 import dry run: the_dry_run_writes_nothing
  PASS  3.3 import dry run: the_dry_run_counts_only_what_will_import
  PASS  3.3 import dry run: the_dry_run_names_why
  PASS  3.3 import dry run: the_import_brings_in_exactly_what_the_dry_run_said

== 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 Leavora 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 Leavora 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 Leavora, 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 7 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 (24 tables dumped)
  PASS  erase planting · sqlite: the file they attached to a request (a sick note) is deleted from disk
  PASS  erase planting · sqlite: the erase page (/employees/1) says what an erase keeps: "Kept: their own sign-in account, if they had one, with its n…"
  PASS  erase planting · sqlite: MANUAL.md says the same: "Kept: their own sign-in account, if they had one, with its n…"
ALL 480 TESTS PASSED (0 failed)

← Back to Leavora · Manual · Quickstart · API