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