Clockora · Test run · run 2026-11-07
Clockora Test run, as shipped in the download
495 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) + 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 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 -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 v4.1.3
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 keeps exact minutes; decimal hours only beside them, by integer arithmetic
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 4.1.3 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 — its backup run 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 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 (67 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 (14)
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 api roles: POST /api/entries refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/entries/{id}/submit refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/entries/{id}/approve refuses every role without the permission (403) and admits the rest
PASS api roles: POST /api/entries/{id}/reject refuses every role without the permission (403) and admits the rest
PASS api roles: GET /api/entries (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 clock extras: break_comes_off_the_worked_minutes
PASS clock extras: break_as_long_as_the_shift_refused
PASS clock extras: manual_duration_is_already_net
PASS clock extras: break_is_stored_and_in_the_api
PASS clock extras: timer_takes_its_breaks_off
PASS clock extras: break_toggle_starts_and_ends
PASS clock extras: hours_2dp_exact_rounding
PASS clock extras: person_layout_unchanged
PASS clock extras: day_layout_sums_each_day
PASS clock extras: decimal_layout_adds_beside_minutes
PASS clock extras: xlsx_is_a_valid_package
PASS clock extras: xlsx_text_is_never_a_formula
PASS clock extras: excel_routes_are_read_gated
PASS 3.3 approvals: one_card_per_person_and_week
PASS 3.3 approvals: card_holds_submitted_only
PASS 3.3 approvals: card_totals_and_overtime
PASS 3.3 approvals: page_shows_three_cards
PASS 3.3 approvals: card_says_what_it_decides
PASS 3.3 approvals: card_flags_overtime
PASS 3.3 approvals: cards_are_not_a_table
PASS 3.3 approvals: nav_has_approvals_with_the_count
PASS 3.3 approvals: viewer_and_member_are_refused
PASS 3.3 approvals: approve_decides_the_cards_entries
PASS 3.3 approvals: approve_leaves_other_people_weeks_and_late_entries
PASS 3.3 approvals: approver_is_named
PASS 3.3 approvals: reject_sends_it_back
PASS 3.3 approvals: locked_period_holds
PASS 3.3 approvals: dashboard_links_to_approvals
PASS 3.3 approvals: csv_has_decimal_hours_last
PASS 3.3 approvals: csv_decimal_hours_are_right
PASS my time: employee_role_is_self_only
PASS my time: team_roles_exclude_employee
PASS my time: staff_login_resolves_its_employee
PASS my time: portal_paths_contained
PASS my time: pin_is_six_digits_stored_as_hmac
PASS my time: pin_finds_its_owner
PASS my time: pins_are_backup_redacted
PASS my time: device_token_resolves
PASS my time: staff_get_elsewhere_lands_on_me
PASS my time: staff_post_elsewhere_refused
PASS my time: staff_sees_own_page
PASS my time: team_account_on_me_goes_to_team_app
PASS my time: staff_clock_in_starts_own_timer
PASS my time: staff_clock_out_writes_a_draft
PASS my time: staff_entry_is_always_their_own
PASS my time: staff_cannot_delete_a_colleagues_entry
PASS my time: staff_submit_week_submits_own_only
PASS my time: staff_writes_still_need_csrf
PASS my time: kiosk_page_opens_with_a_live_token
PASS my time: kiosk_refuses_a_wrong_token
PASS my time: kiosk_pin_clocks_in
PASS my time: kiosk_twice_in_is_refused
PASS my time: kiosk_pin_clocks_out_into_a_draft
PASS my time: kiosk_wrong_pins_pause_the_tablet
PASS my time: kiosk_answers_carry_no_referrer
PASS my time: every_door_uses_the_one_clock_writer
PASS my time: redacted_key_is_replaced_and_pins_cleared
PASS my time: team_page_excludes_staff_logins
PASS my time: invite_binds_the_employee
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 Clockora 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 Clockora 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 Clockora, 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 (22 tables dumped)
ALL 495 TESTS PASSED (0 failed)