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

Packora Test run, as shipped in the download

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

[DB: SQLite]

== 1. grams_to_mg: exact decimal parse, no float drift ==
  PASS  "40" g = 40000 mg (got 40000, want 40000)
  PASS  "12.5" g = 12500 mg (got 12500, want 12500)
  PASS  "3.2" g = 3200 mg (got 3200, want 3200)
  PASS  "0.001" g = 1 mg (got 1, want 1)
  PASS  "1.2344" rounds down → 1234 (got 1234, want 1234)
  PASS  "1.2345" rounds half-up → 1235 (got 1235, want 1235)
  PASS  "0.1" g = 100 mg (classic float trap value, exact here) (got 100, want 100)
  PASS  whitespace tolerated (got 7250, want 7250)
  PASS  garbage → 0 (got 0, want 0)
  PASS  negative → 0 (got 0, want 0)
  PASS  empty → 0 (got 0, want 0)
  PASS  huge grams clamped to 2e9 mg (got 2000000000, want 2000000000)

== 2. mg → display: grams string + kg at 3dp (half-up, integer space) ==
  PASS  12500 mg → "12.5" g (got '12.5', want '12.5')
  PASS  40000 mg → "40" g (got '40', want '40')
  PASS  1 mg → "0.001" g (got '0.001', want '0.001')
  PASS  0 mg → "0" g (got '0', want '0')
  PASS  40,000,000 mg → "40.000" kg (got '40.000', want '40.000')
  PASS  12,500,000 mg → "12.500" kg (got '12.500', want '12.500')
  PASS  3,125,000 mg → "3.125" kg (got '3.125', want '3.125')
  PASS  1,234,499 mg → "1.234" kg (rounds down) (got '1.234', want '1.234')
  PASS  1,234,500 mg → "1.235" kg (half-up) (got '1.235', want '1.235')
  PASS  499 mg → "0.000" kg (got '0.000', want '0.000')
  PASS  500 mg → "0.001" kg (got '0.001', want '0.001')
  PASS  1,000,000,000 mg → "1,000.000" kg (thousands separator) (got '1,000.000', want '1,000.000')

== 3. Register::lineMg + feeCents: exact integer arithmetic ==
  PASS  40000 mg × 1000 units = 40,000,000 mg (got 40000000, want 40000000)
  PASS  12500 mg × 250 units = 3,125,000 mg (got 3125000, want 3125000)
  PASS  0 units → 0 (got 0, want 0)
  PASS  negative units floored → 0 (got 0, want 0)
  PASS  negative weight floored → 0 (got 0, want 0)
  PASS  40 kg × 12¢/kg = 480¢ (got 480, want 480)
  PASS  12.5 kg × 9¢/kg = 112.5¢ → 113¢ (half-up) (got 113, want 113)
  PASS  112.4¢ case rounds down (12.48889 kg × 9) (got 112, want 112)
  PASS  rate 0 → 0¢ (explicit zero is a claim) (got 0, want 0)
  PASS  BLANK rate → null, not 0 (no fake $0 estimate)
  PASS  0 mg with a rate → 0¢ (got 0, want 0)
  PASS  big-but-sane stays integer-exact: 2,000,000 kg × $2.00/kg (got 400000000, want 400000000)

== 4. Register::deadlineBadge vs a fixed 'today' ==
  PASS  19 days out → soon (≤60)
  PASS  6 days past → overdue, days = -6
  PASS  153 days out → ok
  PASS  exactly 60 days out → soon (boundary inclusive)
  PASS  due today → soon, days = 0
  PASS  empty date → none ("verify with your PRO")
  PASS  garbage date → none

