Complia · Test run · run 2026-09-25
Complia Test run, as shipped in the download
330 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. Schema install (SQLite in-memory) ==
PASS schemaExists() after install
PASS table users exists
PASS table settings exists
PASS table complaints exists
PASS table complaint_log exists
PASS install() is idempotent (re-run does not throw)
== 2. Settings ==
PASS org_name read through App::set
PASS ack_window_days read
PASS timezone read
PASS App::today() uses business timezone
== 3. s.164A(3) acknowledgement-deadline math ==
PASS default 29-day window (day-1-inclusive safe)
PASS strict statutory reading satisfied: received Mon 2026-08-03 -> due 2026-09-01, never 09-02
PASS explicit 30-day window still available
PASS month rollover
PASS year rollover
PASS leap year Feb
PASS non-leap Feb
PASS custom window 14 days
PASS custom window 60 days
PASS window clamped: 0 -> 1 day
PASS window clamped: 9999 -> 365
== 4. daysRemaining (signed) ==
PASS 10 days left
PASS due today = 0
PASS overdue by 5
PASS across month
PASS across year
== 5. Status workflow transitions ==
PASS received -> acknowledged
PASS received -> investigating
PASS received -> resolved
PASS received -> rejected
PASS acknowledged -> investigating
PASS acknowledged -> resolved
PASS investigating -> resolved
PASS investigating -> rejected
PASS NO acknowledged -> received (backwards)
PASS NO investigating -> acknowledged (backwards)
PASS NO resolved -> anything (terminal)
PASS NO rejected -> anything (terminal)
PASS NO received -> received (no-op)
PASS NO transition to bogus status
== 6. Derived display status (ack_overdue precedence) ==
PASS received before due -> received
PASS received past due -> ack_overdue
PASS received due today -> received (not overdue)
PASS acknowledged past due -> acknowledged
PASS investigating past due -> investigating
PASS resolved past due -> resolved
PASS rejected past due -> rejected
PASS received + ack timestamp -> received (duty met)
PASS bogus stored status -> received
== 7. Public intake -> token -> lookup flow ==
PASS create returns id
PASS token is 40 hex chars
PASS token hex only
PASS byToken finds complaint
PASS email lowercased + trimmed
PASS status starts received
PASS ack_due_at = today + 30 (business tz)
PASS reference stored
PASS submitted log entry written
PASS byToken miss -> null
PASS byToken rejects malformed token
PASS byToken rejects empty token
PASS byToken rejects SQLi-ish token
== 8. Intake validation ==
PASS valid input accepted
PASS empty name rejected
PASS overlong name rejected
PASS bad email rejected
PASS empty subject rejected
PASS empty details rejected
== 9. Anti-abuse: per-IP rate limit ==
PASS 10 submissions/hour from one IP -> limited
PASS different IP not limited
PASS empty IP never limited (proxied installs)
== 10. clip/clamp overflow safety (MySQL strict-mode defense) ==
PASS clip long string to 190
PASS clip short string untouched
PASS clip multibyte-safe
PASS clamp_int huge -> 2e9
PASS clamp_int -huge -> -2e9
PASS clamp_int non-numeric -> 0
PASS clamp_int custom bounds
PASS oversized subject clipped to 255
PASS oversized reference clipped to 100
PASS oversized ip clipped to 45
PASS 100KB details stored intact (big text column)
== 11. Investigation log (append-only) ==
PASS two notes appended
PASS order preserved (append-only)
PASS no UPDATE/DELETE of complaint_log anywhere in controllers
== 12. Status change semantics (ack timestamp + outcome) ==
PASS acknowledged_at recorded
PASS isAcked true after acknowledgement
PASS derived status acknowledged even past due
PASS outcome stored
PASS outcome_date stored
PASS terminal: no transitions from resolved
== 13. Auth (real users table) ==
PASS correct password accepted
PASS wrong password rejected
PASS unknown user rejected
PASS email case-insensitive
PASS inactive user rejected
== 14. Audit CSV export (formula guard, numeric-aware) ==
PASS =SUM formula neutralized
PASS @ formula neutralized
PASS +cmd neutralized
PASS -text neutralized
PASS leading TAB neutralized
PASS negative number NOT corrupted
PASS negative decimal NOT corrupted
PASS positive numeric "+1" kept
PASS plain text untouched
PASS comma cell RFC-4180 quoted
PASS quote cell escaped
PASS formula + comma: guarded AND quoted
PASS CSV header
PASS CSV row count = complaints + history + header
PASS CRLF line endings
PASS complaint rows tagged
PASS history rows present
PASS complaint row cell count
PASS formula name neutralized in real export
PASS formula subject neutralized in real export
== 15. Security hardening regressions (source-level) ==
PASS csrf_check rejects empty stored token (hash_equals empty-token bypass)
PASS installer rejects empty install token (_it bypass)
PASS demo session cookie is None + Secure + Partitioned (CHIPS)
PASS buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
PASS portable UTC timestamps (no SQLite datetime() in app SQL)
PASS clip + clamp_int helpers exist
PASS settings save clips org fields + clamps ack window
PASS complaint create clips every VARCHAR-bound field
PASS details/notes are MEDIUMTEXT on MySQL (64KB TEXT cap avoided)
PASS public POST handlers csrf_check (intake + track)
PASS honeypot field checked in intake handler
PASS per-IP rate limit enforced in intake handler
PASS status route enforces transition matrix
PASS closing a complaint requires an outcome (in the shared rule)
PASS v3: exactly ONE status-advance rule, used by UI + API + MCP
PASS token lookups are exact parameterized matches
PASS CSV export requires the export permission (not merely login)
PASS public status page 404s on miss
PASS all output escaped: views use e() on complaint fields
PASS .htaccess blocks config/data/src/bin/tests/controllers
== 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: no_phantom_events
PASS v2/v3: rate
PASS v2/v3: ack_due
PASS v2/v3: days_remaining_signed
PASS v2/v3: derive_status
PASS v2/v3: overdue_not_a_column
PASS v2/v3: transitions
PASS v2/v3: create_validates
PASS v2/v3: create_sets_clock
PASS v2/v3: complaint_serializer
PASS v2/v3: serializer_nocompute
PASS v2/v3: csv_dry
PASS v2/v3: csv_backlog_clock
PASS v2/v3: backup_tables
PASS v2/v3: v3_tables
PASS v2/v3: audit_redacts
PASS v2/v3: gdpr_map_columns_exist
PASS v2/v3: letter_fields_are_data_only
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: notifications + webhooks fire on the BROWSER path too, never fatally
PASS v2/v3: the product reuses its tested core (no re-derived guards)
PASS v2: the API never computes its own due date or overdue flag
PASS v2/v3: closing needs an outcome (shared rule), and ?status filters the DERIVED status
PASS v2: UK-reframed marketing/public copy left untouched
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 v3: API.md ships + sw cache is v3.1.5 (App::VERSION)
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: the subject-access export takes the same standing as anonymise
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: acknowledging from the register ==
PASS register ack: the transition is accepted
PASS register ack: the stored status moved
PASS register ack: the statutory clock stopped (acknowledged_at is set)
PASS register ack: the derived status is no longer overdue-able
PASS register ack: it appended to the append-only log, it did not overwrite it
PASS register ack: an unacknowledged complaint past its deadline reads as overdue first
PASS register ack: a late acknowledgement is still recorded (lateness is evidence, not a lock)
PASS register ack: a resolved complaint refuses the transition, in the engine's own words
PASS register ack: the refused complaint was not touched
PASS register ack: acknowledging can never CLOSE a complaint (closing needs a written outcome)
PASS register ack: POST /complaints/{id}/ack is registered
PASS register ack: the endpoint requires complaint.advance and checks CSRF
PASS register ack: it delegates to the ONE shared transition rule — no second copy of the workflow
PASS register ack: an unknown complaint 404s and a refused transition answers 409
PASS register ack: it can only ever ask for "acknowledged"
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 overdue count rides the sidebar on every page
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 register still forces a light palette (this print-out is the evidence pack)
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 stage strip are wired
PASS hero: the register carries a clock in every row, tabs with counts, row links and the inline acknowledge
PASS record: the complaint page has the kit rail, the big clock and the append-only log
PASS public: the complaint form and the status page are on the kit public frame
PASS copy: no other product's vocabulary survives in the views
== 21. v3.1 — the complainant's receipt (P1) ==
PASS receipt: the subject names the organisation and nothing else
PASS receipt: the body carries received date, the response deadline, their own reference and the tracking link
PASS receipt: it says why it is so short, so the silence reads as design rather than a bug
PASS receipt: sending is due when the operator AND the complainant both said yes
PASS receipt REFUSES to echo the complaint back: no subject line, no details, in body OR subject
PASS receipt carries no token beyond the one already in the complainant's own link, and no secret
PASS receipt refuses: operator switch off means nothing sends, even for a complainant who asked
PASS receipt refuses: complainant did not tick the box — the operator switch cannot override that
PASS receipt refuses: an unusable email address is never mailed
PASS receipt refuses: an install with no organisation name still produces a sane subject
PASS receipt idempotency: a complaint already receipted is never due again
PASS receipt idempotency: the row is STAMPED before the mail is sent, and the UPDATE re-checks the stamp
PASS receipt: a mail failure can never lose the complaint — the send is wrapped and swallowed
PASS receipt: one emitter — the automatic path and the resend button both call _cp_send_receipt
PASS receipt permission: resending needs complaint.note + CSRF; a viewer cannot
PASS opt-in ticked is stored as 1
PASS opt-in untouched is stored as 0 — silence is never consent
PASS the public form only offers the box when the operator has switched receipts on
== 22. v3.1 — the complainant's evidence (P3) ==
PASS evidence: ONE storage implementation serves both doors — staff and complainant
PASS evidence: the file's own BYTES decide its type, in the one place that stores it
PASS evidence: stored under a RANDOM name, never the name the uploader chose
PASS evidence: capped at 10 MB and refused at zero bytes
PASS evidence: the public form is multipart, or the browser would send filenames and no files
PASS evidence: PHP's parallel-array upload shape is normalised, so evidence[] actually stores
PASS evidence: the record page finally RENDERS attachments — the 3.0 route had no screen at all
PASS evidence refuses: public uploads are OFF until the operator opens that door
PASS evidence refuses: at most 3 files from the public form, whatever the browser posts
PASS evidence refuses: a rejected file NEVER fails the submission — the complaint still lands
PASS evidence refuses: an attachment is never public — serving needs a login and a permission
PASS evidence permission: a viewer can neither attach nor delete
PASS evidence permission: delete is owner-gated — your own uploads, or admin
PASS evidence permission: only an ADMIN may remove what the complainant supplied
PASS evidence: deleting removes the ROW AND THE FILE — a delete that leaves the bytes is the defect
PASS evidence: deleting a file that is already gone still removes the row (a second click is safe)
== 23. v3.1 — anonymising now reaches the evidence ==
PASS erasure: the map now names the identifying columns on attachments
PASS erasure: the FILES are unlinked, and stored_file is blanked so nothing points at them
PASS erasure: the files go BEFORE the column pass — a half-done erasure must not leave the bytes
PASS erasure: the count is reported to the operator and recorded in the log and the audit trail
PASS erasure: still refuses while the complaint is open — that rule is untouched
PASS the tracking link in the receipt actually opens the case — GET /track?t= looks it up
PASS a wrong or absent tracking code on that link is a 404, never a listing
PASS a refused file is REPORTED to the complainant, never silently dropped
== 24. v3.1 — truth on the surfaces ==
PASS README changelog carries 3.1.1 and both features
PASS README no longer claims Complia never emails the complainant
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 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. Uploads: the pages promise what the server accepts, and an over-size form never loses a complaint ==
PASS ini sizes: 2M, 512K, 1G and "unlimited"
PASS upload limits: per file never above 10 MB and never above what PHP takes
PASS upload limits: labels read as the page shows them
PASS an over-size form (PHP dropped every field) is recognised, not mistaken for an expired session
PASS ...and an ordinary form is not
PASS a file PHP refused as too large is kept (and so refused out loud); an empty slot is not
PASS ...and the refusal names the size limit instead of "choose a file first"
PASS the public form states the server's real limit and checks sizes before sending
PASS the public submit handles an over-size form BEFORE the CSRF check (which would 419 and lose the complaint)
PASS the Docker image accepts 10 MB files and a three-file form
PASS receipt: a send the mail server refuses is reported as not sent
PASS receipt: ...the register does not say "emailed", it says NOT sent
PASS receipt: ...and the claim is released, so it can be resent once mail works
PASS settings: saving the Organisation form keeps the SMTP host, the recipient and every switch
PASS settings: the Email form still saves its own fields, unticked boxes included
PASS reminder steps: parsed descending, de-duplicated, nonsense dropped
PASS reminder step: 10 days left reaches none
PASS reminder step: 5 days left is the 7-day step
PASS reminder step: 2 days left is the TIGHTEST step reached (3), not every step
PASS reminder step: the day after the deadline is the overdue step (0)
PASS reminder step: a week overdue sends nothing more
PASS owners: only active members and admins can own a complaint
PASS reminders: off (the default), nothing is sent
PASS reminders: on, the complaint 3 days out gets the 3-day step, to its owner and the notification address
PASS reminders: the complaint due yesterday gets one overdue reminder
PASS reminders: long-overdue and acknowledged complaints get nothing
PASS reminders: with no mail server the step is recorded as no-smtp, never as sent
PASS reminders: the attempt is written to the complaint's own log, failure included
PASS reminders: a second run (cron after a page load) sends nothing — one latch
PASS reminders: undelivered steps are listed for the dashboard
PASS reminders: a cron route runs them, guarded by the backup token
PASS owner: a route names the owner, gated like a note and CSRF-checked
PASS dashboard: the reminder steps ride the page load
PASS the demo checklist promises a register, not a "defensible" one
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/dry-run — 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: the public pages say what the page is, not "signed in as the admin"
PASS demo card: the admin pages keep "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, feed_tokens.token_hash, invites.token_hash, settings.smtp_pass, settings.backup_token_hash, settings.oidc_client_secret, 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
————————————————————————————————————————————————————
ALL 330 TESTS PASSED (0 failed)
← Back to Complia · Manual · Quickstart · API