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

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