== 5. Hand-computed register: 40g PET + 12.5g corrugated × 1,000 OR / 250 CO ==
  PASS  OR PET total = 40,000,000 mg exactly (got 40000000, want 40000000)
  PASS  OR PET display = "40.000" kg (got '40.000', want '40.000')
  PASS  OR corrugated total = 12,500,000 mg (got 12500000, want 12500000)
  PASS  OR corrugated display = "12.500" kg (got '12.500', want '12.500')
  PASS  OR recyclable split: PET fully recyclable-flagged (got 40000000, want 40000000)
  PASS  OR recyclable split: carton flag off → 0 (got 0, want 0)
  PASS  CO PET total = 10,000,000 mg (got 10000000, want 10000000)
  PASS  CO PET display = "10.000" kg (got '10.000', want '10.000')
  PASS  CO corrugated total = 3,125,000 mg (got 3125000, want 3125000)
  PASS  CO corrugated display = "3.125" kg (got '3.125', want '3.125')
  PASS  OR grand total mg = 52,500,000 (got 52500000, want 52500000)
  PASS  CO grand total mg = 13,125,000 (got 13125000, want 13125000)
  PASS  multi-SKU: OR PET aggregates to 41,000,000 mg (got 41000000, want 41000000)
  PASS  multi-SKU: OR PET display "41.000" kg (got '41.000', want '41.000')
  PASS  multi-SKU: corrugated untouched (got 12500000, want 12500000)
  PASS  CA (zero sales) → empty materialTotals
  PASS  statesWithSales(2026) = OR + CO only, CA absent
  PASS  wrong year → empty register
  PASS  OR detail has 3 SKU-component lines (got 3, want 3)
  PASS  detail line FIX-A/Clamshell = 40,000,000 mg (got 40000000, want 40000000)
  PASS  detail line FIX-A/Carton = 12,500,000 mg (got 12500000, want 12500000)
  PASS  detail line FIX-B/Blister = 1,000,000 mg (got 1000000, want 1000000)

== 6. Fee estimate: kg × operator rate = exact cents; blank rate → no estimate ==
  PASS  OR PET fee = 41 kg × 12¢ = 492¢ exact (got 492, want 492)
  PASS  OR corrugated: no rate → fee null (not 0)
  PASS  OR missing_rates counts the unrated material (got 1, want 1)
  PASS  OR total covers rated materials only = 492¢ (got 492, want 492)
  PASS  CO (no rates at all): total 0, all lines null (got 0, want 0)
  PASS  CO both lines null

== 7. dollars_to_cents_or_null: operator rate input parse ==
  PASS  blank → null (no rate, not $0)
  PASS  "0.12" → 12¢ (got 12, want 12)
  PASS  "1.2" → 120¢ (got 120, want 120)
  PASS  "$2.50" → 250¢ (got 250, want 250)
  PASS  "0" → 0¢ (explicit zero allowed) (got 0, want 0)
  PASS  "0.125" rounds half-up → 13¢ (got 13, want 13)
  PASS  garbage → 0 (never a crash) (got 0, want 0)
  PASS  huge clamped to 2e9¢ (got 2000000000, want 2000000000)

== 8. Unknown-SKU sales flagged (never dropped) + component-less SKUs ==
  PASS  one unmatched sales row detected (got 1, want 1)
  PASS  unmatched row keeps its code (got 'GHOST-1', want 'GHOST-1')
  PASS  unmatched row keeps its units (stored, not dropped) (got 777, want 777)
  PASS  register EXCLUDES unmatched units (PET unchanged) (got 41000000, want 41000000)
  PASS  after adding the SKU, unmatched list is empty
  PASS  its units now weigh in: film = 5g × 777 = 3,885,000 mg (got 3885000, want 3885000)
  PASS  component-less active SKU flagged
  PASS  SKUs with components not flagged

== 9. toCsv: RFC-4180 + numeric-aware formula guard ==
  PASS  header row from keys (got 'Sku,Product,Total Kg', want 'Sku,Product,Total Kg')
  PASS  quotes/commas escaped
  PASS  CRLF endings
  PASS  missing key → blank cell (got '', want '')
  PASS  = formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  + formula neutralized (got '\'+HYPERLINK("x")', want '\'+HYPERLINK("x")')
  PASS  @ formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  leading TAB neutralized (got '\'	Tabbed', want '\'	Tabbed')
  PASS  -DDE neutralized (got '\'-DDE', want '\'-DDE')
  PASS  negative int NOT corrupted (got '-5', want '-5')
  PASS  negative decimal NOT corrupted (got '-12.50', want '-12.50')
  PASS  numeric +7 NOT corrupted (got '+7', want '+7')
  PASS  date untouched (got '2026-06-30', want '2026-06-30')
  PASS  plain value untouched (got 'plain', want 'plain')

