Ownware
Home›Safora›Test run
Safora · Test run · run 2026-09-25

Safora Test run, as shipped in the download

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

== 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 = neutralised (got '\'=1+2', want '\'=1+2')
  PASS  formula + neutralised (got '\'+1', want '\'+1')
  PASS  formula - with text neutralised (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 @ neutralised (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  leading TAB neutralised (got '\'	x', want '\'	x')
  PASS  cmd payload neutralised (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, in order
  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-v3.1.6
  PASS  healthz + api_ping + openapi report 3.1.6
  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 labelled 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  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

== 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 (31 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, 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()

————————————————————————————————————————————————————
ALL 358 TESTS PASSED (0 failed)

← Back to Safora · 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 →