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

Menura Test run, as shipped in the download

327 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. moneyToCents: US, EU, symbols, negatives, raw numbers ==
  PASS  US "$1,234.56" (got 123456, want 123456)
  PASS  EU "1.234,56 €" (got 123456, want 123456)
  PASS  plain "1234.5" (got 123450, want 123450)
  PASS  thousands "1,234" (got 123400, want 123400)
  PASS  EU decimal "12,34" (got 1234, want 1234)
  PASS  "0.99" (got 99, want 99)
  PASS  "$0" (got 0, want 0)
  PASS  bare "9" (got 900, want 900)
  PASS  "$12.50" (got 1250, want 1250)
  PASS  "8,50 €" EU (got 850, want 850)
  PASS  parens negative "(45.00)" (got -4500, want -4500)
  PASS  minus negative "-45.00" (got -4500, want -4500)
  PASS  float 1234.56 (got 123456, want 123456)
  PASS  int 1234 (major units) (got 123400, want 123400)
  PASS  "USD 89.90" (got 8990, want 8990)
  PASS  space thousands "1 234,56" (got 123456, want 123456)
  PASS  US big "1,234,567.89" (got 123456789, want 123456789)
  PASS  EU "€1.000,00" (got 100000, want 100000)
  PASS  empty -> null (got NULL, want NULL)
  PASS  "Market Price" -> null (got NULL, want NULL)
  PASS  "MP" -> null (got NULL, want NULL)
  PASS  em-dash -> null (got NULL, want NULL)
  PASS  null -> null (got NULL, want NULL)
  PASS  array -> null (got NULL, want NULL)

== 2. centsToDecimal round-trips ==
  PASS  123456 -> "1234.56" (got '1234.56', want '1234.56')
  PASS  5 -> "0.05" (got '0.05', want '0.05')
  PASS  1250 -> "12.50" (got '12.50', want '12.50')
  PASS  900 -> "9.00" (got '9.00', want '9.00')
  PASS  -4500 -> "-45.00" (got '-45.00', want '-45.00')
  PASS  0 -> "0.00" (got '0.00', want '0.00')
  PASS  null -> "" (got '', want '')

== 3. normalizeDate: ISO, textual, numeric, ambiguity, invalid ==
  PASS  ISO passthrough (got '2026-04-03', want '2026-04-03')
  PASS  US m/d/Y (default) (got '2026-04-03', want '2026-04-03')
  PASS  d/m/Y with dayFirst (got '2026-03-04', want '2026-03-04')
  PASS  day>12 disambiguates (got '2026-04-13', want '2026-04-13')
  PASS  EU dotted 03.04.2026 (got '2026-03-04', want '2026-03-04')
  PASS  textual "Apr 3, 2026" (got '2026-04-03', want '2026-04-03')
  PASS  year-first 2026/04/03 (got '2026-04-03', want '2026-04-03')
  PASS  impossible -> null (got NULL, want NULL)
  PASS  garbage -> null (got NULL, want NULL)
  PASS  empty -> null (got NULL, want NULL)

== 4. currencyCode from code or symbol ==
  PASS  code "usd" (got 'USD', want 'USD')
  PASS  symbol "€" (got 'EUR', want 'EUR')
  PASS  symbol "£" (got 'GBP', want 'GBP')
  PASS  empty -> "" (got '', want '')

== 5. normalizeList: dedupe, stable order, clip, cap ==
  PASS  string split comma (got array (
  0 => 'gluten',
  1 => 'dairy',
  2 => 'egg',
), want array (
  0 => 'gluten',
  1 => 'dairy',
  2 => 'egg',
))
  PASS  semicolon/pipe/newline (got array (
  0 => 'a',
  1 => 'b',
  2 => 'c',
  3 => 'd',
), want array (
  0 => 'a',
  1 => 'b',
  2 => 'c',
  3 => 'd',
))
  PASS  dedupe case-insensitive (first wins) (got array (
  0 => 'Vegan',
), want array (
  0 => 'Vegan',
))
  PASS  trims + collapses spaces (got array (
  0 => 'spicy hot',
), want array (
  0 => 'spicy hot',
))
  PASS  array of {name:..} objects (got array (
  0 => 'nuts',
  1 => 'soy',
), want array (
  0 => 'nuts',
  1 => 'soy',
))
  PASS  drops blanks (got array (
  0 => 'a',
  1 => 'b',
), want array (
  0 => 'a',
  1 => 'b',
))
  PASS  null -> [] (got array (
), want array (
))
  PASS  empty string -> [] (got array (
), want array (
))
  PASS  element clipped to 190 (got 190, want 190)
  PASS  list capped at 50 (got 50, want 50)