== 10. CSRF empty-token guard + clip/clamp helpers ==
  PASS  EMPTY stored token never validates (hash_equals("","") trap)
  PASS  empty stored vs non-empty given rejected
  PASS  matching tokens validate
  PASS  mismatched tokens rejected
  PASS  str_cap trims to width (got 190, want 190)
  PASS  str_cap multibyte-safe (got 100, want 100)
  PASS  str_cap leaves short strings (got 'CG-TENT-2P', want 'CG-TENT-2P')
  PASS  clamp_int caps 15-digit units (got 2000000000, want 2000000000)
  PASS  clamp_int caps negative overflow (got -2000000000, want -2000000000)
  PASS  clamp_int non-numeric → 0 (got 0, want 0)
  PASS  clamp_int trims + parses (got 42, want 42)
  PASS  clamp_year passthrough (got 2026, want 2026)
  PASS  clamp_year rejects 1899 → current year (got 2026, want 2026)
  PASS  clamp_year rejects garbage → current year (got 2026, want 2026)
  PASS  fmt_money integer cents (got '$1,234.56', want '$1,234.56')
  PASS  fmt_money sub-dollar (got '$0.05', want '$0.05')
  PASS  fmt_money negative (got '-$2.50', want '-$2.50')

== 11. Importer::importSkus — header mapping, upsert, adversarial input ==
  PASS  3 SKUs imported (got 3, want 3)
  PASS  0 skipped (got 0, want 0)
  PASS  name stored (got 'Alpha Product', want 'Alpha Product')
  PASS  category stored (got 'Drinkware', want 'Drinkware')
  PASS  active flag "inactive" → 0 (got 0, want 0)
  PASS  re-import updates, not inserts (got 1, want 1)
  PASS  re-import imported 0 (got 0, want 0)
  PASS  updated name landed (got 'Alpha Product v2', want 'Alpha Product v2')
  PASS  still exactly one IMP-A row (got 1, want 1)
  PASS  reordered/aliased headers import cleanly (got 1, want 1)
  PASS  reordered: category landed (got 'Hardware', want 'Hardware')
  PASS  good row imported (got 1, want 1)
  PASS  2 malformed skipped (got 2, want 2)
  PASS  skip reasons reference the line numbers
  PASS  adversarial row imported (clipped, not crashed) (got 1, want 1)
  PASS  oversize SKU clipped to 100 chars
  PASS  oversize name clipped to 190 (got 190, want 190)
  PASS  missing sku/name headers → 0 imported (got 0, want 0)
  PASS  header error reported

== 12. Importer::importSales — validation, unknown SKU flagged, upsert, clamping ==
  PASS  3 sales rows imported (got 3, want 3)
  PASS  0 unmatched (got 0, want 0)
  PASS  units landed (got 1200, want 1200)
  PASS  reordered headers: 1 good row (upsert-updated) (got 1, want 1)
  PASS  4 bad rows skipped (got 4, want 4)
  PASS  non-numeric units reason
  PASS  bad year reason
  PASS  unknown state reason
  PASS  negative units reason
  PASS  upsert replaced IMP-A OR 2026 units with 5 (got 5, want 5)
  PASS  still exactly one IMP-A × OR × 2026 row (got 1, want 1)
  PASS  unknown-SKU row imported (got 1, want 1)
  PASS  unknown-SKU row counted as unmatched (got 1, want 1)
  PASS  flag reason mentions the SKU list
  PASS  row really stored (got 8, want 8)
  PASS  and it surfaces in unmatchedSales()
  PASS  huge units row imported (got 1, want 1)
  PASS  huge units clamped to 2e9 (got 2000000000, want 2000000000)
  PASS  lowercase state code matches (got 1, want 1)

