Clockora · Test run · run 2026-09-25
Clockora Test run, as shipped in the download
356 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) + day_number, 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-11 is Sunday(0) (got 0, want 0)
PASS day_number is exact-day apart (got 1, want 1)
PASS day_number full week apart (got 7, want 7)
== 1. parseClock / fmtClock: time-of-day as INTEGER minutes-since-midnight (0..1439) ==
PASS "9:00" -> 540 (got 540, want 540)
PASS "09:00" -> 540 (got 540, want 540)
PASS "17:30" -> 1050 (got 1050, want 1050)
PASS "00:00" -> 0 (got 0, want 0)
PASS "23:59" -> 1439 (got 1439, want 1439)
PASS "24:00" -> null (hour>23) (got NULL, want NULL)
PASS "9:60" -> null (min>59) (got NULL, want NULL)
PASS "7" -> null (no colon) (got NULL, want NULL)
PASS "abc" -> null (got NULL, want NULL)
PASS "" -> null (got NULL, want NULL)
PASS 540 -> "09:00" (got '09:00', want '09:00')
PASS 1050 -> "17:30" (got '17:30', want '17:30')
PASS 0 -> "00:00" (got '00:00', want '00:00')
PASS 1439 -> "23:59" (got '23:59', want '23:59')
PASS clock round-trip 07:05 (got '07:05', want '07:05')
== 2. parseDuration / fmtDuration / fmtHMM: manual duration, exact integer minutes ==
PASS "7:30" -> 450 (got 450, want 450)
PASS "8:00" -> 480 (got 480, want 480)
PASS "0:45" -> 45 (got 45, want 45)
PASS "90" -> 90 (plain minutes) (got 90, want 90)
PASS "450" -> 450 (got 450, want 450)
PASS "1:60" -> null (min>59) (got NULL, want NULL)
PASS "-5" -> null (got NULL, want NULL)
PASS "" -> null (got NULL, want NULL)
PASS "abc" -> null (got NULL, want NULL)
PASS 480 -> "8h" (got '8h', want '8h')
PASS 510 -> "8h 30m" (got '8h 30m', want '8h 30m')
PASS 45 -> "45m" (got '45m', want '45m')
PASS 90 -> "1h 30m" (got '1h 30m', want '1h 30m')
PASS 0 -> "0h" (got '0h', want '0h')
PASS 450 -> "7:30" (HMM) (got '7:30', want '7:30')
PASS 480 -> "8:00" (HMM) (got '8:00', want '8:00')
PASS 45 -> "0:45" (HMM) (got '0:45', want '0:45')
PASS 605 -> "10:05" (HMM) (got '10:05', want '10:05')
== 3. spanMinutes / crossesMidnight: same-day, CROSS-MIDNIGHT, and same-instant ==
PASS 09:00->17:00 = 480 (got 480, want 480)
PASS 22:00->06:00 = 480 (crosses) (got 480, want 480)
PASS 00:00->08:00 = 480 (got 480, want 480)
PASS 23:00->01:00 = 120 (crosses) (got 120, want 120)
PASS same instant = 0 (got 0, want 0)
PASS 23:45->00:15 = 30 (crosses) (got 30, want 30)
PASS crossesMidnight 22:00->06:00 true
PASS crossesMidnight 09:00->17:00 false
PASS crossesMidnight equal is false
== 4. absInterval + intervalsOverlap: half-open (back-to-back does NOT overlap) ==
PASS overlapping intervals
PASS back-to-back do NOT overlap
PASS disjoint do NOT overlap
PASS contained overlaps
PASS absInterval length = 480 (got 480, want 480)
PASS cross-midnight abs length = 480 (got 480, want 480)
PASS cross-midnight abs end lands in next day
== 5. findEntryClash: same-day, back-to-back, cross-midnight spillover, rejected, manual ==
PASS 11:00-13:00 clashes 09:00-12:00
PASS 12:00-13:00 back-to-back = no clash
PASS 05:00-07:00 clashes prev-day cross-midnight (spillover)
PASS 06:00-08:00 back-to-back with spillover end = no clash
PASS 15:15-15:45 hits only REJECTED = no clash
PASS exact 09:00-12:00 with excludeId=1 = no clash
PASS exact 09:00-12:00 without exclude = clash
PASS manual candidate never clashes
PASS isClock true for clock row
PASS isClock false for manual row
== 6. date engine: add_days / add_months / days_inclusive / valid_ymd (UTC-anchored) ==
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 month roll (got '2026-02-01', want '2026-02-01')
PASS add_months +1 (got '2026-02-15', want '2026-02-15')
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)
== 7. DST safety (America/New_York 2026): date math + cross-midnight are exact ==
PASS day_number across spring-forward = 1 (got 1, want 1)
PASS days_inclusive Sat..Mon over spring = 3 (got 3, want 3)
PASS day_number across fall-back = 1 (got 1, want 1)
PASS cross-midnight over DST = 480 wall-min (got 480, want 480)
PASS DST cross-midnight overlaps next-morning entry
== 8. weekStart / weekEnd: Monday-start and Sunday-start ==
PASS weekStart Wed->Mon (Mon week) (got '2026-01-05', want '2026-01-05')
PASS weekStart Sun->Mon (Mon week) (got '2026-01-05', want '2026-01-05')
PASS weekStart already Monday (got '2026-01-05', want '2026-01-05')
PASS weekEnd (Mon week) = Sunday (got '2026-01-11', want '2026-01-11')
PASS weekStart Wed->Sun (Sun week) (got '2026-01-04', want '2026-01-04')
PASS weekEnd (Sun week) = Saturday (got '2026-01-10', want '2026-01-10')
== 9. period totals: sumMinutes / totalsByKey in PHP (never SQL), with overflow clamp ==
PASS sumMinutes = 945 (got 945, want 945)
PASS sumMinutes empty = 0 (got 0, want 0)
PASS emp 1 total = 600 (got 600, want 600)
PASS emp 2 total = 345 (got 345, want 345)
PASS project 10 total = 780 (got 780, want 780)
PASS project 11 total = 165 (got 165, want 165)
PASS per-employee totals reconcile to grand
PASS per-project totals reconcile to grand
PASS overflow clamps to 2e9 (got 2000000000, want 2000000000)
PASS clamp_minutes floors at 0 (got 0, want 0)
== 10. bucketByWeek: entries grouped into weeks (Monday-start) ==
PASS week A (01-05) total = 900 (got 900, want 900)
PASS week B (01-12) total = 240 (got 240, want 240)
PASS two week buckets (got 2, want 2)
== 11. overtime FLAG (never pay): threshold, strict >, minutes over, week count ==
PASS own target overrides default (got 2250, want 2250)
PASS zero target -> default (got 2400, want 2400)
PASS zero target + zero default -> 0 (disabled) (got 0, want 0)
PASS over threshold flagged
PASS exactly at threshold NOT flagged (strict >)
PASS under threshold not flagged
PASS threshold 0 disables the flag
PASS overtime minutes = 150 (got 150, want 150)
PASS overtime minutes under = 0 (got 0, want 0)
PASS overtime minutes disabled = 0 (got 0, want 0)
PASS overtime week count = 2 of 3 (got 2, want 2)
== 12. 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 -30 kept numeric (got '-30', want '-30')
PASS 480 kept numeric (got '480', want '480')
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. toCsv: header + rows, RFC-4180, CRLF ==
PASS header row (got 'Employee,Minutes,Status', want 'Employee,Minutes,Status')
PASS row count (header+2) (got 3, want 3)
PASS CRLF line endings
PASS comma field quoted
PASS negative minutes stays numeric
== 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
== 16. scope guard: hours only — NO money/rate/invoice surface in the engine ==
PASS MINUTES_PER_DAY = 1440 (got 1440, want 1440)
PASS MAX_ENTRY_MINUTES = 1440 (24h cap) (got 1440, want 1440)
PASS MAX_DAY_MINUTES = 1440 (got 1440, want 1440)
PASS 480 existing + 480 new fits
PASS 960 existing + 480 new fits exactly
PASS 961 existing + 480 new overflows
PASS day-ceiling case: 480 logged + 8h×9 refused
PASS empty day accepts a full 24h entry
PASS entry_create enforces the day ceiling for ALL methods (source guard)
PASS Timesheet has no ::rate() method (money-free)
PASS Timesheet has no ::amount() method (money-free)
PASS Timesheet has no ::price() method (money-free)
PASS Timesheet has no ::cost() method (money-free)
PASS Timesheet has no ::invoice() method (money-free)
PASS Timesheet has no ::money() method (money-free)
PASS Timesheet has no ::currency() method (money-free)
PASS Timesheet has no ::pay() method (money-free)
PASS Timesheet has no ::bill() method (money-free)
== 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: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: events_are_clockoras
PASS v2: rate
PASS v2: csv_dry
PASS v2: backup_tables
PASS v2: upgrade_from_1x
PASS v2: serializer_matches_core
PASS v2: day_ceiling
PASS v2: overlap_guard
PASS v2: midnight_span
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 — entry rules and the timesheet aggregation, never re-derived
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: no ternary key re-reads (PLAN item 10)
PASS v2: login_attempts is NOT declared in the schema (LoginRate::ensure owns it, columns ip/at)
PASS v2: webhook_deliveries uses the column Webhook::fire actually inserts
PASS v3: exactly ONE writer performs the submit transition (no door has its own SQL)
PASS v3: exactly ONE writer performs the approve/reject transition
PASS v3: the API calls the shared writers rather than duplicating them
PASS v2: notifications still reach the single AND the bulk submit path
PASS v3: bulk submit sends ONE digest, not one mail per entry (2.0 batched deliberately)
PASS v3: every entry transition fires its webhook through the one helper, from the writers only
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2: dark ink flip spares ghost/danger buttons (they sit on --card, not the accent)
PASS v2: theme.js + qr.js ship and layout loads theme.js + has a toggle
PASS v3: API.md ships + sw cache is v3.1.5
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
PASS v2: the API serializer is Clockora's, not a copied one from another product
-- Own It 3.0 --
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_restores_v3_tables
PASS v3: ck_counterparty_off_by_default
PASS v3: ck_counterparty_needs_an_address
PASS v3: ck_counterparty_survives_no_smtp
PASS v3: ck_counterparty_is_gated_and_swallows
PASS v3: ck_counterparty_fires_from_the_shared_writer
PASS v3: ck_mail_carries_no_money
PASS v3: ck_reminder_picks_unsubmitted_time
PASS v3: ck_reminder_ignores_submitted_time
PASS v3: ck_reminder_skips_archived_people
PASS v3: ck_reminder_releases_the_slot_when_mail_fails
PASS v3: ck_reminder_is_once_per_person_per_week
PASS v3: ck_reminder_ledger_is_unique_per_week
PASS v3: ck_reminder_other_weeks_are_untouched
PASS v3: ck_absolute_url_is_built_once
PASS v3: ck_absolute_url_does_not_double_the_base
PASS v3: ck_cron_is_off_unless_enabled
PASS v3: ck_cron_reuses_the_shared_rule
PASS v3: ck_cron_reminds_about_the_week_that_ended
PASS v3: timer_table_exists
PASS v3: timer_converts_elapsed_to_clock_fields
PASS v3: timer_cross_midnight_wraps_end_not_date
PASS v3: timer_under_a_minute_records_nothing
PASS v3: timer_elapsed_never_negative
PASS v3: timer_start_refuses_unknown_employee
PASS v3: timer_start_allowed_for_active_employee
PASS v3: timer_start_refuses_a_second_timer
PASS v3: timer_lookup_finds_the_open_timer
PASS v3: timer_discard_leaves_no_row
PASS v3: timer_stop_obeys_the_overlap_guard
PASS v3: timer_stop_obeys_the_period_lock
PASS v3: timer_stop_writes_an_ordinary_draft_entry
PASS v3: timer_stop_delegates_never_reimplements
PASS v3: timer_stop_keeps_the_clock_running_on_refusal
PASS v3: timer_writes_are_role_gated
PASS v3: lock_covers_its_own_end_date
PASS v3: lock_covers_its_own_start_date
PASS v3: lock_leaves_the_next_day_open
PASS v3: ui_submit_refused_in_locked_period
PASS v3: ui_submit_allowed_outside_lock
PASS v3: ui_decide_refused_in_locked_period
PASS v3: ui_decide_allowed_outside_lock
PASS v3: ui_delete_refused_in_locked_period
PASS v3: create_refused_into_locked_period
PASS v3: create_allowed_outside_lock
PASS v3: decide_once_refuses_second_decision
PASS v3: decide_once_recorded_approver
PASS v3: mcp_submit_refused_in_locked_period
PASS v3: mcp_decide_refused_in_locked_period
PASS v3: mcp_refusal_is_the_apps_own_sentence
PASS v3: per_entry_refusal_names_the_locked_period
PASS v3: mcp_read_still_works_when_locked
PASS v3: mcp_totals_match_the_products_engine
PASS v3: mcp_viewer_cannot_write
PASS v3: mcp_viewer_can_still_read
PASS v3: mcp_unknown_arg_refused_by_name
PASS v3: mcp_get_is_405
PASS v3: payroll_basis_is_approved_only
PASS v3: overtime_comes_from_the_shared_engine
PASS v3: overtime_threshold_is_the_employees_own
PASS v3: audit_records_decisions
PASS v3: audit_detail_carries_no_secret
PASS v3: gdpr_export_is_complete
PASS v3: gdpr_blanks_identity
PASS v3: gdpr_keeps_the_hours
PASS v3: gdpr_keeps_the_entry_rows
PASS v3: unknown_role_gets_the_most_restrictive
PASS v3: empty_role_is_treated_as_legacy_admin
PASS v3: viewer_cannot_lock_a_period
PASS v3: admin_can_lock_a_period
PASS v3: last_admin_cannot_be_demoted
PASS v3: pdf_renders
PASS v3: update_on_a_missing_settings_row_silently_does_nothing
PASS v3: migrate_ensures_the_settings_singleton
PASS v3: a_settings_save_lands_once_the_row_is_ensured
PASS v3: brand_css_brands_the_light_theme
PASS v3: brand_css_brands_the_dark_theme_too
PASS v3: brand_css_repoints_the_hardcoded_ink
PASS v3: brand_dark_accent_is_legible_on_the_dark_canvas
PASS v3: brand_ink_on_the_accent_clears_AA
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: on_demand_and_scheduled_share_one_redaction_list
PASS v3: scheduled_backup_prunes_to_the_retention_count
PASS v3: seed_nothing_dated_after_today_and_last_week_is_real
PASS v3: seed_has_one_person_on_the_clock
PASS v3: seed_reset_clears_timers
PASS v3: db_files_cleaned
PASS v3: BACKUP: both downloads are administrators-only, and only administrators are shown them
PASS v3: SETTINGS: the settings page (mail server, user name, notification address) is administrators-only, like its save
PASS v3: MCP: the probe, the tool listing and the REST API all name the server "clockora"
PASS v3: door_ui_refuses
PASS v3: door_api_refuses
PASS v3: door_mcp_refuses
PASS v3: door_api_allows_outside_lock
PASS v3: all_three_quote_the_same_rule
PASS v3: csrf_still_required
PASS v3: no_php_warnings_on_any_door
PASS v3: backup_refuses_viewer_and_member
PASS v3: backup_serves_the_admin
PASS v3: settings_page_refuses_viewer
PASS v3: WEBHOOKS: the events offered are exactly the events fired (entry.approved, entry.created, entry.rejected, entry.submitted)
PASS v3: WEBHOOKS: entry.created fires from the one writer every door uses (form, REST API, stopped timer)
PASS v3: the setup checklist and the reminder speak to what Clockora is (no billing; staff may not have a login)
PASS v3: the period-lock rule has exactly ONE definition
PASS v3: MCP writes go through the app's own refusal functions, not its own copy
PASS v3: overtime is never recomputed — the PDF, CSV and MCP all read _ck_sheet_data()
PASS v3: the payroll CSV exports exact minutes, never a rounded decimal-hours float
PASS v3: roles go through RolesKit (unknown role must not grant rights)
PASS v3: 3.0 routes registered (mcp, periods, payroll, audit, privacy, team, restore, sso)
PASS v3: healthz answers BEFORE the install redirect (the deploy-kit healthcheck)
PASS v3: version is 3.1.5 everywhere the APP declares it
PASS v3: the nine cores are present and byte-identical to the reference copy (or skipped when unavailable)
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: saved views are RENDERED, not just writable
PASS v3: the entries list offers bulk actions
PASS v3: bulk owns NO write SQL of its own — it loops the single-item guards
PASS v3: the delete WRITE is shared by the button and the bulk path (one definition)
PASS v3: GET /mcp answers the transport hint BEFORE the key check
PASS v3: the scheduled backup is reachable — writeScheduled() has a registered route
PASS v3: no view posts to a route that does not exist
PASS v3: the settings form reads the settings it was given (no undefined view variable)
PASS v3: every single-item UI mutation is role-gated with _ck_require, not bare requireLogin
PASS v3: every write route in index.php reaches a role gate (router-derived, not hand-listed)
PASS v3: every api-settings key/webhook action is admin-gated, not merely logged in
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 (43 checked)
PASS backups: every non-admin role (viewer, member) gets 403 on every backup, restore and backup-token route
PASS backup JSON: an administrator gets a backup, with every credential column planted (9)
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 356 TESTS PASSED (0 failed)