== 6. normalizeMenu: nested shape ==
  PASS  menu name (got 'Bella Cucina', want 'Bella Cucina')
  PASS  item count (got 3, want 3)
  PASS  item 0 section (got 'Antipasti', want 'Antipasti')
  PASS  item 0 name (got 'Bruschetta', want 'Bruschetta')
  PASS  item 0 price cents (got 950, want 950)
  PASS  item 0 modifiers (got array (
  0 => 'Add cheese +3.00',
), want array (
  0 => 'Add cheese +3.00',
))
  PASS  item 0 allergens (got array (
  0 => 'gluten',
), want array (
  0 => 'gluten',
))
  PASS  item 0 dietary (got array (
  0 => 'vegan',
), want array (
  0 => 'vegan',
))
  PASS  item 0 sort (got 0, want 0)
  PASS  item 1 name (got 'Calamari', want 'Calamari')
  PASS  item 1 allergens (got array (
  0 => 'gluten',
  1 => 'shellfish',
), want array (
  0 => 'gluten',
  1 => 'shellfish',
))
  PASS  item 2 section (dishes key) (got 'Secondi', want 'Secondi')
  PASS  item 2 name (dish key) (got 'T-Bone', want 'T-Bone')
  PASS  item 2 price null (Market Price) (got NULL, want NULL)
  PASS  item 2 dietary (dietary key) (got array (
  0 => 'gluten-free',
), want array (
  0 => 'gluten-free',
))

== 7. normalizeMenu: flat shape + dedupe + empty-row drop ==
  PASS  flat menu name (got 'Corner Cafe', want 'Corner Cafe')
  PASS  flat dedupe+drop -> 2 items (got 2, want 2)
  PASS  flat item 0 section (category) (got 'Mains', want 'Mains')
  PASS  flat item 0 price (cost key) (got 1200, want 1200)
  PASS  flat item 1 name (item key) (got 'Fries', want 'Fries')
  PASS  flat item 1 section (got 'Sides', want 'Sides')
  PASS  flat item 1 price (got 450, want 450)
  PASS  empty raw -> no items (got array (
), want array (
))
  PASS  empty raw -> blank name (got '', want '')

== 8. normalizeMenu: clip/clamp overflow safety (MySQL strict-mode) ==
  PASS  name clipped to 255 (got 255, want 255)
  PASS  description clipped to 2000 (got 2000, want 2000)
  PASS  section clipped to 190 (got 190, want 190)
  PASS  positive price clamped +2e9 (got 2000000000, want 2000000000)
  PASS  negative price clamped -2e9 (got -2000000000, want -2000000000)

== 9. groupSections: first-appearance order ==
  PASS  group count (got 2, want 2)
  PASS  first section (got 'Mains', want 'Mains')
  PASS  second section (got 'Starters', want 'Starters')
  PASS  mains has 2 items (got 2, want 2)

== 10. sku slug ==
  PASS  sku slug (got 'starters-garlic-bread', want 'starters-garlic-bread')
  PASS  sku strips punct (got 'caf-cr-me-br-l-e', want 'caf-cr-me-br-l-e')
  PASS  sku empty -> item (got 'item', want 'item')