== 13. Auth + settings write-path clamps ==
  PASS  correct password authenticates
  PASS  session uid set on login
  PASS  wrong password rejected
  PASS  unknown email rejected
  PASS  password_hash is not plaintext
  PASS  company name clipped to 190 (got 190, want 190)
  PASS  contact name clipped to 120 (got 120, want 120)
  PASS  absurd year → current year (got 2026, want 2026)
  PASS  capped oversize SKU inserts without error
  PASS  stored capped name exactly 190 (got 190, want 190)
  PASS  oversize component weight clamps to 2e9 mg and inserts
  PASS  upsert refuses weight_g scientific notation ("1e3") instead of zero-filling
  PASS  upsert refuses an over-cap weight_mg (9e99) instead of overflow-to-zero
  PASS  upsert refuses an over-cap weight_g instead of the silent 2e9 clamp
  PASS  upsert still accepts a plain decimal exactly (18.4 g → 18400 mg)
  PASS  no zero-weight component was written by any refused probe
  PASS  v3: requireKey refuses a deactivated user at the SQL layer (u.active = 1)
  PASS  v3: no privileged route is left on the bare login guard
  PASS  v3: the admin can still administer (no perm gates the owner out)
  PASS  v3: no REST handler writes on the bare key guard
  PASS  v3: GET /mcp reaches the transport hint WITHOUT the key check in front of it
  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

----------------------------------------------------

== 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: deadline_latch_col
  PASS  v2: mint
  PASS  v2: revoke
  PASS  v2: totp
  PASS  v2: recovery
  PASS  v2: delivery
  PASS  v2: events
  PASS  v2: rate
  PASS  v2: backup_tables
  PASS  v2: register_totals
  PASS  v2: recyclable_split
  PASS  v2: detail_reconciles
  PASS  v2: fee_math
  PASS  v2: deadline_badge
  PASS  v2: components_dry_run
  PASS  v2: components_dry_reasons
  PASS  v2: components_commit_matches_dry_run
  PASS  v2: grams_to_mg_exact
  PASS  v2: components_reimport_updates
  PASS  v2: skus_dry_run
  PASS  v2: serializers_clean
  PASS  v2: routes registered (api, 2fa, security, backup, components import, healthz)
  PASS  v2: migrate() runs on boot (1.x installs gain the new tables)
  PASS  v2: cli-server base guard (extension-bearing routes work under php -S)
  PASS  v2: login is rate-limited and 2FA intercepts it
  PASS  v2: every importer is staged dry-run then explicit confirm
  PASS  v2: exporting a filing announces report.generated
  PASS  v2: the deadline alert rides a page load, latched so it cannot repeat
  PASS  v2: the API reuses Register:: (no second copy of the tonnage maths)
  PASS  v2: an unknown material is refused rather than invented
  PASS  v2: notifications never break the request
  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 carries its own accent with dark on-accent ink (kit tokens)
  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 still 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 (healthz, api_ping, openapi)

