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