Fixora · Test run · run 2026-11-07
Fixora Test run, as shipped in the download
690 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.
[DB: SQLite]
== 1. nextDue: last_done + interval (hand-computed, leap-year + year boundary) ==
PASS 30d from 2026-06-01 = 2026-07-01 (got '2026-07-01', want '2026-07-01')
PASS 7d from 2026-06-28 crosses month = 2026-07-05 (got '2026-07-05', want '2026-07-05')
PASS 10d from 2026-12-25 crosses year = 2027-01-04 (got '2027-01-04', want '2027-01-04')
PASS LEAP: 14d from 2028-02-15 = 2028-02-29 (got '2028-02-29', want '2028-02-29')
PASS NON-leap: 14d from 2027-02-15 = 2027-03-01 (got '2027-03-01', want '2027-03-01')
PASS 30d from 2026-01-31 = 2026-03-02 (Feb=28) (got '2026-03-02', want '2026-03-02')
PASS 365d from 2028-02-29 = 2029-02-28 (got '2029-02-28', want '2029-02-28')
PASS 1d interval = next day (got '2026-07-05', want '2026-07-05')
PASS normalizes sloppy 2026-2-3 input (got '2026-02-13', want '2026-02-13')
PASS invalid last_done → null (no crash)
PASS empty last_done → null
== 2. dueInDays + due-soon boundary at exactly the window ==
PASS due in 5 days (got 5, want 5)
PASS due today = 0 (got 0, want 0)
PASS 5 days overdue = -5 (got -5, want -5)
PASS across US DST start (pure dates) (got 14, want 14)
PASS across year boundary (got 7, want 7)
PASS long overdue -40 (got -40, want -40)
PASS -1 day → overdue (got 'overdue', want 'overdue')
PASS 0 days (due today) → due-soon (got 'due-soon', want 'due-soon')
PASS EXACTLY window (7) → due-soon (got 'due-soon', want 'due-soon')
PASS window+1 (8) → ok (got 'ok', want 'ok')
PASS window 1: 1 → due-soon (got 'due-soon', want 'due-soon')
PASS window 1: 2 → ok (got 'ok', want 'ok')
PASS -100 → overdue (got 'overdue', want 'overdue')
PASS tz edge: today(UTC+14)=2026-11-08 > today(UTC-11)=2026-11-07
PASS tz edge: same due date differs by ≥1 day across the date line (got true, want true)
PASS pmComputed next_due (got '2026-07-01', want '2026-07-01')
PASS pmComputed days_until (got -4, want -4)
PASS pmComputed status (got 'overdue', want 'overdue')
PASS pmComputed due-soon: due 2026-07-11 in 6d
== 3. work-order state machine: full transition matrix ==
PASS open → in-progress ALLOWED (got true, want true)
PASS open → done ALLOWED (got true, want true)
PASS open → cancelled ALLOWED (got true, want true)
PASS open → open rejected (got false, want false)
PASS in-progress → in-progress rejected (got false, want false)
PASS in-progress → done ALLOWED (got true, want true)
PASS in-progress → cancelled ALLOWED (got true, want true)
PASS in-progress → open ALLOWED (got true, want true)
PASS done → in-progress rejected (got false, want false)
PASS done → done rejected (got false, want false)
PASS done → cancelled rejected (got false, want false)
PASS done → open ALLOWED (got true, want true)
PASS cancelled → in-progress rejected (got false, want false)
PASS cancelled → done rejected (got false, want false)
PASS cancelled → cancelled rejected (got false, want false)
PASS cancelled → open rejected (got false, want false)
PASS unknown target status rejected
== 4. labor × rate + parts = exact integer cents ==
PASS 90 min @ $60.00/h = $90.00 (9000¢) (got 9000, want 9000)
PASS 50 min @ $55.00/h = 4583¢ (rounded) (got 4583, want 4583)
PASS 30 min @ $65.00/h = 3250¢ (got 3250, want 3250)
PASS 1 min @ $60.00/h = 100¢ (got 100, want 100)
PASS 0 min = 0¢ (got 0, want 0)
PASS rate 0 = 0¢ (got 0, want 0)
PASS negative minutes = 0¢ (got 0, want 0)
PASS 10 min @ 1¢/h rounds to 0¢ (got 0, want 0)
PASS WO total: 240min@$65/h + $1845.00 parts = 210500¢ (got 210500, want 210500)
PASS WO total: labor only (got 6000, want 6000)
PASS negative parts ignored (got 0, want 0)
== 5. aggregate: downtime, by-type/priority/status, month-over-month, canceled excluded ==
PASS count includes canceled (got 4, want 4)
PASS downtime excludes canceled (390) (got 390, want 390)
PASS labor cents 210min → 21000¢ (got 21000, want 21000)
PASS parts cents 7500¢ (got 7500, want 7500)
PASS total 28500¢ (got 28500, want 28500)
PASS by_type corrective=3 (got 3, want 3)
PASS by_type preventive=1 (got 1, want 1)
PASS by_priority urgent=1 (got 1, want 1)
PASS by_status canceled=1 (got 1, want 1)
PASS May total = 23000¢ (got 23000, want 23000)
PASS May downtime = 300 (got 300, want 300)
PASS Jun total = 5500¢ (no canceled) (got 5500, want 5500)
PASS Jun count = 1 (no canceled) (got 1, want 1)
PASS months newest-first
PASS monthKey: 2026-07-01 02:00 UTC → 2026-06 in America/New_York (got '2026-06', want '2026-06')
PASS monthKey: same instant is 2026-07 in UTC (got '2026-07', want '2026-07')
== 6. toCsv: RFC-4180 + numeric-aware formula guard ==
PASS header row from keys (got 'Tag,Name,Labor Minutes', want 'Tag,Name,Labor Minutes')
PASS quotes/commas escaped
PASS CRLF endings
PASS missing key → blank cell (got '', want '')
PASS = formula neutralized (got '\'=1+2', want '\'=1+2')
PASS + formula neutralized (got '\'+HYPERLINK("x")', want '\'+HYPERLINK("x")')
PASS @ formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
PASS leading TAB neutralized (got '\' Tabbed', want '\' Tabbed')
PASS -DDE neutralized (got '\'-DDE', want '\'-DDE')
PASS negative int NOT corrupted (got '-5', want '-5')
PASS negative decimal NOT corrupted (got '-12.50', want '-12.50')
PASS numeric +7 NOT corrupted (got '+7', want '+7')
PASS date untouched (got '2026-06-30', want '2026-06-30')
PASS plain value untouched (got 'plain', want 'plain')
== 7. CSRF empty-token guard + clip/clamp helpers ==
PASS EMPTY stored token never validates (hash_equals("","") trap)
PASS empty stored vs non-empty given rejected
PASS matching tokens validate
PASS mismatched tokens rejected
PASS clip trims to width (got 190, want 190)
PASS clip multibyte-safe (got 100, want 100)
PASS clip leaves short strings (got 'AST-0001', want 'AST-0001')
PASS clamp_int caps 15-digit value (got 2000000000, want 2000000000)
PASS clamp_int caps negative overflow (got -2000000000, want -2000000000)
PASS clamp_int non-numeric → 0 (got 0, want 0)
PASS clamp_int trims + parses (got 42, want 42)
PASS clamp_interval floor 1 (got 1, want 1)
PASS clamp_interval cap 3650 (got 3650, want 3650)
PASS clamp_window floor 1 (got 1, want 1)
PASS clamp_window cap 365 (got 365, want 365)
PASS clamp_minutes floors at 0 (got 0, want 0)
PASS money_to_cents "149.99" → 14999 (got 14999, want 14999)
PASS money_to_cents "$1,845.00" → 184500 (got 184500, want 184500)
PASS money_to_cents 15 digits clamped (got 2000000000, want 2000000000)
PASS money_to_cents negative floored to 0 (got 0, want 0)
PASS valid_date accepts leap 2028-02-29 (got '2028-02-29', want '2028-02-29')
PASS valid_date rejects 2026-02-29 (non-leap)
PASS valid_date rejects Feb 30
PASS valid_date rejects month 13
PASS valid_date normalizes 2026-2-3 (got '2026-02-03', want '2026-02-03')
PASS fmt_money integer cents (got '$1,234.56', want '$1,234.56')
PASS fmt_money sub-dollar (got '$0.05', want '$0.05')
PASS fmt_money negative (got '-$2.50', want '-$2.50')
PASS fmt_minutes 205 → 3h 25m (got '3h 25m', want '3h 25m')
== 8. assetInput/createAsset — adversarial input lands clipped, never 22001 ==
PASS location with clipped 9000-char name inserts
PASS 9000-char multibyte name clipped to 190 (got 190, want 190)
PASS 500-char tag clipped to 40 (got 40, want 40)
PASS bad status coerced to operational (got 'operational', want 'operational')
PASS invalid purchase date dropped (got '', want '')
PASS 15-digit cost clamped to 2e9¢ (got 2000000000, want 2000000000)
PASS adversarial asset INSERTS cleanly (no 22001)
PASS stored name exactly 190 chars (got 190, want 190)
PASS stored cost clamped (got 2000000000, want 2000000000)
PASS 50KB notes stored intact
PASS auto tag AST-0001 (junk tag ignored by pattern) (got 'AST-0001', want 'AST-0001')
PASS auto tag increments to AST-0002 (got 'AST-0002', want 'AST-0002')
== 9. woInput/createWorkOrder — clamps + WO numbering ==
PASS bad type → corrective (got 'corrective', want 'corrective')
PASS bad priority → medium (got 'medium', want 'medium')
PASS assigned_to clipped to 190 (got 190, want 190)
PASS labor clamped to 2e9 (got 2000000000, want 2000000000)
PASS parts $123.45 → 12345¢ (got 12345, want 12345)
PASS negative downtime floored 0 (got 0, want 0)
PASS adversarial WO inserts cleanly
PASS WO number WO-0001 (got 'WO-0001', want 'WO-0001')
PASS 100KB description stored intact
PASS WO number increments to WO-0002 (got 'WO-0002', want 'WO-0002')
== 10. transitions through the DB (done requires resolution, cancel is terminal) ==
PASS open → done WITHOUT resolution rejected
PASS status unchanged after rejected transition (got 'open', want 'open')
PASS open → in-progress ok
PASS in-flight row has NULL closed_at
PASS in-progress → done WITH resolution ok
PASS resolution saved (got 'Replaced belt and tensioner.', want 'Replaced belt and tensioner.')
PASS closed_at set on done
PASS done → canceled rejected (matrix)
PASS done → open (reopen) ok
PASS closed_at cleared on reopen
PASS open → canceled ok
PASS canceled → open rejected (terminal)
PASS canceled → in-progress rejected (terminal)
PASS canceled → done rejected (terminal)
PASS canceled row keeps closed_at (got false, want false)
== 11. PM completion: WO created + last_done advanced, double-submit safe ==
PASS PM with 120KB instructions inserts
PASS 45d since last_done on 30d interval → overdue (got 'overdue', want 'overdue')
PASS overdue by exactly -15 days (got -15, want -15)
PASS completion created a WO
PASS completion WO is preventive (got 'preventive', want 'preventive')
PASS completion WO is done (got 'done', want 'done')
PASS completion WO labor = est_minutes (got 25, want 25)
PASS completion WO linked to schedule (got 1, want 1)
PASS completion WO has a resolution
PASS last_done advanced to today (got '2026-11-07', want '2026-11-07')
PASS second submit same day → NOT created
PASS exactly one new WO total (got 3, want 3)
PASS last_done still today (got '2026-11-07', want '2026-11-07')
PASS after completion schedule is healthy (due in 30d) (got 'ok', want 'ok')
PASS after completion days_until = 30 (got 30, want 30)
== 12. auth + settings write-path clamps ==
PASS correct password authenticates
PASS session uid set on login
PASS wrong password rejected
PASS unknown email rejected
PASS password_hash is not plaintext
PASS company name clipped to 190 (got 190, want 190)
PASS currency symbol clipped to 8 (got 8, want 8)
PASS labor rate clamped to 2e9 (got 2000000000, want 2000000000)
PASS due-soon window clamped to 365 (got 365, want 365)
== 13. calendar: month validation, prev/next arithmetic, grid boundaries, leap Feb ==
PASS validMonth accepts 2026-07 (got '2026-07', want '2026-07')
PASS validMonth rejects month 13
PASS validMonth rejects junk
PASS validMonth rejects pre-1970
PASS validMonth rejects 2026-7 (needs 2 digits)
PASS monthAdd Jan − 1 = prior Dec (got '2025-12', want '2025-12')
PASS monthAdd Dec + 1 = next Jan (got '2027-01', want '2027-01')
PASS monthAdd +6 across year (got '2027-01', want '2027-01')
PASS monthAdd −19 lands 2024-12 (got '2024-12', want '2024-12')
PASS LEAP Feb 2028 has 29 in-month cells (got 29, want 29)
PASS non-leap Feb 2027 has 28 cells (got 28, want 28)
PASS Feb 2027 grid is exactly 4 weeks (Feb 1 = Monday, 28 days) (got 4, want 4)
PASS Feb 2027 first cell IS Feb 1 (got '2027-02-01', want '2027-02-01')
PASS Jul 2026 has 31 in-month cells (got 31, want 31)
PASS Jul 2026 grid starts Mon Jun 29 (Jul 1 = Wednesday) (got '2026-06-29', want '2026-06-29')
PASS Jun 29 cell flagged out-of-month
PASS every week is 7 cells (got 7, want 7)
PASS Jul 2026 grid ends Sun Aug 2 (got '2026-08-02', want '2026-08-02')
PASS on-time PM lands on its due date (got 1, want 1)
PASS BOTH overdue PMs pinned to today (got 2, want 2)
PASS date-overdue PM flagged overdue
PASS usage-overdue PM carries usage flag
PASS next-month PM excluded from July
PASS WO lands on its opened day (got 'WO-0009', want 'WO-0009')
PASS 02:00 UTC Aug 1 buckets to Jul 31 in NY (got 'WO-0008', want 'WO-0008')
PASS viewing August: overdue stays pinned to July today (not shown)
PASS viewing August: the Aug 2 PM shows normally (got 1, want 1)
== 14. meter readings: monotonic validation, correction flag, clip/clamp ==
PASS meter name clipped to 100 (got 100, want 100)
PASS meter unit clipped to 20 (got 20, want 20)
PASS negative reading floored 0 (got 0, want 0)
PASS blank unit defaults to hours (got 'hours', want 'hours')
PASS meter created
PASS initial reading logged (got 1, want 1)
PASS current reading stored (got 1000, want 1000)
PASS forward reading 1000→1200 accepted
PASS current advanced to 1200 (got 1200, want 1200)
PASS LOWER reading 1100 rejected without correction flag
PASS current unchanged after rejection (got 1200, want 1200)
PASS no reading row written on rejection (got 2, want 2)
PASS EQUAL reading accepted (meter did not move)
PASS lower reading WITH correction flag accepted
PASS meter wound back to 900 (got 900, want 900)
PASS correction row flagged is_correction=1 (got 1, want 1)
PASS PM baseline above the corrected value was REBASED to it (got 900, want 900)
PASS correction message reports the rebase
PASS usage trigger live after rebase (250 units remain) (got 250, want 250)
PASS correction above baselines rebases nothing
== 15. usage trigger: hand-computed fixtures + days-vs-usage races ==
PASS 1240/1250: 10 units remain (got 10, want 10)
PASS 1260/1250: −10 (overdue by usage) (got -10, want -10)
PASS 1100/1250: 150 remain (got 150, want 150)
PASS usage window = 10% of 250 = 25 (got 25, want 25)
PASS usage window floors at 1 (interval 5 → 1) (got 1, want 1)
PASS 10 remaining ≤ 25 → due-soon (got 'due-soon', want 'due-soon')
PASS EXACTLY 25 remaining → due-soon (got 'due-soon', want 'due-soon')
PASS 26 remaining → ok (got 'ok', want 'ok')
PASS 0 remaining (due now) → due-soon (got 'due-soon', want 'due-soon')
PASS −10 remaining → overdue (got 'overdue', want 'overdue')
PASS USAGE-FIRST race: days ok but meter past trigger → overdue (got 'overdue', want 'overdue')
PASS …due_source = usage (got 'usage', want 'usage')
PASS …usage_remaining = −10 (got -10, want -10)
PASS …days side untouched (25d out) (got 25, want 25)
PASS DAYS-FIRST race: meter fine but date blown → overdue (got 'overdue', want 'overdue')
PASS …due_source = days (got 'days', want 'days')
PASS …usage side reads ok (got 'ok', want 'ok')
PASS both windows hit → due-soon, source=both
PASS NO meter → days only (usage keys null)
PASS meter but interval_units=0 → days only
PASS meter row missing (NULL join) → days only
PASS usage PM stored with meter link (got 1, want 1)
PASS usage PM completion creates WO
PASS last_done_reading advanced to meter current (3512) (got 3512, want 3512)
PASS last_done advanced to today too (got '2026-11-07', want '2026-11-07')
== 16. parts inventory: clamps, atomic consumption, stock never negative ==
PASS part number clipped to 40 (got 40, want 40)
PASS part name clipped to 190 (got 190, want 190)
PASS negative stock floored to 0 (got 0, want 0)
PASS 15-digit cost clamped to 2e9 (got 2000000000, want 2000000000)
PASS negative min stock floored (got 0, want 0)
PASS auto part number PRT-0001 (got 'PRT-0001', want 'PRT-0001')
PASS part number increments PRT-0002 (got 'PRT-0002', want 'PRT-0002')
PASS WO misc parts cost stored (got 2500, want 2500)
PASS WO total = misc while no lines (got 2500, want 2500)
PASS consume 4 of 10 accepted
PASS stock decremented 10→6 (got 6, want 6)
PASS WO parts total = 2500 + 4×1299 = 7696¢ (got 7696, want 7696)
PASS line snapshots unit cost 1299¢ (got 1299, want 1299)
PASS line snapshots part number (got 'PRT-0001', want 'PRT-0001')
PASS OVER-consume (7 of 6) rejected whole
PASS stock untouched by rejected consume (got 6, want 6)
PASS no line added on rejection (got 1, want 1)
PASS WO cost untouched on rejection (got 7696, want 7696)
PASS consume exactly remaining 6 accepted
PASS stock now 0 (got 0, want 0)
PASS consume from empty rejected — stock NEVER negative
PASS stock still 0 (not −1) (got 0, want 0)
PASS qty 0 rejected
PASS unknown part rejected
PASS WO total with 10 consumed = 15490¢ (got 15490, want 15490)
PASS line removal accepted
PASS 4 units returned to stock (got 4, want 4)
PASS WO re-priced: 2500 + 6×1299 = 10294¢ (got 10294, want 10294)
PASS max-cost consumption accepted (no engine error)
PASS WO parts total CLAMPED to 2e9¢, not overflowed (got 2000000000, want 2000000000)
PASS after misc edit: 1000 + 6×1299 = 8794¢ (got 8794, want 8794)
== 17. WO parts cost flows into the cost report (hand-computed fixture) ==
PASS 3 units consumed for the fixture
PASS stored parts total 5747¢ (got 5747, want 5747)
PASS woCostCents = 12000 + 5747 = 17747¢ (got 17747, want 17747)
PASS aggregate parts_cents = 5747 (got 5747, want 5747)
PASS aggregate labor_cents = 12000 (got 12000, want 12000)
PASS aggregate total_cents = 17747 (got 17747, want 17747)
PASS month bucket carries the same 17747¢ (got 17747, want 17747)
PASS export shape: 5747¢ prints as 57.47 (got '57.47', want '57.47')
== 18. low-stock boundary: at-or-below min, min 0 = alerts off ==
PASS stock == min → LOW
PASS stock = min+1 → not low
PASS stock 0, min 5 → LOW
PASS min 0 → never low (alerts off)
PASS min 0 with stock → not low
== 19. parts CSV formula guard + csrf_check() present on every new POST handler ==
PASS part number formula neutralized (got '\'=CMD|calc', want '\'=CMD|calc')
PASS part name formula neutralized (got '\'+HYPERLINK("evil")', want '\'+HYPERLINK("evil")')
PASS numeric stock value untouched (got '-12.50', want '-12.50')
PASS new POST handler meter_create() calls csrf_check()
PASS new POST handler meter_delete() calls csrf_check()
PASS new POST handler meter_reading_create() calls csrf_check()
PASS new POST handler wo_part_add() calls csrf_check()
PASS new POST handler wo_part_remove() calls csrf_check()
PASS new POST handler part_create() calls csrf_check()
PASS new POST handler part_update() calls csrf_check()
PASS new POST handler part_delete() calls csrf_check()
PASS new POST handler assignee_create() calls csrf_check()
PASS new POST handler assignee_delete() calls csrf_check()
== 20. Database::migrate(): old-shape DB gains tables/columns, data intact ==
PASS parts table created by migrate
PASS asset_meters table created
PASS wo_parts + assignees created
PASS pm_schedules gained meter_id
PASS legacy WO backfilled: misc = old parts cost (4200¢) (got 4200, want 4200)
PASS legacy WO total unchanged (4200¢) (got 4200, want 4200)
PASS WO row count intact through migrate (got 8, want 8)
PASS PM row count intact (got 3, want 3)
PASS asset row count intact (got 4, want 4)
PASS legacy WO data intact (labor 30 min)
PASS re-running migrate never re-backfills buyer data (got 999, want 999)
PASS post-migrate consumption works on a legacy WO
PASS legacy WO re-priced: 4200 + 2×1000 = 6200¢ (got 6200, want 6200)
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
----------------------------------------------------
== 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: rate
PASS v2: csv_dry
PASS v2: csv_commit
PASS v2: csv_dup_skips
PASS v2: guard_resolution
PASS v2: wo_serializer
PASS v2: guard_monotonic
PASS v2: backup_tables
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: notifications + webhooks fire on the BROWSER path too, never fatally
PASS v2: API reuses the tested core (no re-derived guards)
PASS v2: meter ingest + pm-due endpoints exist and pm-due reuses the UI computation
PASS v2: dark palette + no hardcoded white backgrounds outside @media print
PASS v2: theme.js + qr.js ship and layout loads theme.js + a toggle
PASS v2: shared modules are copy-identical to the shared core (never forked per app)
PASS v2: API.md ships + sw cache is v4.1.3
PASS v3: the 3.0 check ran clean (no PHP warning, no fatal)
PASS v3: a forward meter reading is accepted through MCP
PASS v3: an equal reading is accepted (the meter did not move)
PASS v3: THE INVARIANT: a lower reading is REFUSED through MCP
PASS v3: the refused reading left the meter untouched
PASS v3: the refused reading wrote no meter_readings row
PASS v3: MCP offers no correction argument at all
PASS v3: a smuggled correction argument is refused
PASS v3: the correction attempt changed nothing
PASS v3: create_workorder raises a job through the product's own creator
PASS v3: closing without a resolution is refused
PASS v3: the refused close left the job open
PASS v3: a legal transition is accepted
PASS v3: closing with a resolution is accepted
PASS v3: an illegal transition is refused by the product
PASS v3: a viewer key is refused record_meter
PASS v3: the viewer refusal changed nothing
PASS v3: the MCP status enum is the product's own vocabulary (hyphen and all)
PASS v3: every advertised status is answerable
PASS v3: every column the anonymize map names really exists
PASS v3: erasure touches no maintenance record column
PASS v3: the iCal feed uses the description key the core reads
PASS v3: index.php calls Database::migrate() at boot
PASS v3: no literal route is shadowed by an earlier {id} route
PASS v3: all 9 shared cores are byte-identical to the reference copy
PASS v3: the deploy kit ships both files, fully filled
PASS v3: the deploy kit names THIS product and carries the fixed healthcheck
PASS v3: the brand accent is applied in :root AND html[data-theme=dark]
PASS v3: an uploaded logo and the brand name reach the app chrome
PASS v3: GET /mcp is routed to the unauthenticated probe
PASS v3: the GET probe runs no key check and exposes no tools
PASS v3: GET reaches the core and gets 405 + Allow: POST
PASS v3: MCP logs meters through Maintenance::addMeterReading(), never its own UPDATE
PASS v3: MCP advances work orders through Maintenance::transitionWorkOrder()
PASS v3: MCP never winds a meter back (the correction flag is hard-coded false)
PASS v3: the PDFs read the product's own cost functions
PASS v3: the technician view posts to the desk's existing routes, not new ones
PASS v3: the technician view offers only transitions the product allows
PASS v3: the technician view has real touch targets and a narrow-screen rule
PASS v3: both backup doors use one redaction list
PASS v3: the redaction list covers every credential, including the calendar token
PASS v3: the PM calendar feed uses its own token, not the backup one
PASS v3: every write handler is permission-gated
PASS v3: no call to a function this product does not define
PASS C2: the report only reads by_month keys the aggregate really writes
PASS C2: no sensitive handler is login-only — all are role-gated
PASS C2: both backup doors call the ONE shared redaction list
PASS C2: Api::requireKey refuses a deactivated user (u.active)
PASS C2: every App:: method the controllers call actually exists
PASS C2: Api::base() does not double an already-absolute base
== UI kit v1.0 · work-order board move ==
PASS board move: open → in-progress accepted
PASS board move: status persisted (got 'in-progress', want 'in-progress')
PASS board move: a Done drop with no resolution is refused
PASS board move: nothing was written on the refusal (got 'in-progress', want 'in-progress')
PASS board move: the endpoint answers 422 asking for the note, not a silent failure
PASS board move: open → canceled accepted
PASS board move: dragging a canceled card back is refused
PASS board move: a refused transition answers 409
PASS board move: no direct status UPDATE in the endpoint
PASS board move: requires wo.advance + CSRF
PASS board move: the move is written to the audit trail
PASS board move: route registered and the board posts to it
== v3.1 · work-order attachments (P3) ==
PASS attach: PNG bytes are recognized (got 'image/png', want 'image/png')
PASS attach: JPEG bytes are recognized (got 'image/jpeg', want 'image/jpeg')
PASS attach: PDF bytes are recognized — a CMMS needs paper, not only pictures (got 'application/pdf', want 'application/pdf')
PASS attach: a picture and a document are distinguished for display
PASS attach: a text file wearing a .png name is refused (content decides)
PASS attach: storing a disguised file fails with a reason a human can act on
PASS attach: an oversized file is refused and the cap is stated
PASS attach: a real PNG is stored
PASS attach: the stored name is minted here, so an upload cannot choose its own extension
PASS attach: the recorded mime is the sniffed one (got 'image/png', want 'image/png')
PASS attach: upload demands photo.upload + CSRF
PASS attach: delete demands wo.edit + CSRF
PASS attach: the served Content-Type comes from the RECORDED mime, never the URL extension
PASS attach: served files are nosniff and never rendered as HTML
PASS attach: uploads are stored through Attach, not the 512 KB logo path
PASS attach: the work-order record actually RENDERS what was uploaded (it used to render nothing)
== v3.1 · PM calendar feed (P4) ==
PASS feed: dated PM schedules become events
PASS feed: every event has a stable UID (a feed without one duplicates on every refresh)
PASS feed: the output is a VCALENDAR with CRLF line endings
PASS feed: tasks are all-day events on their due date
PASS feed: an overdue task says so in the title a phone shows
PASS feed: the token is stored HASHED — the database never holds a working URL
PASS feed: a wrong token does not open the feed
PASS feed: an unknown or revoked token is refused with 404, not an empty calendar
PASS feed: the serve path only ever considers LIVE tokens for this feed
PASS feed: creating and revoking a feed demands settings.edit + CSRF
PASS feed: revoking twice cannot un-revoke or re-date a feed (guarded UPDATE)
PASS feed: the URL is shown once — only the hash is stored
PASS feed: the calendar is marked noindex and served as text/calendar
PASS feed: routes registered and the page is reachable from the nav
== fix wave: every REST write checks the key holder's role ==
PASS api: the viewer key is refused POST /api/work-orders (403, needs wo.create)
PASS api: the member key passes the role check on POST /api/work-orders
PASS api: the admin key passes the role check on POST /api/work-orders
PASS api: the viewer key is refused POST /api/work-orders/999/status (403, needs wo.advance)
PASS api: the member key passes the role check on POST /api/work-orders/999/status
PASS api: the viewer key is refused POST /api/assets/999/readings (403, needs meter.record)
PASS api: the member key passes the role check on POST /api/assets/999/readings
PASS api: every /api/* write route checks the key holder's role (no key-only writes)
== fix wave: saving the company form keeps the email settings ==
PASS settings: saving the company form keeps the SMTP host, sender and notification address
PASS settings: saving the company form keeps the notice switches on
PASS settings: the company form itself still saves
PASS settings: the email form still clears the email settings when you clear them
== fix wave: the daily PM digest ==
PASS digest columns exist
PASS digest is off on a fresh install
PASS digest needs smtp and an address
PASS digest is not sent when nothing is due
PASS digest dry run counts and claims nothing
PASS digest a refused send is not marked sent
PASS digest goes to the notification address with every item
PASS digest marks today as sent
PASS digest goes at most once a day
PASS digest is audited
PASS digest: the viewer is refused the send-now button (403)
PASS digest: the member is refused the send-now button (403)
PASS digest: an admin presses the button and is told what happened
PASS digest: cron/pm-digest.php exists, runs the one function, and refuses anything but the command line
PASS digest: the web server refuses /cron/ (router and .htaccess)
== 3.3 · the weekly report for people without a login ==
PASS weekly report: its settings columns exist [sqlite]
PASS weekly report: addresses are lower-cased, kept once, checked, and at most 10 [sqlite]
PASS weekly report: "last week" is the ISO week just ended, Monday to Sunday, across a year end too [sqlite]
PASS weekly report: off until switched on, and nothing goes with no addresses [sqlite]
PASS weekly report: a dry run counts the addresses and claims nothing [sqlite]
PASS weekly report: it lists last week's work orders done and opened, what is still open, PM overdue and parts at their minimum [sqlite]
PASS weekly report: no prices go out (parts cost, unit cost) [sqlite]
PASS weekly report: one email per address, each with its own stop link, no address seeing another [sqlite]
PASS weekly report: at most once a week [sqlite]
PASS weekly report: a week nobody got is given back, and the next run sends it [sqlite]
PASS weekly report: the stop link is signed per address and takes only that address off [sqlite]
PASS weekly report: the viewer cannot set it, send it or preview it (403)
PASS weekly report: the member cannot set it, send it or preview it (403)
PASS weekly report: an admin saves the addresses; a bad one is refused and nothing is saved
PASS weekly report: the preview shows the email and sends nothing
PASS weekly report: a stop link with a wrong token is a 404
PASS weekly report: the right link shows a button (a scanner that follows it changes nothing); pressing it takes that address off only
PASS weekly report: cron/weekly-report.php runs the one function and refuses anything but the command line
== 3.3.1 · the asset and part imports' preview runs the import's own checks ==
PASS import preview (assets): it counts exactly what the import brings in, names the same skipped rows, and writes nothing [sqlite]
PASS import preview (assets): a tag that exists, a tag twice in the file and a row with no name are each named [sqlite]
PASS import preview (parts): it counts exactly what the import brings in, names the same skipped rows, and writes nothing [sqlite]
PASS import preview (parts): a part number that exists, one twice in the file and a row with no name are each named [sqlite]
PASS import preview: the upload step uses the import's own handler in normal mode, never the dry-run flag [sqlite]
== fix wave: a viewer cannot read the audit trail ==
PASS audit: the viewer is refused /audit (403)
PASS audit: an admin still opens /audit
PASS audit: the viewer is refused /audit.csv (403)
PASS audit: an admin still opens /audit.csv
PASS audit: the sidebar shows the viewer no audit link
PASS audit: the viewer still opens /
PASS audit: the viewer still opens /assets
PASS audit: the viewer still opens /pm
PASS audit: the viewer still opens /workorders
PASS audit: the viewer still opens /workview
PASS audit: the viewer still opens /parts
PASS audit: the viewer still opens /report
PASS audit: the viewer still opens /calendar
PASS audit: the viewer still opens /tags
PASS audit: the viewer still opens /locations
PASS audit: every *.view permission the code asks for is granted to the viewer, except audit.view
== fix wave: urgent is urgent everywhere ==
PASS priority: "My jobs" lists urgent first, then high, medium, low
PASS priority: priority from reads the four values and critical as urgent
PASS priority: agent critical is refused with the real values
PASS priority: agent urgent is filed as urgent
PASS priority: agent unknown priority is refused not filed as medium
PASS priority: agent with no priority still gets medium
PASS priority: agent filter by urgent finds the urgent job
PASS priority: agent schema and instructions name urgent never critical
PASS priority: rest refuses an unknown priority with 422
PASS events: agent work order fires workorder created
PASS events: agent done fires workorder completed
PASS events: agent reading fires reading recorded
PASS events: bulk done fires workorder completed after the commit
PASS events: each work order event is fired from one function
== fix wave: every backup and restore route is administrator-only ==
PASS backup: a signed-in viewer gets 403 on GET /backup.json
PASS backup: a signed-in viewer gets 403 on GET /backup.sqlite
PASS backup: a signed-in viewer gets 403 on GET /restore
PASS backup: a signed-in viewer gets 403 on POST /restore/dry-run
PASS backup: a signed-in viewer gets 403 on POST /restore/commit
PASS backup: a signed-in viewer gets 403 on POST /restore/token
PASS backup: a signed-in viewer without the token gets nothing from GET /backup/scheduled
PASS backup: a signed-in member gets 403 on GET /backup.json
PASS backup: a signed-in member gets 403 on GET /backup.sqlite
PASS backup: a signed-in member gets 403 on GET /restore
PASS backup: a signed-in member gets 403 on POST /restore/dry-run
PASS backup: a signed-in member gets 403 on POST /restore/commit
PASS backup: a signed-in member gets 403 on POST /restore/token
PASS backup: a signed-in member without the token gets nothing from GET /backup/scheduled
PASS backup: an admin still opens the restore page
PASS ui: the sidebar brand cannot be shrunk to nothing (flex-shrink:0)
PASS ui: with no company name the sidebar falls back to "Fixora", not another product's name
== fix wave: the webhook buttons work ==
PASS webhooks: Add creates the endpoint (no 404)
PASS webhooks: Pause switches it off (no 404)
PASS webhooks: pressing it again switches it back on
PASS webhooks: the page offers Pause and Delete for the endpoint, at those same addresses
PASS webhooks: Delete removes it (no 404)
-- F2b: the demo ribbon's wording and size; the JSON backup's secrets --
PASS demo ribbon: Fixora has no public page, so a page served without sign-in keeps the admin wording too
PASS demo ribbon: an administrator's page keeps the admin wording
PASS demo ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
PASS demo ribbon: registered only behind the .demo-mode marker, and non-HTML output passes through untouched
PASS backup: the JSON download carries none of the 14 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.ical_token_hash, settings.oidc_client_secret, settings.report_secret, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, backup_state.s3_secret, invites.token_hash, feed_tokens.token_hash, work_requests.public_token)
————————————————————————————————————————————————————
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
== 3.2 · the work-request portal ==
PASS portal: OFF on a fresh install — /fault/<anything> is a 404
PASS portal: switching it on mints a random key of 24 hex characters
PASS portal: a member cannot switch the portal on (settings.edit)
PASS portal: the form opens with no login, and is kept out of search engines
PASS portal: the tag address names the machine
PASS portal: a wrong key, or an unknown tag, is a 404 that says nothing
PASS portal: a report lands as a request with a reference and the requester's own link
PASS portal: nothing became a work order by itself (a request waits for the team)
PASS portal: the address is stored as a salted hash, never in the clear
PASS portal: the honeypot — a bot that fills the hidden field is thanked and nothing is kept
PASS portal: a report with no name is refused with a plain sentence
PASS portal: at most 5 reports an hour from one address
PASS status: the requester's link shows their report as received, and no staff names
PASS status: a made-up token is a 404
PASS inbox: a viewer sees the requests but cannot accept one
PASS inbox: the dashboard asks for action on new requests
PASS accept: a technician accepts it into a corrective work order carrying the requester and the words
PASS accept: once — accepting it again is refused
PASS status: the requester now sees the work order number and its state
PASS decline: a reason is needed, because the requester reads it
PASS decline: the requester sees "not taken forward" and why
PASS tags: with the portal on, each printed QR opens the form for its machine
PASS tags: ?for=staff still prints the sign-in lookup tags
PASS api: GET /api/requests lists the inbox for a key holder, filtered by status
PASS portal: a new link closes every old link and tag
PASS portal: switched off, the current link is a 404 too
PASS backup: the portal key and the requester links are on the redaction list
PASS webhooks: request.created is on offer
PASS accept: the request photo is filed on the work order as a "before" photo, checked by its bytes
PASS validate: a report under five characters, or with no place and no machine, is refused
PASS keyOk: an empty or short stored key never opens the portal
== 3.2 · checklists on PM work orders ==
PASS checklist: steps are one per line, bullets and numbers and blank lines dropped
PASS pm: with a checklist, logging is refused until every step is answered, and nothing is written
PASS pm: the answers are kept on the completed work order, in order, with who answered
PASS pm: a failed step raises one corrective, high-priority work order on the same asset, linked from the step
PASS pm: a completed work order's answers do not change
PASS work order: with an unanswered step it cannot be marked done (the one rule every door uses)
PASS work order: failing the same step twice raises one work order, not two
PASS work order: once every step is answered it closes, and steps cannot be added after
PASS work order: an unknown answer is refused
PASS door: the PM page lists the steps to answer
PASS door: posting the PM with a step unanswered is refused with the reason
PASS door: answered in full, the PM is logged and its answers are on the work order
PASS door: dragging it to Done on the board is refused while a step is unanswered
PASS door: the work order page shows the checklist
PASS api: a viewer's key cannot answer a step
PASS api: GET /api/work-orders/{id} carries the checklist
== 3.2 · saved repair comments ==
PASS saved comments: kept once, tidied, and a duplicate or an empty one is refused
PASS saved comments: removed on request
PASS saved comments: a member cannot change the list (settings.edit)
PASS saved comments: the work order offers them under its resolution box, one tap to insert
== 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)
PASS updates: the updater table holds exactly one state row
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 Fixora 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 Fixora 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 Fixora, 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 5 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 (29 tables dumped)
PASS erase planting · sqlite: the Data requests page lists someone who only sent a public request
PASS erase planting · sqlite: a request still waiting for a decision refuses the erase, and nothing changes
690/690 PASS, 0 FAIL
← Back to Fixora · Manual · Quickstart · API