Home›Safora›Test run
Safora · Test run · run 2026-11-07

Safora Test run, as shipped in the download

493 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.

== 1. °C/°F conversion (canonical storage is °C) ==
  PASS  0°C = 32°F (got 32, want ~32)
  PASS  100°C = 212°F (got 212, want ~212)
  PASS  -18°C = -0.4°F (got -0.4, want ~-0.4)
  PASS  75°C = 167°F (got 167, want ~167)
  PASS  32°F = 0°C (got 0, want ~0)
  PASS  212°F = 100°C (got 100, want ~100)
  PASS  round-trip 4.5°C (got 4.5, want ~4.5)
  PASS  round-trip -22°C (got -22, want ~-22)

== 2. displayTemp / parseTempToC ==
  PASS  display 3°C in C (got '3.0 °C', want '3.0 °C')
  PASS  display 75°C in F (got '167.0 °F', want '167.0 °F')
  PASS  display null (got '—', want '—')
  PASS  parse "4.5" as C (got 4.5, want 4.5)
  PASS  parse "-18" as C (got -18.0, want -18.0)
  PASS  parse "4,5" comma (got 4.5, want 4.5)
  PASS  parse "167" as F->C (got 75.0, want 75.0)
  PASS  parse "" -> null (got NULL, want NULL)
  PASS  parse "abc" -> null (got NULL, want NULL)
  PASS  parse absurd 9999999 -> null (DECIMAL overflow guard) (got NULL, want NULL)
  PASS  parse absurd -9999999 -> null (got NULL, want NULL)
  PASS  parse absurd F 9999999 -> null (got NULL, want NULL)
  PASS  parse extreme-but-sane 300 -> 300 (got 300.0, want 300.0)
  PASS  parse blast-freezer -80 -> -80 (got -80.0, want -80.0)

== 3. equipmentRange: type defaults + per-equipment overrides ==
  PASS  fridge default max 5 (got array (
  'min' => NULL,
  'max' => 5.0,
), want array (
  'min' => NULL,
  'max' => 5.0,
))
  PASS  freezer default max -18 (got array (
  'min' => NULL,
  'max' => -18.0,
), want array (
  'min' => NULL,
  'max' => -18.0,
))
  PASS  hot_hold default min 63 (got array (
  'min' => 63.0,
  'max' => NULL,
), want array (
  'min' => 63.0,
  'max' => NULL,
))
  PASS  fridge override max 4 (got array (
  'min' => NULL,
  'max' => 4.0,
), want array (
  'min' => NULL,
  'max' => 4.0,
))
  PASS  probe with bounds (got array (
  'min' => 0.0,
  'max' => 8.0,
), want array (
  'min' => 0.0,
  'max' => 8.0,
))
  PASS  other no bounds (got array (
  'min' => NULL,
  'max' => NULL,
), want array (
  'min' => NULL,
  'max' => NULL,
))

== 4. tempInRange boundary cases ==
  PASS  5.0 within [null,5]
  PASS  5.1 above [null,5] fails
  PASS  4.9 within [null,5]
  PASS  -18 within [null,-18]
  PASS  -17.9 above [null,-18]
  PASS  63 within [63,null]
  PASS  62.9 below [63,null]
  PASS  null temp never in range

== 5. equipmentInRange (fridge / freezer / hot-hold) ==
  PASS  fridge 4°C in range
  PASS  fridge 5°C in range (limit)
  PASS  fridge 7°C out of range
  PASS  freezer -20°C in range
  PASS  freezer -12°C out of range
  PASS  hot-hold 65°C in range
  PASS  hot-hold 60°C out of range
  PASS  unconfigured probe always in range

== 6. cookInRange: cooking 75°C, reheating 82°C boundaries ==
  PASS  cook 75.0 passes
  PASS  cook 74.9 fails
  PASS  cook 80 passes
  PASS  reheat 82.0 passes
  PASS  reheat 81.9 fails
  PASS  reheat 75 fails (needs 82)
  PASS  cook null fails

== 7. coolingInRange: ≤8°C within 90 min ==
  PASS  8°C in 90min passes
  PASS  7°C in 60min passes
  PASS  8.1°C in 60min fails
  PASS  8°C in 91min fails
  PASS  5°C in 120min fails
  PASS  missing minutes fails