== 11. CSV formula-injection guard (numeric-aware) ==
  PASS  =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  +-formula neutralized (non-numeric) (got '\'+cmd', want '\'+cmd')
  PASS  +1 is numeric -> KEPT (got '+1', want '+1')
  PASS  leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  leading-CR neutralized + quoted (got '"\'
X"', want '"\'
X"')
  PASS  -text neutralized (got '\'-abc', want '\'-abc')
  PASS  negative number KEPT (got '-45.00', want '-45.00')
  PASS  negative int KEPT (got '-9', want '-9')
  PASS  plain value untouched (got 'Garlic Bread', want 'Garlic Bread')
  PASS  quoting still applies to formula+comma (got '"\'=a,b"', want '"\'=a,b"')

== 12. toNestedCsv: shape + escaping + formula guard in real export ==
  PASS  nested header row (got 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary', want 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary')
  PASS  CRLF line endings
  PASS  formula item name neutralized in export
  PASS  negative price preserved unquoted in export
  PASS  list fields joined with "; "
  PASS  menu name with comma+quote is RFC-4180 quoted
  PASS  nested row cell count (got 8, want 8)

== 13. toPosCsv: flat shape + SKU + numeric price ==
  PASS  pos header row (got 'SKU,Name,Category,Price,Description', want 'SKU,Name,Category,Price,Description')
  PASS  pos has SKU slug
  PASS  pos negative price preserved
  PASS  pos row cell count (got 5, want 5)
  PASS  pos skips nameless items (got 1, want 1)

== 14. toJson: shape, decodes back, decimals + cents ==
  PASS  valid JSON
  PASS  menu key (got 'Menu, "One"', want 'Menu, "One"')
  PASS  sections is list (got 2, want 2)
  PASS  first section name (got 'Starters', want 'Starters')
  PASS  item name survives (got '=cmd', want '=cmd')
  PASS  price as decimal (got '9.50', want '9.50')
  PASS  price_cents as int (got 950, want 950)
  PASS  modifiers array (got array (
  0 => 'Add cheese',
), want array (
  0 => 'Add cheese',
))
  PASS  allergens array (got array (
  0 => 'gluten',
  1 => 'dairy',
), want array (
  0 => 'gluten',
  1 => 'dairy',
))
  PASS  dietary_flags array (got array (
  0 => 'vegan',
), want array (
  0 => 'vegan',
))
  PASS  negative price decimal (got '-45.00', want '-45.00')

== 15. Security hardening regressions ==
  PASS  dashboard month query is engine-portable (no SQLite datetime())
  PASS  settings_save blocks writes in public demo mode
  PASS  demo extract short-circuits to fixtures BEFORE reading base_url/settings
  PASS  file_serve sends X-Content-Type-Options: nosniff
  PASS  upload enforces finfo MIME allowlist
  PASS  upload caps file size
  PASS  upload stores under a random name
  PASS  csrf_check rejects empty stored token (hash_equals empty-token bypass)
  PASS  installer rejects empty install token (_it bypass)
  PASS  LLM client never follows redirects (key cannot leak to redirect target)
  PASS  LLM key read from settings, never echoed/logged
  PASS  demo session cookie is None + Secure + Partitioned
  PASS  buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
  PASS  portable UTC timestamps (no SQLite datetime/CURRENT_TIMESTAMP in inserts)
  PASS  settings view never echoes the stored API key value
  PASS  every string clip + money clamp helpers exist

== LLM truncation guard ==
  PASS  truncated (openai/ollama length) -> clear error
  PASS  truncated (anthropic max_tokens) -> same error
  PASS  error names the 8192-token ceiling
  PASS  error tells the user what to DO (split the menu)
  PASS  normal stop -> no error
  PASS  end_turn -> no error
  PASS  missing reason -> no error
  PASS  all three providers carry the raised cap (no stray literal)
  PASS  every provider path guards truncation before parsing

== 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/v3: tables
  PASS  v2/v3: totp_cols
  PASS  v2/v3: smtp_cols
  PASS  v2/v3: mint
  PASS  v2/v3: revoke
  PASS  v2/v3: totp
  PASS  v2/v3: recovery
  PASS  v2/v3: delivery
  PASS  v2/v3: events
  PASS  v2/v3: rate
  PASS  v2/v3: backup_tables
  PASS  v2/v3: item_json_decoded
  PASS  v2/v3: item_null_price
  PASS  v2/v3: menu_counts
  PASS  v2/v3: price_parser
  PASS  v2/v3: pos_csv
  PASS  v2/v3: json_export
  PASS  v2/v3: import_dry_run
  PASS  v2/v3: import_dry_reasons
  PASS  v2/v3: serializers_clean
  PASS  v2/v3: v3_tables
  PASS  v2/v3: audit_redacts
  PASS  v2/v3: price_percent
  PASS  v2/v3: price_charm99
  PASS  v2/v3: price_absolute
  PASS  v2/v3: price_round50
  PASS  v2/v3: price_null_stays_null
  PASS  v2/v3: price_never_negative
  PASS  v2/v3: retired_model_migrated
  PASS  v2/v3: custom_model_left_alone
  PASS  v2/v3: other_provider_left_alone
  PASS  v2/v3: qr_unpublished_is_not_served
  PASS  v2/v3: qr_publish_mints_a_token
  PASS  v2/v3: qr_unpublish_takes_it_offline
  PASS  v2/v3: qr_republish_keeps_the_printed_code
  PASS  v2/v3: qr_hidden_survives_a_review_save
  PASS  v2/v3: qr_86_hides_from_guests
  PASS  v2/v3: qr_guest_page_renders_the_menu
  PASS  v2/v3: qr_back_on_in_one_click
  PASS  v2/v3: qr_offline_page_says_so
  PASS  v2/v3: qr_exports_keep_86d_items
  PASS  v2/v3: review_keeps_item_ids
  PASS  v2/v3: review_fires_item_updated_for_a_changed_row_only
  PASS  v2/v3: review_keeps_86_and_adds_and_removes
  PASS  v2/v3: seed_buyer_publishes_nothing
  PASS  v2/v3: seed_demo_publishes_one_qr_menu
  PASS  v2/v3: seed_demo_86s_one_dish
  PASS  v2: routes registered (api, 2fa, security, backup, import, healthz)
  PASS  v2: migrate() runs on boot
  PASS  v2: cli-server base guard
  PASS  v2: login is rate-limited and 2FA intercepts it
  PASS  v2: both extraction paths announce menu.processed
  PASS  v2: exports reuse the product writers (no second CSV writer)
  PASS  v2: the CSV import reuses the app price parser
  PASS  v2: dry run and commit share ONE validator
  PASS  v3: item.updated carries before AND after (from the shared edit rule)
  PASS  v3: exactly ONE item-edit rule, used by the API and MCP
  PASS  v2: notifications never break the request
  PASS  v2: upload staying UI-only is documented in the code
  PASS  v2: no undefined-key ternary re-reads in any controller
  PASS  v2: endpoints taking nested input allowlist their keys in Api::body()
  PASS  v2: dark palette + zero hardcoded white backgrounds
  PASS  v2: dark mode keeps ghost/danger buttons legible (kit truth: the kit paints them from theme tokens, and the branding override skips them)
  PASS  v2: theme.js + qr.js ship and the layout loads theme.js with a toggle
  PASS  v2: shared cores are present
  PASS  v2: .htaccess re-allows the /backup.sqlite ROUTE while denying real .sqlite files
  PASS  v2: dev router denies .sqlite FILES but not the backup route
  PASS  v3: API.md ships and the sw cache is v3.1.6
  PASS  v3: version is 3.1.6 everywhere
  PASS  v2: README is Menura's own, not another product's
  PASS  v3: no call to a function this product does not define
  PASS  C2: no sensitive handler is login-only — all are role-gated
  PASS  C2: both backup doors call the ONE shared redaction list
  PASS  C2: Api::requireKey refuses a deactivated user (u.active)
  PASS  C2: every App:: method the controllers call actually exists
  PASS  C2: Api::base() does not double an already-absolute base
  PASS  C2: allowlisted arrays reject a nested element
  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: live POS preview + the kit layout ==
  PASS  pos preview: one line per named item (got 3, want 3)
  PASS  pos preview: SKU is the exporter's own slug (got 'starters-garlic-bread', want 'starters-garlic-bread')
  PASS  pos preview: price is the exporter's decimal (got '28.50', want '28.50')
  PASS  pos preview: a clean menu raises no issues (got array (
), want array (
))
  PASS  pos preview: every previewed SKU appears in the real POS CSV export (preview and export cannot drift)
  PASS  pos preview: a nameless row never reaches the till (got 3, want 3)
  PASS  pos preview: the dropped row is counted, not hidden (got 1, want 1)
  PASS  pos preview: dropped rows and duplicate SKUs are hard (bad) issues
  PASS  pos preview: a missing price and a missing section are warnings, not blockers
  PASS  pos preview: an empty editor previews nothing and complains about nothing (got array (
  'rows' => 
  array (
  ),
  'issues' => 
  array (
  ),
  'dropped' => 0,
), want array (
  'rows' => 
  array (
  ),
  'issues' => 
  array (
  ),
  'dropped' => 0,
))
  PASS  pos-preview: a missing menu answers 404 via App::json($data, 404) — a bare http_response_code() before App::json() is overwritten back to 200 (found 2026-08-20)
  PASS  posted rows: the blank row is skipped (got 2, want 2)
  PASS  posted rows: money goes through the same parser as a hand edit (got 2850, want 2850)
  PASS  pos preview: POST /menus/{id}/pos-preview is registered
  PASS  pos preview: the endpoint requires menu.export, checks CSRF and 404s an unknown menu (via App::json(...,404) — the status arg, not a bare http_response_code that App::json overwrites)
  PASS  pos preview: it never writes — no INSERT/UPDATE/DELETE inside the endpoint body
  PASS  kit: layout loads kit css+js, groups the nav with icons + g-keys, carries the theme toggle and a who-line
  PASS  kit: no theme.js; app css is an app layer with a dark accent; the pre-kit file is gone
  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, needs-your-action and the section strip are wired
  PASS  hero: sticky source viewer ⇄ item editor with the live POS pane wired to the endpoint
  PASS  menus list: tabs carry counts and rows link to the record
  PASS  copy: no other product's vocabulary survives in the views (this app digitizes menus)

== v3.1: the documented export columns ARE the exported columns ==
  PASS  preset: the POS CSV header is the one the README documents (got 'SKU,Name,Category,Price,Description', want 'SKU,Name,Category,Price,Description')
  PASS  preset: the nested CSV header is the one the README documents (got 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary', want 'Menu,Section,Item,Description,Price,Modifiers,Allergens,Dietary')
  PASS  preset: the README states the POS columns verbatim (docs cannot drift from the exporter)
  PASS  preset: the README states the nested columns verbatim
  PASS  preset: prices export as bare decimals, never with a currency symbol (a till rejects "£6.50")
  PASS  preset: the SKU is stable across exports — a re-import updates, it does not duplicate
  PASS  preset: the product still claims NO POS API (the kept limitation is not quietly dropped)
  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
  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: the settings page never echoes the stored SMTP password into its HTML
  PASS  F2 security: a blank SMTP password keeps the stored one; only the remove box clears it
  PASS  F2 model: the Anthropic default is no longer the retired claude-3-haiku-20240307
  PASS  F2 model: a stored retired id is read as the current default (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  F2 model: a blank model is the default (got 'gpt-4o-mini', want 'gpt-4o-mini')
  PASS  F2 model: an owner-typed current model is used as typed (got 'claude-opus-5-5', want 'claude-opus-5-5')
  PASS  F2 model: Anthropic refusing the model fails with the model name and the setting to change
  PASS  F2 model: OpenAI model_not_found is recognised
  PASS  F2 model: Ollama "model not found" is recognised and says to pull it
  PASS  F2 model: an auth error is not mistaken for a model refusal
  PASS  F2 PDF: OpenAI receives a PDF as a file part (file_data), not as an image_url
  PASS  F2 PDF: OpenAI still receives photos as image_url
  PASS  F2 PDF: Ollama refuses a PDF before any call, saying to convert the pages
  PASS  F2 model: no screen, default or doc offers a retired model
  PASS  F2 model: installer, settings page and save handler read the one default (Llm::DEFAULT_MODELS)
  PASS  F2 QR: staff page, publish, 86 toggle and the guest page are all routed
  PASS  F2 QR: qr_menu_publish needs item.edit and a CSRF token
  PASS  F2 QR: qr_item_toggle needs item.edit and a CSRF token
  PASS  F2 QR: qr_menu_page needs menu.view
  PASS  F2 QR: the guest page needs no login and serves only a PUBLISHED menu by token
  PASS  F2 QR: every review row (and the add-row template) carries its 86 state, so a save keeps it
  PASS  F2 QR: a token is 24 hex characters and anything else is refused before a lookup
  PASS  F2 PDF menu: prices carry the currency the owner set (base_currency; the PDF read a setting that does not exist)
  PASS  F2 demo: the demo upload screen says a visitor's own file is not read (every upload returns a sample)
  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 (28 checked)
  PASS  F2 webhooks: the events offered in Settings (item.updated, menu.processed) are exactly the events the code sends
  PASS  F2b ribbon: the public page /m/0f3a9c2b7d1e4f5a6b8c9d0e says what it is ("this is the menu your guests open from the QR code on the table"), not "signed in as the admin"
  PASS  F2b ribbon: an admin page (/menus) keeps the admin wording
  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 (404)
  PASS  F2b backup: a value was planted and read back in every credential column (settings.llm_api_key, settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, 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, menura_backup_redact()

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

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