Fixora · Test run · run 2026-09-25
Fixora Test run, as shipped in the download
545 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.
[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-09-25 > today(UTC-11)=2026-09-24
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, cancelled excluded ==
PASS count includes cancelled (got 4, want 4)
PASS downtime excludes cancelled (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 cancelled=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 cancelled) (got 5500, want 5500)
PASS Jun count = 1 (no cancelled) (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 → cancelled rejected (matrix)
PASS done → open (reopen) ok
PASS closed_at cleared on reopen
PASS open → cancelled ok
PASS cancelled → open rejected (terminal)
PASS cancelled → in-progress rejected (terminal)
PASS cancelled → done rejected (terminal)
PASS cancelled 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-09-25', want '2026-09-25')
PASS second submit same day → NOT created
PASS exactly one new WO total (got 3, want 3)
PASS last_done still today (got '2026-09-25', want '2026-09-25')
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-09-25', want '2026-09-25')
== 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 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
----------------------------------------------------
== 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 v3.1.6
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 anonymise 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 → cancelled accepted
PASS board move: dragging a cancelled 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 recognised (got 'image/png', want 'image/png')
PASS attach: JPEG bytes are recognised (got 'image/jpeg', want 'image/jpeg')
PASS attach: PDF bytes are recognised — 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)
== 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 11 credentials this schema holds (settings.smtp_pass, settings.backup_token_hash, settings.ical_token_hash, settings.oidc_client_secret, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, invites.token_hash, feed_tokens.token_hash)
————————————————————————————————————————————————————
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
545/545 PASS, 0 FAIL
← Back to Fixora · Manual · Quickstart · API