== 8. deliveryInRange: chilled / frozen / ambient ==
  PASS  chilled 5°C accepted
  PASS  chilled 8°C accepted
  PASS  chilled 10°C rejected
  PASS  frozen -18°C accepted
  PASS  frozen -15°C accepted
  PASS  frozen -10°C rejected
  PASS  ambient no temp accepted
  PASS  chilled no temp rejected

== 9. validateEntry: corrective action REQUIRED when out of range ==
  PASS  in range, no action -> OK (got NULL, want NULL)
  PASS  in range, with action -> OK (got NULL, want NULL)
  PASS  out of range, no action -> error
  PASS  out of range, blank action -> error
  PASS  out of range WITH action -> OK (got NULL, want NULL)

== 10. checklist completeness ==
  PASS  progress 2 of 3 (got array (
  'done' => 2,
  'total' => 3,
), want array (
  'done' => 2,
  'total' => 3,
))
  PASS  not complete when partial
  PASS  complete when all ticked
  PASS  empty checklist not complete

== 11. dayStatus: green / amber / red / none ==
  PASS  green: all done, all in range (got 'green', want 'green')
  PASS  amber: deviation corrected (got 'amber', want 'amber')
  PASS  amber: pending today (got 'amber', want 'amber')
  PASS  red: deviation unaddressed (got 'red', want 'red')
  PASS  red: missed check in the past (got 'red', want 'red')
  PASS  red dominates amber (got 'red', want 'red')
  PASS  none: empty untouched day (got 'none', want 'none')
  PASS  green: readings fine, none required (got 'green', want 'green')
  PASS  green today complete (got 'green', want 'green')

== 12. statusRank ordering (red worst) ==
  PASS  red > amber
  PASS  amber > green
  PASS  green > none