-- Own It 3.0 --
  PASS  v3: role_cols
  PASS  v3: v3_settings_cols
  PASS  v3: v3_tables
  PASS  v3: v3_columns_typed
  PASS  v3: upgrade_restores_v3
  PASS  v3: upgrade_keeps_admin
  PASS  v3: roles_viewer_readonly
  PASS  v3: roles_member_scope
  PASS  v3: roles_admin_all
  PASS  v3: last_admin_guard
  PASS  v3: audit_redacts_at_depth
  PASS  v3: audit_csv
  PASS  v3: audit_append_only
  PASS  v3: pdf_renders
  PASS  v3: ical_token
  PASS  v3: ical_feed
  PASS  v3: branding_refuses_svg
  PASS  v3: branding_accent
  PASS  v3: branding_ink_is_aa
  PASS  v3: restore_refuses_other_app
  PASS  v3: mcp_initialize
  PASS  v3: mcp_tools_list
  PASS  v3: mcp_call
  PASS  v3: mcp_scalar_guard
  PASS  v3: mcp_unknown_arg
  PASS  v3: mcp_allowlist
  PASS  v3: mcp_domain_refusal
  PASS  v3: mcp_get_405
  PASS  v3: readiness_names_gaps
  PASS  v3: readiness_not_zero_filled
  PASS  v3: fresh install · install_from_nothing
  PASS  v3: fresh install · schema_exists
  PASS  v3: fresh install · migrate_is_idempotent
  PASS  v3: fresh install · audit_table_after_boot
  PASS  v3: fresh install did not fatal
  PASS  v3: migrate() runs at boot, after the schemaExists guard
  PASS  v3: every core in src/ is REQUIRED by index.php (no autoloader here)
  PASS  v3: routes registered (mcp both verbs, audit, pdf, branding, ical, restore, users)
  PASS  v3: /skus/bulk is registered before /skus/{id}
  PASS  v3: MCP and the PDF reuse the register engine (no second tonnage maths)
  PASS  v3: upsert_sku goes through the SAME path as POST /api/skus
  PASS  v3: the shared upsert exists and the REST endpoint is a thin wrapper
  PASS  v3: every core is byte-identical to the shared core
  PASS  v3: Gdpr.php is deliberately absent (packora stores products, not people)
  PASS  v3: every UI mutation is audited and role-gated
  PASS  v3: branding accent reaches the page and the audit link is role-gated
  PASS  v3: README documents the 3.0 release
  PASS  v3: API.md documents the MCP surface
  PASS  v3: deploy kit ships

== UI kit v1.0 · report builder preview ==
  PASS  preview: the engine the preview calls still totals OR PET exactly (got 41000000, want 41000000)
  PASS  preview: an unsaved rate of 12¢/kg prices 41 kg at 492¢ (got 492, want 492)
  PASS  preview: a material with no rate stays null, never a fake $0
  PASS  preview: the endpoint prices through Register::feeCents, not its own maths
  PASS  preview: an unknown state is refused with 422
  PASS  preview: an out-of-range year is refused with 422
  PASS  preview: a negative rate is refused with 422
  PASS  preview: a non-numeric rate is refused rather than guessed
  PASS  preview: the endpoint never writes a rate row
  PASS  preview: requires a session + CSRF
  PASS  preview: route registered and the builder posts to it

