Ownware
Home›Clockora›Test run
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)

← Back to Clockora · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →