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

Extracta Test run, as shipped in the download

294 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  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  "N/A" -> null (got NULL, want NULL)
  PASS  null -> 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  -4500 -> "-45.00" (got '-45.00', want '-45.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  textual "3 April 2026" (got '2026-04-03', want '2026-04-03')
  PASS  year-first 2026/04/03 (got '2026-04-03', want '2026-04-03')
  PASS  2-digit year 4/3/26 (got '2026-04-03', want '2026-04-03')
  PASS  impossible -> null (got NULL, want NULL)
  PASS  bad ISO -> 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. normalize: raw LLM JSON -> canonical record ==
  PASS  vendor via "supplier" (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
  PASS  invoice_number via "invoice_no" (got 'INV-2026-0042', want 'INV-2026-0042')
  PASS  invoice_date normalized (got '2026-04-03', want '2026-04-03')
  PASS  due_date from textual (got '2026-04-17', want '2026-04-17')
  PASS  currency from "$" (got 'USD', want 'USD')
  PASS  subtotal cents (got 100000, want 100000)
  PASS  tax via "vat" (got 8500, want 8500)
  PASS  total via "grand_total" (got 108500, want 108500)
  PASS  two line items (got 2, want 2)
  PASS  line 1 desc (got 'Widget A', want 'Widget A')
  PASS  line 1 qty (float) (got 2.0, want 2.0)
  PASS  line 1 amount cents (got 60000, want 60000)
  PASS  line 2 desc via "name" (got 'Service B', want 'Service B')
  PASS  empty raw -> blank vendor (got '', want '')
  PASS  empty raw -> null date (got NULL, want NULL)
  PASS  empty raw -> [] items (got array (
), want array (
))

== 6. reconcile: arithmetic cross-checks ==
  PASS  clean invoice reconciles (no warnings)
  PASS  subtotal+tax != total is flagged
  PASS  line items not summing to subtotal is flagged
  PASS  within-tolerance rounding does not warn
  PASS  nothing to check -> ok

== 7. toCsv: RFC-4180 escaping round-trips ==
  PASS  header row present (got 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total', want 'Vendor,Invoice #,Invoice Date,Due Date,Currency,Subtotal,Tax,Total')
  PASS  vendor with comma/quote/newline round-trips
  PASS  CRLF line endings

== 8. toJson: valid, decodes back, decimals ==
  PASS  valid JSON
  PASS  vendor survives (got 'Acme Supplies Ltd', want 'Acme Supplies Ltd')
  PASS  total as decimal (got '1085.00', want '1085.00')
  PASS  line item unit price decimal (got '300.00', want '300.00')

== CSV formula-injection guard ==
  PASS  Extraction =-formula neutralized (got '\'=1+2', want '\'=1+2')
  PASS  Extraction @-formula neutralized (got '\'@SUM(A1)', want '\'@SUM(A1)')
  PASS  Extraction leading-TAB neutralized (got '\'	X', want '\'	X')
  PASS  Extraction -text neutralized (got '\'-abc', want '\'-abc')
  PASS  Extraction negative number kept (got '-45.00', want '-45.00')
  PASS  Extraction plain value untouched (got 'Safe Name', want 'Safe Name')
  PASS  Extraction quoting still applies (got '"\'=a,b"', want '"\'=a,b"')

== Security hardening regressions ==
  PASS  dashboard week query is engine-portable (no SQLite datetime())
  PASS  settings_save blocks writes in public demo mode
  PASS  file_serve sends X-Content-Type-Options: nosniff
  PASS  installer rejects empty install token (hash_equals empty-token bypass)
  PASS  LLM client never follows redirects (key cannot leak to a redirect target)
  PASS  settings view never echoes the stored API key value

== 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 document)
  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

== AI model: current default, retired ids replaced, a refused model named ==
  PASS  the Anthropic default is the id Anthropic names as the replacement (read 2026-09-24) (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  the OpenAI and Ollama defaults are unchanged (got array (
  0 => 'gpt-4o-mini',
  1 => 'llama3.2-vision',
), want array (
  0 => 'gpt-4o-mini',
  1 => 'llama3.2-vision',
))
  PASS  a saved claude-3-haiku-20240307 (the old default) is replaced (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  a saved claude-3-5-sonnet (the old accuracy hint) becomes claude-sonnet-4-6 (got 'claude-sonnet-4-6', want 'claude-sonnet-4-6')
  PASS  a current id is left as the owner typed it (got 'claude-sonnet-5', want 'claude-sonnet-5')
  PASS  claude-opus-4-5 is current and not caught by the retired opus-4-0/4-1 entries (got 'claude-opus-4-5-20251101', want 'claude-opus-4-5-20251101')
  PASS  an empty model falls back to the provider default (got 'claude-haiku-4-5-20251001', want 'claude-haiku-4-5-20251001')
  PASS  OpenAI ids are never rewritten (got 'gpt-4.1-mini', want 'gpt-4.1-mini')
  PASS  Anthropic refusing a model: the message names it, Settings and the current default
  PASS  OpenAI model_not_found is named the same way
  PASS  Ollama without the model: the message says to pull it
  PASS  any other provider error keeps the provider text (got 'LLM API error: invalid x-api-key', want 'LLM API error: invalid x-api-key')
  PASS  a reply that opens with a thinking block still yields its text (got '{"a":1}', want '{"a":1}')
  PASS  a refusal gets a plain message, not a JSON parse error
  PASS  every provider call resolves its model through resolveModel (no inline default left)
  PASS  provider errors pass through apiErrorMessage with the provider and model
  PASS  the Anthropic reply is read through anthropicText, not content[0]
  PASS  no retired Claude 3 id is offered by the installer, Settings or the settings save
  PASS  installer and settings save both resolve the model through Llm (retired ids stored as the replacement)
  PASS  Settings reads the one default list and says when a saved model was retired
  PASS  the upload page says, on the public demo only, that extraction there is a canned sample

== Possible duplicate invoices are flagged ==
  PASS  same vendor (any case, punctuation) and invoice number -> flagged with the other document (got array (
  0 => 7,
  1 => 'same vendor and invoice number',
), want array (
  0 => 7,
  1 => 'same vendor and invoice number',
))
  PASS  no invoice number, same vendor + total + date -> flagged (got array (
  0 => 9,
  1 => 'same vendor, total and invoice date',
), want array (
  0 => 9,
  1 => 'same vendor, total and invoice date',
))
  PASS  same invoice number from a DIFFERENT vendor is not a duplicate (got NULL, want NULL)
  PASS  same vendor, same total, different date and number -> not flagged (monthly retainers) (got NULL, want NULL)
  PASS  no vendor read -> nothing to compare (got NULL, want NULL)
  PASS  the review page compares against every other extracted document and passes the verdict to the view
  PASS  the review view shows the warning with a link to the other document

== Load demo data keeps the owner's settings ==
  PASS  the seed check ran cleanly (no .demo-mode marker in the tree)
  PASS  loading demo data on a real install leaves the owner's AI provider, model, company name and currency as they were
  PASS  ...and still loads the demo records

== Invitations by email through your own SMTP ==
  PASS  the invite check ran cleanly
  PASS  the email names the organisation, the app, the role, the link and the 7-day expiry
  PASS  a line break in the organisation name cannot add a mail header
  PASS  with no SMTP server set, the box is not offered
  PASS  ...and a send is refused with a reason, not attempted
  PASS  with SMTP set, the box is offered
  PASS  an unreachable mail server comes back as a reported failure, never an exception
  PASS  a real send goes out through the owner's SMTP server
  PASS  the invitee's message carries their address, the link and the expiry
  PASS  the invite handler emails only when "Email the link" is ticked, and still shows the link once
  PASS  the public demo never sends an invitation
  PASS  the invite form offers "Email the link", ticked only when SMTP is set

== Backups and restore are for an administrator only ==
  PASS  the backup guard check ran cleanly
  PASS  a signed-in viewer is refused backup_json (403)
  PASS  a signed-in member is refused backup_json (403)
  PASS  a signed-in viewer is refused backup_sqlite (403)
  PASS  a signed-in member is refused backup_sqlite (403)
  PASS  a signed-in viewer is refused restore_page (403)
  PASS  a signed-in member is refused restore_page (403)
  PASS  a signed-in viewer is refused restore_upload (403)
  PASS  a signed-in member is refused restore_upload (403)
  PASS  a signed-in viewer is refused restore_commit (403)
  PASS  a signed-in member is refused restore_commit (403)
  PASS  a signed-in viewer is refused backup_token_mint (403)
  PASS  a signed-in member is refused backup_token_mint (403)
  PASS  an administrator still gets the backup download
  PASS  the scheduled backup answers to its token only, even for a signed-in administrator
  PASS  the Security page shows the Backups card only to someone allowed to download it

== 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: backup_tables
  PASS  v2: reconciling_doc_needs_no_review
  PASS  v2: non_reconciling_doc_needs_review
  PASS  v2: needs_review_is_reconcile_verdict
  PASS  v2: money_is_cents_and_decimal
  PASS  v2: line_items_surfaced
  PASS  v2: extract_hook_fires_event
  PASS  v2: review_hook_fires_event
  PASS  v2: export_includes_reviewed
  PASS  v2: backup_redacts_llm_key
  PASS  v2: db_files_cleaned
  PASS  v2: routes registered (api, 2fa, security, backup, healthz)
  PASS  v2: migrate() runs on every boot (Extracta had NO migrate() before 2.0)
  PASS  v2: 2FA intercepts login + login is rate-limited
  PASS  v2: BOTH extraction paths fire the hook (demo + real)
  PASS  v2: the review hook fires from the UI too, not just the API
  PASS  v2: needs_review reuses Extraction::reconcile, it does not re-derive a rule
  PASS  v2: API corrections use the app own normalisers, not ad-hoc parsing
  PASS  v2: the fields object is declared nested, so the documented payload is accepted
  PASS  v2: the API knows the product REAL status vocabulary (incl. reviewed)
  PASS  v3: backup redacts every reusable credential, incl. the buyer's own LLM key
  PASS  v3: both backup callers share ONE redaction list
  PASS  v2: no PHP-8 ternary key re-reads (PLAN item 10)
  PASS  v2: cli-server base guard (PLAN item 9)
  PASS  v2: core modules stay dependency-free (no product helpers inside them)
  PASS  v2: clip() matches the family implementation (copied code must not drift)
  PASS  v2: light accent is dark enough for white button text (WCAG AA)
  PASS  v2/kit: dark palette (kit + app layer) + kit js + qr.js + layout wiring
  PASS  v2: no themed background paired with an un-overridden hardcoded colour
  PASS  v2/kit: palette tokens are WIRED (kit css) and the app layer overrides the accent
  PASS  v3: sw cache is v3.1.5 + API.md ships
  PASS  v3: /healthz, api_ping and openapi all report 3.1.5

-- Own It 3.0 --
  PASS  v3: v3_tables
  PASS  v3: v3_columns
  PASS  v3: upgrade_never_loosens
  PASS  v3: settings_singleton_ensured
  PASS  v3: upgrade_from_2x
  PASS  v3: migrate_called_at_boot
  PASS  v3: roles
  PASS  v3: whitelist_drops_undeclared
  PASS  v3: stored_keys_are_exactly_declared
  PASS  v3: values_go_through_the_parsers
  PASS  v3: ambiguous_date_is_parsed
  PASS  v3: db_matches_and_marks_reviewed
  PASS  v3: reconcile_is_the_products
  PASS  v3: line_items_narrowed
  PASS  v3: mcp_tools
  PASS  v3: mcp_write_tools_declare_it
  PASS  v3: mcp_arg_guard
  PASS  v3: mcp_get_is_405
  PASS  v3: mcp_never_returns_file_path
  PASS  v3: reject_keeps_values
  PASS  v3: approve_marks_reviewed
  PASS  v3: template_narrows_only
  PASS  v3: template_null_is_full_set
  PASS  v3: template_empty_falls_back
  PASS  v3: pdf_renders
  PASS  v3: gdpr_search_finds_values
  PASS  v3: gdpr_search_is_case_insensitive
  PASS  v3: gdpr_search_misses_non_matches
  PASS  v3: audit_redacts_llm_key
  PASS  v3: audit_records_mcp_writes
  PASS  v3: branding_content_decides
  PASS  v3: restore_refuses_other_product
  PASS  v3: routes registered, LITERALS before {id} patterns
  PASS  v3: MCP writes go through the SAME whitelist builder as the review form
  PASS  v3: the record builder NAMES every field — it never copies keys from its input
  PASS  v3: every path parses currency AND dates with Extraction (one field, one meaning)
  PASS  v3: a template can only NARROW the declared field set
  PASS  v3: the extraction report renders Extraction::reconcile, never a second arithmetic check
  PASS  v3: subject search is gated the SAME way as the nav link that offers it
  PASS  v3: GDPR redaction keeps the money and never logs the search term
  PASS  v3: core Mcp is byte-identical to the shared core
  PASS  v3: core AuditLog is byte-identical to the shared core
  PASS  v3: core Pdf is byte-identical to the shared core
  PASS  v3: core BackupRestore is byte-identical to the shared core
  PASS  v3: core Branding is byte-identical to the shared core
  PASS  v3: core RolesKit is byte-identical to the shared core
  PASS  v3: core Oidc is byte-identical to the shared core
  PASS  v3/C2: a nested value where a value belongs is REFUSED, never cast to the string "Array"
  PASS  v3/C2: _rec_from_input runs the guard over every declared field AND over line items
  PASS  v3/C2: every path that writes extracted values refuses a nested value
  PASS  v3/C2: the viewer seat is read-only in the map and cannot spend the buyer's AI credits
  PASS  v3/C2: the pre-3.0 screens are role-gated, not merely signed-in
  PASS  v3/C2: minting an API key and downloading the register are admin acts
  PASS  v3/C2: no route outside the user's own account is left on a bare requireLogin
  PASS  v3/C2: /backup.sqlite is an app ROUTE and the dev router does not 403 it
  PASS  v3/C2: bin/demo.php can run — migrate() calls AuditLog::ensure()
  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  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

-- F2b: the demo card, the webhook buttons and events, the JSON backup --
  PASS  demo card: /documents keeps the admin wording
  PASS  demo card: /review/1 keeps the admin wording
  PASS  demo card: /login keeps the admin wording
  PASS  demo card: below 600px the script starts it as the small "Own it" pill unless the visitor opened it this session
  PASS  demo card: output without </body> (JSON, CSV, a PDF) passes through untouched
  PASS  forms: every POST form on a screen reaches a registered route (24 checked)
  PASS  webhooks: the Settings page answers and lists every event it offers
  PASS  webhooks: the Add, Pause, Resume and Delete buttons each reach a route and do what they say
  PASS  webhooks: Delete also removes that webhook's delivery log, as its confirmation says
  PASS  webhooks: a paused webhook is sent nothing
  PASS  webhooks: document.extracted is sent by the action where it happens [302]
  PASS  webhooks: document.reviewed is sent by the action where it happens [302]
  PASS  webhooks: every event the Settings page offers is sent somewhere
  PASS  webhooks: approving several documents at once announces each one
  PASS  backup redaction: a credential was planted in every credential column (10)
  PASS  backup redaction: /backup.json and the scheduled backup both use the one list, v3_backup_redact()
  PASS  backup redaction: /backup.json (downloaded by an administrator) carries none of the planted credentials
  PASS  backup redaction: /backup.json (downloaded by an administrator) shows [REDACTED] in each credential column and leaves none unredacted
  PASS  backup redaction: the scheduled backup file carries none of the planted credentials
  PASS  backup redaction: the scheduled backup file shows [REDACTED] in each credential column and leaves none unredacted
  PASS  backup redaction: the administrator got the download (HTTP 200)

————————————————————————————————————————————————————

== v3.1 P6: accounting export presets ==
  PASS  P6: QuickBooks preset carries the documented header and one row PER LINE ITEM (2 items + 1 summary = 3 data rows)
  PASS  P6: Xero preset carries the starred header, quantity 1, Tax Exclusive, currency passthrough
  PASS  P6: unknown preset falls back to the generic CSV (never errors, never invents a format)
  PASS  P6: the route accepts only the named presets
  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
ALL 294 TESTS PASSED (0 failed)

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