Revup · Test run · run 2026-09-25
Revup Test run, as shipped in the download
336 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. Template rendering ==
PASS all three placeholders (got 'Hi Ava, thanks from Purple Dental: https://x/r/t1', want 'Hi Ava, thanks from Purple Dental: https://x/r/t1')
PASS repeated placeholder (got 'Bo Bo', want 'Bo Bo')
PASS missing var -> empty (got 'Hi !', want 'Hi !')
PASS unknown braces untouched (got 'A {other}', want 'A {other}')
PASS no placeholders -> passthrough (got 'plain text', want 'plain text')
PASS HTML in value stays raw in message body (got '<b>x</b>', want '<b>x</b>')
PASS e() escapes for view context (got '<b>"x"</b>', want '<b>"x"</b>')
PASS firstName picks first word (got 'Ava', want 'Ava')
PASS firstName empty -> "there" (got 'there', want 'there')
== 2. Tokens ==
PASS token is 40 hex chars
PASS 200 generated tokens are unique (got 200, want 200)
== 3. Open flow: every rating is shown the review link (threshold boundaries) ==
PASS open flow: the old gate decision (isPublic) no longer exists
PASS default 4: rating 3 -> also offered a private message (got true, want true)
PASS default 4: rating 4 -> no private offer (got false, want false)
PASS default 4: rating 1 -> also offered a private message (got true, want true)
PASS threshold 5: rating 4 -> also offered (got true, want true)
PASS threshold 3: rating 3 -> no private offer (got false, want false)
PASS threshold 1: private offer is off for every rating (got false, want false)
PASS threshold clamped: 0 behaves as 1 (off) (got false, want false)
PASS threshold clamped: 9 behaves as 5 (got true, want true)
PASS an invalid rating is never offered anything (got false, want false)
PASS state: unrated -> the stars (got 'rate', want 'rate')
PASS state: rated at/above threshold -> review links (got 'review', want 'review')
PASS state: rated below threshold, no message -> review links + private form (got 'feedback', want 'feedback')
PASS state: private message sent -> thanks + review links (got 'thanks', want 'thanks')
PASS open flow: no rating under any threshold lands in a state without the review link
PASS reviewLinks: primary offered in slot 1 with its label
PASS reviewLinks: a non-http(s) URL is never offered
PASS parseRating "5" -> 5 (got 5, want 5)
PASS parseRating "4" -> 4 (got 4, want 4)
PASS parseRating 0 -> null (got NULL, want NULL)
PASS parseRating 6 -> null (got NULL, want NULL)
PASS parseRating "abc" -> null (got NULL, want NULL)
PASS parseRating null -> null (got NULL, want NULL)
== 4. Clip/clamp on writes (MySQL strict-mode defense) ==
PASS contact name clipped to 190 (got 190, want 190)
PASS contact email clipped to 190 (got 190, want 190)
PASS contact phone clipped to 40 (got 40, want 40)
PASS contact tags clipped to 255 (got 255, want 255)
PASS campaign name clipped to 190 (got 190, want 190)
PASS campaign subject clipped to 190 (got 190, want 190)
PASS campaign template clipped to 10000 (got 10000, want 10000)
PASS bogus channel coerced to email (got 'email', want 'email')
PASS updateContact lowercases email (got 'ava@x.com', want 'ava@x.com')
PASS clamp_int caps range (got 2000000000, want 2000000000)
PASS clamp_int floor (got -2000000000, want -2000000000)
PASS clamp_int non-numeric -> 0 (got 0, want 0)
== 5. Request creation + single-use rating ==
PASS creates one request per valid unique contact (got 2, want 2)
PASS request tokens unique + hex
PASS new requests are pending (got 'pending', want 'pending')
PASS last_request_at stamped on contact
PASS re-create while open request exists -> skipped (got 0, want 0)
PASS canRate true before rating
PASS recordRating consumes token (got true, want true)
PASS recordRating second time rejected (single-use) (got false, want false)
PASS first rating persisted, not overwritten (got 5, want 5)
PASS rated_at stamped
PASS canRate false after rating
PASS rating alone does not mark the review page opened (got 0, want 0)
PASS markReviewOpened: an unrated request cannot be marked (got false, want false)
PASS markReviewOpened: first open is recorded (got true, want true)
PASS markReviewOpened: a second open is not a new event (webhook fires once) (got false, want false)
PASS markReviewOpened: redirected stamped (got 1, want 1)
== 6. Feedback storage + clipping ==
PASS feedback message clipped to 5000 (never 22001) (got 5000, want 5000)
PASS feedback rating stored (got 2, want 2)
PASS feedback starts unhandled (got 0, want 0)
PASS feedback rating clamped to 5 (got 5, want 5)
== 7. CSV import mapping ==
PASS mapped rows (got 3, want 3)
PASS blank row skipped (got 1, want 1)
PASS alias headers matched (Full Name) (got 'Ava T', want 'Ava T')
PASS email lowercased + kept (got 'ava@x.com', want 'ava@x.com')
PASS invalid email dropped to empty (got '', want '')
PASS oversize CSV name clipped to 190 (got 190, want 190)
PASS oversize CSV phone clipped to 40 (got 40, want 40)
PASS oversize CSV tags clipped to 255 (got 255, want 255)
PASS UTF-8 BOM on first header stripped (got 'Zed', want 'Zed')
PASS unmappable header -> nothing imported (got 0, want 0)
PASS row cap enforced (got 10, want 10)
PASS rows beyond cap counted as skipped (got 20, want 20)
PASS nameless row falls back to email (got 'only@x.com', want 'only@x.com')
== 8. CSV import into DB (dedupe by email) ==
PASS inserted (got 2, want 2)
PASS updated (deduped by email) (got 1, want 1)
PASS no duplicate rows created (got 1, want 1)
PASS update keeps old phone when new blank (got '+1777', want '+1777')
PASS formula content stored RAW in DB (guard is at export) (got '=cmd()', want '=cmd()')
== 9. 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 +cmd neutralized (got '\'+cmd', want '\'+cmd')
PASS +1 numeric -> KEPT (got '+1', want '+1')
PASS -text neutralized (got '\'-abc', want '\'-abc')
PASS negative number KEPT (got '-45.00', want '-45.00')
PASS leading TAB neutralized (got '\' X', want '\' X')
PASS leading CR neutralized + quoted
PASS plain value untouched (got 'Ava T', want 'Ava T')
PASS comma value quoted (got '"a,b"', want '"a,b"')
PASS quote value escaped (got '"say ""hi"""', want '"say ""hi"""')
PASS export header row (got 'name,phone', want 'name,phone')
PASS CRLF endings
PASS formula neutralized in real export
PASS negative number preserved in export
PASS empty export -> empty string (got '', want '')
== 10. Mailer message building (no socket) ==
PASS From header present
PASS To header present
PASS Subject header present
PASS MIME text/plain utf-8
PASS CRLF header/body separator
PASS dot-stuffing applied
PASS CRLF stripped from To (header injection)
PASS CRLF stripped from Subject (header injection)
PASS encodeHeader ASCII passthrough (got 'Hello', want 'Hello')
PASS encodeHeader UTF-8 -> RFC 2047 (got '=?UTF-8?B?Q2Fmw6k=?=', want '=?UTF-8?B?Q2Fmw6k=?=')
PASS dotStuff normalizes lone LF to CRLF (got 'a
b', want 'a
b')
PASS dotStuff escapes leading dot at start (got '..x', want '..x')
PASS send refuses when unconfigured (no socket)
PASS send refuses invalid recipient before any socket
PASS Mailer::configured false on blank
PASS Mailer::configured true on host+from
== 11. Sms request building (no network) ==
PASS URL hard-coded to api.twilio.com
PASS SID rawurlencoded in path (no traversal)
PASS URL ends with /Messages.json
PASS From urlencoded
PASS To urlencoded
PASS Body urlencoded (& escaped)
PASS auth is sid:token (got 'AC123/../evil:tok', want 'AC123/../evil:tok')
PASS SMS body clipped to 1600 (got 1600, want 1600)
PASS Sms::configured false on blanks
PASS Sms::send refuses when unconfigured (no network)
PASS Sms::send refuses invalid phone before network
== 12. Sender modes: demo-block, manual, missing address ==
PASS demo mode blocks send, marks sent (got 'sent', want 'sent')
PASS demo mode via = demo (got 'demo', want 'demo')
PASS demo outcome persisted on row (got 'demo', want 'demo')
PASS demo sent_at stamped
PASS manual mode keeps pending (got 'pending', want 'pending')
PASS manual mode via = manual (got 'manual', want 'manual')
PASS manual persisted as pending (got 'pending', want 'pending')
PASS missing address -> failed (got 'failed', want 'failed')
PASS missing address error message set
PASS sms unconfigured -> pending/manual (got 'pending/manual', want 'pending/manual')
PASS gateLink contains /r/<token>
== 13. Gate rate limit (per-IP) ==
PASS 61st hit from same IP blocked
PASS different IP unaffected
== 14. Auth ==
PASS password hash verifies
PASS stored hash verifies
PASS wrong password rejected
== 15. Stats ==
PASS stats sent >= 1
PASS stats rated counts ratings
PASS stats avg is float or null
PASS below + at_or_above == rated
PASS stats: opened_review counts raters who opened the review page
PASS stats: the gate-era keys are gone (no "kept private" figure)
== 16. Security hardening regressions ==
PASS csrf_check rejects empty stored token (hash_equals empty-token bypass)
PASS installer rejects empty install token (_it bypass)
PASS settings_save blocks writes in public demo mode
PASS Sender demo-block sits BEFORE any credential/config read
PASS Twilio endpoint hard-coded https://api.twilio.com
PASS Twilio client never follows redirects
PASS no error_log of credentials anywhere in senders
PASS settings view never echoes smtp_pass value
PASS settings view never echoes twilio_token value
PASS settings view never echoes twilio_sid value
PASS demo session cookie is None + Secure + Partitioned
PASS buyer session cookie is HttpOnly + Lax + Secure-on-HTTPS
PASS gate view shows first name only (no email/phone echoed)
PASS gate feedback form carries the honeypot field
PASS gate handlers enforce the per-IP rate limit
PASS gate token lookup is parameterized
PASS public review links carry rel=noopener
PASS dev router denies config.php / data / .sqlite
PASS .htaccess denies config.php / sqlite / demo marker
PASS portable UTC timestamps (no SQLite datetime() in controllers)
PASS no stack traces: front controller catches boot errors
== 3.1.4 open flow (rendered rating page) ==
PASS open flow: the 'review' page links to the public review page
PASS open flow: the 'feedback' page links to the public review page
PASS open flow: the 'thanks' page links to the public review page
PASS open flow: a low rating sees the review link BEFORE the private form, never instead of it
PASS open flow: the private form is only on the below-threshold page
PASS open flow: the page never tells a customer their message replaces a review
PASS open flow: gate_rate never sends a rating straight to an external review URL
PASS open flow: the review click-through route is registered and records the open
PASS open flow: settings can switch the private offer off (threshold 1 is selectable)
PASS open flow: the dashboard no longer sells a count that "never reached a public profile"
PASS tile trend: six months, oldest first (got array (
0 => 'Apr',
1 => 'May',
2 => 'Jun',
3 => 'Jul',
4 => 'Aug',
5 => 'Sep',
), want array (
0 => 'Apr',
1 => 'May',
2 => 'Jun',
3 => 'Jul',
4 => 'Aug',
5 => 'Sep',
))
PASS tile trend: counts SENT requests by send month (pending/failed and creation month ignored) (got array (
0 => 0,
1 => 0,
2 => 0,
3 => 0,
4 => 1,
5 => 1,
), want array (
0 => 0,
1 => 0,
2 => 0,
3 => 0,
4 => 1,
5 => 1,
))
PASS tile trend: the quarter total can never exceed the "Requests sent" total
PASS tile trend: the dashboard reads Revup::sentPerMonth, not created_at
== 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: notify_cols
PASS v2: smtp_preexisting
PASS v2: mint
PASS v2: revoke
PASS v2: totp
PASS v2: recovery
PASS v2: delivery
PASS v2: rate
PASS v2: csv_dry
PASS v2: csv_refuses_unreachable
PASS v2: backup_tables
PASS v2: gate_reuse
PASS v2: feedback_no_token
PASS v2: request_sent_fires_from_the_sender
PASS v2: request_sent_not_refired_by_a_followup
PASS v2: runner_releases_what_is_due
PASS v2: runner_second_pass_same_day_is_a_noop
PASS v2: runner_followup_waits_for_the_gap
PASS v2: runner_followup_skips_who_answered
PASS v2: runner_followup_leaves_the_first_send_alone
PASS v2: runner_followup_is_sent_once
PASS v2: runner_stamps_every_pass
PASS v2: runner_respects_the_off_switch
PASS v2: routes registered (api, 2fa, security, backup, import, healthz)
PASS v2: 2FA intercepts login + login is rate-limited
PASS v2: negative feedback notifies the operator + fires a webhook, never fatally
PASS v2: reuses revup OWN Mailer — MailerLite is deliberately NOT shipped
PASS v2: API reuses the UI core (Revup::createRequests + _send_requests)
PASS C2: requireKey filters users.active (deactivating a user revokes their key)
PASS v2: no ternary key re-reads in ANY controller (PHP 8 warning hygiene, PLAN item 10)
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2: theme.js + qr.js ship and layout loads theme.js + a toggle
PASS v3: API.md ships + sw cache is v3.1.5 (and not the old name, or a changed stylesheet serves stale)
PASS v2: cli-server base guard present
PASS v3: index.php calls Database::migrate() at boot (1.x/2.0 installs gain their columns)
PASS v3: an installer-shaped install has every column the code writes
PASS v3: no literal route is shadowed by an earlier {id} route
PASS v3: the CSV import form posts to a route that reaches the importer
PASS v3: version is 3.1.5 everywhere (healthz, api_ping, openapi), with nothing older left behind
PASS v3: the bump did not fork the shared core modules
PASS v3: all 9 shared cores are byte-identical to the reference copy
PASS v3: the deploy kit ships both files, fully filled
PASS v3: the deploy kit names THIS product and carries the fixed healthcheck
PASS v3: the brand accent is applied in :root AND html[data-theme=dark]
PASS v3: an uploaded logo and the brand name reach the app chrome, not just the settings page
PASS v3: GET /mcp is routed to the unauthenticated probe (a 401 hides that we speak MCP)
PASS v3: the GET probe runs no key check and exposes no tools
PASS v3: GET reaches the core and gets 405 + Allow: POST
PASS v3: MCP creates requests through Revup::createRequests(), never its own INSERT
PASS v3: MCP cannot change the gate threshold or the review URL
PASS v3: exactly ONE send-window planner, used by the preview and the release
PASS v3: every advertised reply merge field actually resolves
PASS v3: an unknown placeholder is left alone rather than blanked
PASS v3: reply merge fields are data keys only
PASS v3: both backup doors use one redaction list
PASS v3: the redaction list names every credential column revup stores
PASS v3: no call to a function this product does not define
PASS v3: the 2.0 settings and API-key handlers are permission-gated too
PASS v3: every v3 write handler is permission-gated
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 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: the send queue runs in slices with visible progress ==
PASS send-batch: routed, permission-gated and CSRF-checked like the single send
PASS send-batch: dispatches through the SHARED sender, never its own copy
PASS send-batch: a slice is bounded — a long queue can never become one long request
PASS send-batch: the progress list carries a FIRST NAME only, like the public gate
PASS send-batch: every run is audited with what it sent and what is left
PASS send-batch: the runner stops itself when nothing is moving (no infinite loop)
PASS send-batch/engine: the private-offer rule the queue reports is Revup's own
PASS send-batch/engine: a bad rating parses, it is the threshold that gates it (got 3, want 3)
== 3.1 schedule + follow-up ==
PASS schedule/happy: a running window with work releases once the hour has come (got '', want '')
PASS schedule/happy: it releases exactly AT the chosen hour, not only after it (got '', want '')
PASS schedule/happy: a window that missed its hour still goes out on the next pass (got '', want '')
PASS schedule/happy: the hour is clamped into a real day, whatever is stored
PASS schedule/refusal: automatic release is off unless someone turned it on (got 'Automatic release is off for this window.', want 'Automatic release is off for this window.')
PASS schedule/refusal: a finished window is never revived by the runner (got 'This window is not running.', want 'This window is not running.')
PASS schedule/refusal: before the hour, nothing goes out (got 'Not yet — this window releases at 09:00 UTC.', want 'Not yet — this window releases at 09:00 UTC.')
PASS schedule/refusal: nothing due means nothing sent, however loudly the cron fires (got 'Nothing is due yet.', want 'Nothing is due yet.')
PASS schedule/idempotent: a second pass on the same day releases nothing (got 'Already released today.', want 'Already released today.')
PASS schedule/idempotent: yesterday's stamp does not block today
PASS schedule/idempotent: the day is CLAIMED before anything is sent, so a crash cannot double-send
PASS followup/happy: silence for the full gap earns exactly one reminder (got '', want '')
PASS followup/happy: the gap counts from when the first message actually went out (got 'Too soon — 7 day(s) have not passed since it was sent.', want 'Too soon — 7 day(s) have not passed since it was sent.')
PASS followup/happy: the reminder reuses the SAME link — it is the same single-use ask
PASS followup/happy: it says once that it is a reminder, and adds nothing else
PASS followup/refusal: a customer who rated is never nudged again (got 'This customer already rated.', want 'This customer already rated.')
PASS followup/refusal: a low rating counts as an answer too — feedback is not silence (got 'This customer already rated.', want 'This customer already rated.')
PASS followup/refusal: someone who went to the review page has already been asked enough (got 'This customer already went to the review page.', want 'This customer already went to the review page.')
PASS followup/refusal: a request whose first message never left is not followed up (got 'The first message never went out.', want 'The first message never went out.')
PASS followup/refusal: no gap configured means no reminder, ever (got 'No follow-up is configured for this window.', want 'No follow-up is configured for this window.')
PASS followup/refusal: a nonsense gap is read as OFF rather than as an instruction
PASS followup/idempotent: exactly one reminder, whatever else runs (got 'The follow-up already went out.', want 'The follow-up already went out.')
PASS followup/idempotent: the stamp is written ONLY on a successful send
PASS followup/idempotent: a reminder never rewrites the first message's own history
PASS followup/idempotent: the runner only ever looks at requests with no stamp yet
PASS schedule/permission: both new POSTs need batch.run and a CSRF token
PASS schedule/permission: the cron door refuses to answer over HTTP
PASS schedule/permission: the public demo never runs the schedule
PASS schedule/permission: overlapping runs are refused by an atomic lock, not a lock file
PASS schedule: the runner reuses the product's OWN sender — no second send path
PASS schedule: a stopped runner is VISIBLE — every pass is stamped and the page reads it
PASS followup/refusal: manual mode is not a failure — an install with no channel is not nagged
PASS followup/refusal: a follow-up with no channel is NOT stamped, so it goes out once SMTP is set up
PASS schedule: a manual run and the cron run take the SAME path
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
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 (38 checked)
PASS F2 webhooks: the events offered in Settings (feedback.received, rating.public, request.sent) are exactly the events the code sends
PASS F2b ribbon: the public page /r/Ab3dEf6hIj9kLm2nOp5q says what it is ("this is the page your customers rate you on"), not "signed in as the admin"
PASS F2b ribbon: an admin page (/requests) 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.smtp_pass, settings.twilio_token, 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, revup_backup_redact()
————————————————————————————————————————————————————
ALL 336 TESTS PASSED (0 failed)
← Back to Revup · Manual · Quickstart · API