== 13. auditCsv: RFC-4180 escaping ==
  PASS  header row (got 'Date,Type,Item,Reading,In range,Corrective action,By', want 'Date,Type,Item,Reading,In range,Corrective action,By')
  PASS  quotes/commas escaped
  PASS  CRLF endings
  PASS  row count (header + 2) (got 3, want 3)
  PASS  formula = neutralized (got '\'=1+2', want '\'=1+2')
  PASS  formula + neutralized (got '\'+1', want '\'+1')
  PASS  formula - with text neutralized (got '\'-SUM(A1)', want '\'-SUM(A1)')
  PASS  numeric -2 kept (numeric-aware, not a formula) (got '-2', want '-2')
  PASS  numeric -18.5 kept (numeric-aware) (got '-18.5', want '-18.5')
  PASS  formula @ neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  leading TAB neutralized (got '\'	x', want '\'	x')
  PASS  cmd payload neutralized (got '\'=cmd|calc', want '\'=cmd|calc')
  PASS  CR payload quoted+prefixed (got '"\'
X"', want '"\'
X"')
  PASS  plain text untouched (got 'Sam', want 'Sam')
  PASS  mid-string = untouched (got 'a=b', want 'a=b')

== 14. Pdf: produces a valid, non-trivial PDF document ==
  PASS  starts with %PDF header
  PASS  ends with %%EOF
  PASS  has xref table
  PASS  has a page object
  PASS  non-trivial length
  PASS  a very long run of text still renders

== 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: rule_in_range_needs_nothing
  PASS  v2: rule_out_of_range_needs_action
  PASS  v2: rule_out_of_range_with_action_ok
  PASS  v2: range_defaults
  PASS  v2: range_custom_overrides_default
  PASS  v2: parses_fahrenheit
  PASS  v2: day_status
  PASS  v2: equipment_serializer
  PASS  v2: csv_dry
  PASS  v2: csv_commit_rejects_reversed_bounds
  PASS  v2: backup_tables
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on every boot (1.x had none — existing installs need it)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: EVERY browser logging path emits the diary events, never fatally (5 paths)
  PASS  v2: out-of-range gets its own event + notification, in ONE shared emitter
  PASS  v2: the API ENFORCES the corrective-action rule via the tested core
  PASS  v2: the API reuses the shared emitter rather than duplicating the alert
  PASS  v2: compliance + checks + signoff endpoints exist and reuse dayStatus
  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  API.md ships + the sw cache name carries a version

== Own It 3.0: MCP · roles · audit · scheduled checks · corrective actions ==
  PASS  migrate() creates audit_log + check_schedules + entry_photos
  PASS  log_entries gains the action verification columns
  PASS  migrate() creates the settings row (only the installer used to)
  PASS  UPGRADE PATH: drop every v3 table, migrate, they come back
  PASS  a member records checks but cannot edit safe ranges or verify actions
  PASS  a viewer reads and cannot record
  PASS  a viewer canNOT read the audit trail or team roster
  PASS  an admin can verify actions and sign off a day
  PASS  MCP exposes exactly the row's five tools and 3.2.0's two read-only ones, in order
  PASS  3.2.0 MCP trace_deliveries: a batch code (any case, part of it) finds the delivery, supplier, use-by and the open recall
  PASS  3.2.0 MCP trace_deliveries: a delivery recorded before 3.2.0 still shows its supplier
  PASS  3.2.0 MCP allergen_matrix: allergen=nuts returns only the dish that may contain nuts, with both lists
  PASS  3.2.0 MCP allergen_matrix: no filter lists every dish; an unknown allergen is refused
  PASS  3.2.0 MCP: a viewer key may read the trace (read-only tool)
  PASS  every tool description declares READ-ONLY or WRITES
  PASS  GET /mcp is refused with 405
  PASS  an in-range reading saves through MCP
  PASS  THE RULE: out-of-range with no corrective action is REFUSED via MCP
  PASS  **SMUGGLED °F**: 53.6F = 12C is still out of range, still demands an action, and says so in °C
  PASS  a valid °F reading converts correctly (39.2F -> 4.0C)
  PASS  out-of-range WITH an action saves and is flagged out of range
  PASS  that entry appears as an OPEN corrective action
  PASS  the MCP layer has NO temperature maths of its own (code, comments stripped)
  PASS  verification closes an open action
  PASS  a second verification is refused, naming who signed it off first
  PASS  verifying an in-range reading is refused — there is nothing to verify
  PASS  a verified action leaves the open list
  PASS  close_action refuses an already-verified entry
  PASS  MCP role gate: a member key cannot verify
  PASS  MCP role gate: a viewer key cannot record
  PASS  MCP writes land in the audit trail
  PASS  the due list is derived from the schedules
  PASS  a check counts as done because a READING exists, not a tick-box
  PASS  a weekly check appears only on its weekday
  PASS  calendar events carry stable per-day UIDs (no duplicates on refresh)
  PASS  the calendar feed carries no staff names
  PASS  the corrective-action trail renders as a real PDF
  PASS  the food-safety audit pack was ported to the shared core PDF engine
  PASS  inkOn() clears 4.5:1
  PASS  a PHP script named .png is refused
  PASS  another product's backup is refused by name stamp
  PASS  OIDC refuses alg=none
  PASS  F2 sensor: a person's unexplained breach is still refused; a sensor's is stored open
  PASS  F2 sensor: the stored breach is open and says it awaits a corrective action
  PASS  F2 sensor: a breach with no action makes the day red
  PASS  F2 sensor: a manager cannot verify a breach nobody has written an action for
  PASS  F2 sensor: a person records the action (name required), once, never overwritten
  PASS  F2 sensor: after the action is recorded, verification works as for any breach
  PASS  F2 sensor: the breach chase picks it up like any other

== Own It 3.0: wiring (static) ==
  PASS  Database::migrate() runs at boot
  PASS  install() converges with migrate(), so a fresh install is not a 2.0 schema
  PASS  POST and GET /mcp are both routed
  PASS  src includes are idempotent, so load order cannot redeclare a class
  PASS  the v3 audit TRAIL did not steal /audit from the food-safety audit pack
  PASS  every App::view() a controller renders resolves to a real file
  PASS  the audit PACK page still exists and is the pack, not the trail
  PASS  a switched-off person's API key stops working (u.active in requireKey)
  PASS  record_reading reuses parseTempToC + validateEntry and owns no rule of its own
  PASS  MCP writes go through the same event helper the browser paths use
  PASS  bulk verification loops the single-entry function inside one transaction
  PASS  the calendar feed is token-guarded and session-free
  PASS  every logging + config mutation is role-gated
  PASS  every logging + config mutation writes an audit entry
  PASS  button ink follows the branding contrast helper, not a hardcoded #fff
  PASS  the uploaded logo is actually RENDERED in the layout (not just stored)
  PASS  the branding accent overrides BOTH themes, not just :root
  PASS  src/Mcp.php is byte-identical to the shared core copy of Mcp.php
  PASS  src/AuditLog.php is byte-identical to the shared core copy of AuditLog.php
  PASS  src/Pdf.php is byte-identical to the shared core copy of Pdf.php
  PASS  src/BackupRestore.php is byte-identical to the shared core copy of BackupRestore.php
  PASS  src/Branding.php is byte-identical to the shared core copy of Branding.php
  PASS  src/RolesKit.php is byte-identical to the shared core copy of RolesKit.php
  PASS  src/Oidc.php is byte-identical to the shared core copy of Oidc.php
  PASS  src/Gdpr.php is byte-identical to the shared core copy of Gdpr.php
  PASS  src/Ical.php is byte-identical to the shared core copy of Ical.php
  PASS  every /api-settings handler is role-gated, not merely logged-in
  PASS  creating or deleting a webhook is ADMIN-only (it is an off-box data feed)
  PASS  revoking an API key is ADMIN-only (it takes every integration down)
  PASS  the settings page itself is role-gated, not merely logged-in
  PASS  the nav never links a role to a page it will be refused
  PASS  every /api/* WRITE route role-checks the key, not just its existence
  PASS  the roles map keeps admin able to administer, and keeps settings.manage away from the rest
  PASS  an MCP tool refusal THROWS McpRefusal — never a result the agent reads as success
  PASS  wiping the register is admin-only, not merely logged-in
  PASS  sw.js cache is static-v4.1.3
  PASS  healthz + api_ping + openapi report 4.1.3
  PASS  API.md documents the MCP endpoint
  PASS  README documents "New in 3.0"
  PASS  the deploy kit is filled in
  PASS  nav: every sidebar link is highlighted by the page it opens
  PASS  nav: no page highlights a sidebar item that does not exist
  PASS  ip: a direct visitor's forged headers are ignored
  PASS  ip: behind a trusted proxy the forwarded visitor is used
  PASS  ip: CF-Connecting-IP wins over the X-Forwarded-For chain
  PASS  ip: the chain is read right-to-left, past trusted hops
  PASS  ip: a Cloudflare edge is not a trusted proxy by default
  PASS  ip: CIDR maths (v4 boundaries + v6, no cross-family match)

== UI kit: recording a scheduled check from the day board ==
  PASS  day-board: a good fridge reading is in range (engine, not the endpoint, decides)
  PASS  day-board: a warm fridge reading is out of range
  PASS  day-board: an unparseable temperature is null, which the endpoint refuses (got NULL, want NULL)
  PASS  day-board: an out-of-range reading with NO corrective action is refused, in the engine's own words
  PASS  day-board: the same reading WITH an action is accepted
  PASS  day-board: an in-range reading needs no action
  PASS  day-board: POST /day/{date}/check is registered
  PASS  day-board: the endpoint requires logs.create and checks CSRF
  PASS  day-board: it runs the three engine calls rather than re-deciding safety itself
  PASS  day-board: a refusal returns 422 and tells the board to ask for a corrective action
  PASS  day-board: an unknown date or unit is refused (404), never logged against nothing
  PASS  day-board: the saved entry is audited BY ID (the browser paths used to write a null)
  PASS  kit: layout loads kit css+js, groups the nav with icons + g-keys, carries the theme toggle and a who-line
  PASS  kit: the open-corrective-action count rides the sidebar on every page, not just where a controller passed it
  PASS  kit: no theme.js; app css is an app layer with a dark accent; the pre-kit file is gone
  PASS  kit: the printed diary still forces a light palette (an EHO prints this page)
  PASS  kit: App::asset() versions by mtime and the service worker revalidates code
  PASS  kit: nothing is fetched from another host
  PASS  dashboard: the first-run checklist is computed from the database, not the session
  PASS  dashboard: KPI sparkline and needs-your-action (open actions, due checks, unsigned days) are wired
  PASS  hero: the day board lists every scheduled unit with its safe range and an in-place record form
  PASS  hero: the rail carries the manager sign-off and the inspector export
  PASS  copy: no other product's vocabulary survives in the views (this app is a HACCP logbook)

== 23. v3.1 — the breach nobody closed (chase engine) ==
  PASS  chase: an unverified breach older than the grace period is due
  PASS  chase: due exactly ON the grace boundary, not a minute later
  PASS  chase: the subject names the count and the business
  PASS  chase: the body carries the reading, who recorded it, the action written at the time, hours open and a link
  PASS  chase: a breach saved with NO action written says so in capitals — that is the worst row on the page
  PASS  chase: the mail carries nothing secret — no token, password, key or hash reaches the manager
  PASS  chase: °F businesses get °F in the email, from the same converter the screen uses
  PASS  chase refuses: inside the grace window, nothing is due
  PASS  chase refuses: a VERIFIED action is never chased — closing it is what stops the email
  PASS  chase refuses: an in-range reading is not a breach
  PASS  chase refuses: an entry with no recorded_at is skipped, never chased on a guessed time
  PASS  chase refuses: the toggle is checked FIRST — off means no query, no mail, no stamp
  PASS  chase refuses: no SMTP host or no address is a quiet no-op, not a 500 in somebody's cron
  PASS  chase permission: only a role allowed to verify actions may fire the manual send
  PASS  chase permission: the manual route requires login + permission + CSRF, in that order
  PASS  chase permission: the cron door takes no session — it is a hashed token compared with hash_equals
  PASS  chase permission: minting the cron token is settings-level, and only the hash is stored
  PASS  chase idempotency: an entry already stamped is never due again
  PASS  chase idempotency: the SELECT excludes anything already stamped
  PASS  chase idempotency: the UPDATE re-checks the stamp, so two runs racing cannot both send
  PASS  chase idempotency: rows are CLAIMED before the mail is sent — a crash costs one email, never a loop
  PASS  chase: mail failure can never break the request — the family emitter swallows it
  PASS  chase: the toggle ships OFF and the grace period defaults to 4 hours
  PASS  chase: all three doors are routed, and the literal ones cannot be swallowed by /actions/{id}/verify

== 24. v3.1 — a closed day is not a missing record ==
  PASS  closed: a shut kitchen with no records reads CLOSED, not red (got 'closed', want 'closed')
  PASS  closed: the same day NOT marked closed is still red — the mark is what changes it (got 'red', want 'red')
  PASS  closed: label + rank exist, and closed never ranks as a problem (got array (
  0 => 'Closed',
  1 => 0,
), want array (
  0 => 'Closed',
  1 => 0,
))
  PASS  closed refuses to hide an unresolved breach — no corrective action is still red (got 'red', want 'red')
  PASS  closed refuses to hide a corrected breach either — it is amber, not "closed" (got 'amber', want 'amber')
  PASS  closed: work that WAS recorded on a closed day still reads green, not swept away (got 'green', want 'green')
  PASS  closed: the sign-off refuses "closed" with no reason — an unexplained closure explains nothing
  PASS  closed permission: marking a day closed rides the existing day.signoff gate + CSRF
  PASS  closed idempotency: sign_date is UNIQUE and a second sign-off UPDATEs rather than inserting a twin
  PASS  closed idempotency: unticking the box on a re-sign clears the flag (it is written every time, never OR-ed)
  PASS  closed: every status path reads the flag — the calendar, the audit pack and the day agree

== 25. v3.1 — England & Wales bank holidays (offered, never assumed) ==
  PASS  Easter Sunday 2024 (got '2024-03-31', want '2024-03-31')
  PASS  Easter Sunday 2025 (got '2025-04-20', want '2025-04-20')
  PASS  Easter Sunday 2026 (got '2026-04-05', want '2026-04-05')
  PASS  Easter Sunday 2027 (got '2027-03-28', want '2027-03-28')
  PASS  2026 bank holidays match the published England & Wales list exactly (got array (
  0 => '2026-01-01',
  1 => '2026-04-03',
  2 => '2026-04-06',
  3 => '2026-05-04',
  4 => '2026-05-25',
  5 => '2026-08-31',
  6 => '2026-12-25',
  7 => '2026-12-28',
), want array (
  0 => '2026-01-01',
  1 => '2026-04-03',
  2 => '2026-04-06',
  3 => '2026-05-04',
  4 => '2026-05-25',
  5 => '2026-08-31',
  6 => '2026-12-25',
  7 => '2026-12-28',
))
  PASS  2027 too — Christmas on a Saturday moves BOTH days to the 27th and 28th (got array (
  0 => '2027-01-01',
  1 => '2027-03-26',
  2 => '2027-03-29',
  3 => '2027-05-03',
  4 => '2027-05-31',
  5 => '2027-08-30',
  6 => '2027-12-27',
  7 => '2027-12-28',
), want array (
  0 => '2027-01-01',
  1 => '2027-03-26',
  2 => '2027-03-29',
  3 => '2027-05-03',
  4 => '2027-05-31',
  5 => '2027-08-30',
  6 => '2027-12-27',
  7 => '2027-12-28',
))
  PASS  a weekend Boxing Day is listed as its SUBSTITUTE, the way the official list does it (got 'Boxing Day (substitute day)', want 'Boxing Day (substitute day)')
  PASS  an ordinary Tuesday is not a bank holiday (got NULL, want NULL)
  PASS  Good Friday 2026 is named (got 'Good Friday', want 'Good Friday')
  PASS  a malformed date is null, never a guess (got NULL, want NULL)
  PASS  the bank-holiday prompt is labeled England & Wales wherever it appears, and never auto-closes a day
  PASS  the calculator states out loud what it cannot know (one-off royal holidays, Scotland, NI)
  PASS  every empty-state icon a view asks for is one the kit actually draws
  PASS  no .alert stacks multiple element children straight into the kit's flex row
  PASS  no code concatenates Api::base() onto App::url() — that doubles the origin when base_url is set
  PASS  _sf_abs returns App::url() untouched when it is already absolute, and prefixes it when it is not
  PASS  every absolute-URL site now goes through the helper (invite, backup cron, SSO callback, calendar, breach cron, alert body)

== 26. v3.1 — the truth on the surfaces ==
  PASS  README changelog carries 3.1.1 and both features
  PASS  README no longer claims Safora only notifies at the moment of the reading
  PASS  the closed-day pill and calendar cell are styled — a new status with no CSS is an invisible one
  PASS  the audit pack prints the REASON beside a closed day, not just the word
  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

== settings-backed thresholds (2026-08-24: record, don't determine — no baked-in law) ==
  PASS  cooling: default 8C/90min still holds
  PASS  cooling: 100min fails on defaults
  PASS  cooling: settings widen the window (120min)
  PASS  cooling: settings tighten the target (5C)
  PASS  delivery: chilled limit comes from settings
  PASS  delivery: frozen limit comes from settings
  PASS  hot-hold minimum comes from settings
  PASS  no threshold comment claims legal character (they are defaults, operator-verifiable)
  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

== F2: fixes ==
  PASS  F2 security: backup_json is administrators only (a viewer could download the whole database)
  PASS  F2 security: backup_sqlite is administrators only (a viewer could download the whole database)
  PASS  F2 security: a viewer and a member cannot manage settings (so cannot download a backup)
  PASS  F2 sensor: POST /api/readings with source "sensor" stores the out-of-range reading (was a 422 with nothing written)
  PASS  F2 sensor: the record-action route needs the reading permission and a CSRF token
  PASS  F2 PHP: the installer refuses PHP older than 8.1 with a plain message, before anything else runs
  PASS  F2 PHP: the requirements no longer say PHP 8.0 or "PHP 8+"
  PASS  F2 security: a signed-in non-admin (viewer, member) gets 403 on every backup and restore route
  PASS  F2 security: the probe is real — an administrator is let through to the backup download
  PASS  F2 invite: with SMTP set up, the invitation is delivered over a real SMTP conversation, carrying the link
  PASS  F2 invite: with no SMTP it says so and sends nothing (the link is still shown once)
  PASS  F2 invite: a refusing mail server is reported, never thrown
  PASS  F2 invite: the invite handler emails it and still shows the link once
  PASS  F2 routes: every POST form in the views reaches a POST route (48 checked)
  PASS  F2 webhooks: the events offered in Settings (day.signed_off, reading.out_of_range, reading.recorded) are exactly the events the code sends
  PASS  F2b ribbon: Safora has no public page, so its pages keep the admin wording (/, /log)
  PASS  F2b ribbon: below 600px it starts as the small "Own it" pill; a tap opens it and that choice holds for the session
  PASS  F2b ribbon: injected once, just before </body>; output with no </body> (JSON, CSV, PDF) passes untouched
  PASS  F2b ribbon: boot() installs it only behind the .demo-mode marker
  PASS  F2b security: /backup/scheduled refuses a signed-in viewer, member with no token or a wrong one (403)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, settings.breach_alert_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, api_keys.token_hash, webhooks.secret, updater_state.license_api_key, backup_state.s3_secret, invites.token_hash)
  PASS  F2b backup: the JSON download carries none of them
  PASS  F2b backup: the scheduled backup carries none of them
  PASS  F2b backup: both backup doors use the one list, safora_backup_redact()

== 3.2.0 · the inspection folder: digest, training, documents, HACCP, allergens, traceability ==
  PASS  3.2.0 setup: an administrator, a member and a viewer on a fresh database
  PASS  digest: a day with nothing due, missed, open, expiring or recalled says nothing
  PASS  digest: off by default (like every other message Safora sends)
  PASS  digest: Settings switches it on and keeps the hour
  PASS  digest: with no mail server it says so and does not use up the day
  PASS  digest body: the checks due today, the document expiring within 14 days, the open recall, the diary link
  PASS  digest: not before the chosen hour (08:00 < 09:00)
  PASS  digest: at the hour it reaches the mail server, addressed to the notification address, with the day's list
  PASS  digest: once a day — the second page load or cron call sends nothing
  PASS  digest: a refused send is reported as refused, never as sent, and both land in the audit trail
  PASS  digest: the cron route refuses a wrong token (got 403, want 403)
  PASS  training: a topic needs a title
  PASS  training: a member cannot write the instructions (got 403, want 403)
  PASS  training: a member cannot sign off training (a manager does) (got 403, want 403)
  PASS  training: who, when, and by whom (defaults to the manager signed in)
  PASS  training: the page shows the instructions and who was trained; a viewer reads it with no forms
  PASS  documents: a real PDF is stored; the bytes decide (an executable named .pdf is refused); a bad expiry is refused
  PASS  documents: an unknown type is filed as other (got 'other', want 'other')
  PASS  documents: the file comes back byte for byte to anyone who can read the diary
  PASS  documents: the dashboard lists the expired one (red) and the one expiring within 30 days (amber)
  PASS  dashboard: "Needs your action" carries the documents and the open recall
  PASS  documents: a member cannot remove one (got 403, want 403)
  PASS  documents: …and the file is still there
  PASS  documents: removing one deletes the file and the row, and is audited
  PASS  documents: the upload form without a file says so
  PASS  HACCP: an empty plan offers the sample
  PASS  HACCP: the sample's six steps take their critical limits from THIS kitchen's safe ranges
  PASS  HACCP: the sample never overwrites a plan
  PASS  HACCP: saving replaces the plan in order, trimmed, and drops a step with no name
  PASS  HACCP: a save that fails halfway leaves the old plan whole (one transaction) (got array (
  0 => 'Goods in',
  1 => 'Cook',
), want array (
  0 => 'Goods in',
  1 => 'Cook',
))
  PASS  HACCP: a member cannot change the plan (got 403, want 403)
  PASS  HACCP: a viewer reads the plan as a table, with no form and no Edit (even asking for it)
  PASS  HACCP: a manager's Edit opens the form with every step and an empty one to add
  PASS  allergens: unknown codes are dropped, the rest kept in the matrix order (got 'celery,milk,sulphites', want 'celery,milk,sulphites')
  PASS  allergens: a dish stores what it contains and may contain
  PASS  allergens: the same dish name (any case) updates the dish instead of adding a second, conflicting row
  PASS  allergens: Edit loads the dish with its boxes ticked
  PASS  allergens: the printable matrix is a page of its own, with the kitchen's name and the 14 columns
  PASS  allergens: a member cannot change the matrix (got 403, want 403)
  PASS  trace: a delivery check records supplier, product, batch and use-by; a use-by that is not a date is left empty
  PASS  trace: a batch search (any case, part of it) finds the delivery with who checked it
  PASS  trace: %, _ and the escape character ! are searched as characters, not wildcards
  PASS  trace: a recall needs the product and the reason
  PASS  trace: a member cannot record a recall (got 403, want 403)
  PASS  trace: closing a recall records the day and what was done, once
  PASS  audit PDF: the pack ends with the inspection folder — plan, allergens, deliveries with batches, recalls, training
  PASS  API /api/trace: a read key finds the delivery by batch, with the recall log
  PASS  API /api/allergens: ?allergen=sesame gives only the dishes with it
  PASS  API /api/allergens: an unknown allergen is a 422 (got 422, want 422)
  PASS  API /api/trace: no key, no data (got 401, want 401)
  PASS  4c: "Load demo data" on an install with records refuses and says why
  PASS  demo data: a HACCP plan, five dishes, training records, two stored documents, one open recall, deliveries with batches
  PASS  demo data: the stored certificate is a real PDF that downloads
  PASS  demo data: the open recall's batch traces to the delivery that brought it in
  PASS  demo data: the dashboard shows the certificate expiring this month and the open recall
  PASS  "Reset all data" removes the inspection folder's records and the stored files with them
  PASS  demo data: the seed works where the demo reset runs it, before the controllers are loaded (got '6 no-controllers', want '6 no-controllers')
  PASS  …and index.php loads the kitchen controller with require_once, so the seed having loaded it first is no redeclaration

== customer voice: the time a checklist run took ==
  PASS  pace: 8 s for 10 items is quick (under 2 s each); 2 min 5 s is not; under 3 items is never flagged; unknown time says nothing (got array (
  0 => 
  array (
    'text' => 'ticked in 8 s for 10 items',
    'quick' => true,
  ),
  1 => 'ticked in 2 min 5 s for 10 items',
  2 => false,
  3 => false,
  4 => true,
), want array (
  0 => 
  array (
    'text' => 'ticked in 8 s for 10 items',
    'quick' => true,
  ),
  1 => 'ticked in 2 min 5 s for 10 items',
  2 => false,
  3 => false,
  4 => true,
))
  PASS  a run saved 5 s after the page opened records it, and the checklist page and the day view mark six items in 5 s as quick
  PASS  re-saved after 5 minutes: the latest time shows and nothing is flagged
  PASS  saved without the page opened in this session: the time is unknown, stored as nothing and not shown
  PASS  opening the checklist page starts the clock
  PASS  3.2.0 checks leave nothing behind in data/uploads
  PASS  import dry run: a row whose minimum is above its maximum is counted as skipped, not ready, and nothing is written (ready 1, skipped 1)

————————————————————————————————————————————————————

== 4.0: the Update button is wired ==
  PASS  updates: the release public key is pinned
  PASS  updates: Database::migrate() creates the updater table (in code, not in a comment)
  SKIP  updates: the state row (this suite keeps no open database in its own process; update_e2e.sh installs, migrates and updates for real)
  PASS  updates: index.php clears stale compiled code before anything else loads
  PASS  updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
  PASS  updates: route get /updates is updates_page()
  PASS  updates: route post /updates/key is updates_key_save()
  PASS  updates: route post /updates/check is updates_check()
  PASS  updates: route post /updates/apply is updates_apply()
  PASS  updates: updates_page() opens with the restore page's admin gate
  PASS  updates: updates_key_save() opens with the restore page's admin gate
  PASS  updates: updates_check() opens with the restore page's admin gate
  PASS  updates: updates_apply() opens with the restore page's admin gate
  PASS  updates: every POST checks the CSRF token
  PASS  updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
  PASS  updates: the menu links to the page
  PASS  updates: bin/update.php refuses the web and finishes in a fresh process
  PASS  updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
  PASS  updates: with a key and a newer signed release the page offers "Back up and update"

== 4.0 A2: sample data, backups, health ==
  PASS  A2: the owner's-install checks ran to the end
  PASS  A2: an owner's own install (no Cloud config, no demo marker)
  PASS  A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
  PASS  A2: switched off: an install without "Start with sample data" gets none
  PASS  A2: first request: the marker is used once and the sample business is there
  PASS  A2: every one of the owner's settings is theirs, not the sample's
  PASS  A2: the sample logins cannot sign in: no password matches, and switched off where the product can
  PASS  A2: the owner's own login is untouched
  PASS  A2: the load is in the audit log
  PASS  A2: a second marker on a Safora 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 (2 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 Safora 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: under the standard keep count (14) every backup is 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 Safora, 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)
ALL 493 TESTS PASSED (0 failed)

← Back to Safora · Manual · Quickstart · API