== v3.1 · deadline reminders ==
  PASS  reminder: a normal ladder parses in descending order (got array (
  0 => 60,
  1 => 30,
  2 => 7,
), want array (
  0 => 60,
  1 => 30,
  2 => 7,
))
  PASS  reminder: whitespace, duplicates and stray separators survive (got array (
  0 => 60,
  1 => 30,
  2 => 7,
), want array (
  0 => 60,
  1 => 30,
  2 => 7,
))
  PASS  reminder: nonsense is dropped rather than stored (got array (
  0 => 14,
), want array (
  0 => 14,
))
  PASS  reminder: an empty ladder is empty, not a silent default (got array (
), want array (
))
  PASS  reminder: 65 days out is below every rung (got NULL, want NULL)
  PASS  reminder: 60 days out hits the first rung (got 60, want 60)
  PASS  reminder: 31 days out is still the 60 rung (got 60, want 60)
  PASS  reminder: 30 days out tightens to 30 (got 30, want 30)
  PASS  reminder: 7 days out tightens to 7 (got 7, want 7)
  PASS  reminder: overdue maps to rung 0 (got 0, want 0)
  PASS  reminder: a fresh deadline fires
  PASS  reminder: the same rung does not fire twice
  PASS  reminder: the ladder tightening fires again
  PASS  reminder: a re-run at the same rung stays silent
  PASS  reminder: going overdue fires the final rung
  PASS  reminder: an overdue state does not keep firing every page load
  PASS  reminder: a CHANGED deadline date starts the ladder over
  PASS  reminder: only valid addresses survive, case-insensitively deduplicated (got array (
  0 => 'Owner@Example.com',
  1 => 'consultant@example.com',
), want array (
  0 => 'Owner@Example.com',
  1 => 'consultant@example.com',
))
  PASS  reminder: no addresses at all means nobody is mailed (got array (
), want array (
))
  PASS  reminder: the toggle ships ON but sends nowhere until an address exists (got 1, want 1)
  PASS  reminder: the counterparty list ships EMPTY (got '', want '')
  PASS  reminder: an unknown toggle is treated as OFF — new mail never starts sending itself
  PASS  reminder: the body names the state, the date and the days left
  PASS  reminder: it says plainly that Packora does not file anything
  PASS  reminder: it leaks no credential or SMTP detail
  PASS  reminder: an overdue filing says how late it is and defers to the PRO
  PASS  reminder: the watcher can never break a page
  PASS  reminder: each rung is latched AFTER the attempt, so a dead mail server cannot loop it
  PASS  reminder: sends go through the guarded family sender, not a raw socket
  PASS  reminder: the ladder is re-parsed on save, so nonsense can never be stored
  PASS  reminder: settings are permission-gated + CSRF like every other write here
  PASS  reminder: the watcher reports the rung it fired (25 days out = the 30-day rung, one recipient)
  PASS  reminder: a second run (cron after a page load) sends nothing — one latch for both paths
  PASS  reminder: without SMTP the rung is recorded as a demo send, never counted as delivered
  PASS  reminder: a cron route runs the ladder, guarded by the backup token like the scheduled backup
  PASS  reminder: the settings page tells the owner how to schedule it
  PASS  filing: recording freezes the rows, their exact total and a SHA-256 of them
  PASS  filing: straight after recording, it matches what the register computes
  PASS  filing: after a packaging weight changes, the recording still holds what was filed
  PASS  filing: ...and the report page is told the register now computes a different total
  PASS  filing: only an administrator can record one (viewer and member cannot)
  PASS  filing: routes registered; recording is permission-gated and CSRF-checked
  PASS  filing: the exports and the recording build their rows in one place
  PASS  sparkline: years run oldest first and end at the year shown
  PASS  sparkline: the last two points are last year and this year (300 units vs 100 → exactly 3×)
  PASS  sparkline: a year with no sales still ends the series, at zero
  PASS  sparkline: the dashboard draws the yearly series, not one point per state
  PASS  backup guard: the harness made one signed-in account per role (viewer,member,admin)
  PASS  backup guard: GET /backup.json — 403 for every non-admin role, open to admin
  PASS  backup guard: GET /backup.sqlite — 403 for every non-admin role, open to admin
  PASS  backup guard: GET /restore — 403 for every non-admin role, open to admin
  PASS  backup guard: POST /restore/upload — 403 for every non-admin role, open to admin
  PASS  backup guard: POST /restore/commit — 403 for every non-admin role, open to admin
  PASS  backup guard: POST /restore/token — 403 for every non-admin role, open to admin
  PASS  backup guard: GET /backup/scheduled without its token refuses every signed-in role, admin included
  PASS  backup guard: GET /reminders/run without its token refuses every signed-in role, admin included
  PASS  webhooks: "Add" reaches its route and saves the endpoint (was a 404)
  PASS  webhooks: "Pause" reaches its route and flips the endpoint
  PASS  webhooks: "Delete" reaches its route and removes the endpoint
  PASS  demo card: no page here is public, so every page keeps "signed in as the admin"
  PASS  demo card: below 600px it starts as the small "Own it" pill; a tap opens it and the choice holds for the session
  PASS  demo card: boot() hooks it only behind the .demo-mode marker, and a page with no </body> passes through untouched
  PASS  backup redaction: a canary sits in every credential column of the schema (api_keys.token_hash, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.ical_token_hash, users.password_hash, users.totp_secret, users.totp_recovery, webhooks.secret)
  PASS  backup redaction: each arrives as [REDACTED] and no canary is anywhere in the JSON backup
  PASS  settings: a typed SMTP password is saved
  PASS  settings: the Settings page never carries the stored SMTP password (it was printed into the form)
  PASS  settings: saving the form with the password field left empty keeps the stored password
  PASS  settings: typing a new password replaces it
  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)
  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  backup: the download and the scheduled backup redact through the one PACKORA_BACKUP_REDACT list
374/374 PASS, 0 FAIL

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