Leavora · Test run · run 2026-09-25
Leavora Test run, as shipped in the download
334 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-25 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 neutralised (got '\'=SUM(A1)', want '\'=SUM(A1)')
PASS +1 neutralised (got '\'+1', want '\'+1')
PASS -SUM neutralised (got '\'-SUM(A1)', want '\'-SUM(A1)')
PASS @cmd neutralised (got '\'@cmd', want '\'@cmd')
PASS leading TAB neutralised (got '\' x', want '\' x')
PASS cmd payload neutralised (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 v3.1.5 (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 (3.1.5 = 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 licence: every font ships the SIL OFL text for its own family beside it
PASS licence: 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 (39 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 (10)
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
————————————————————————————————————————————————————
ALL 334 TESTS PASSED (0 failed)
← Back to Leavora · Manual · Quickstart · API