Votera · Test run · run 2026-10-06
Votera Test run, as shipped in the download
550 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-10-06 with php tests/run.php. Run it yourself after you download.
[DB: SQLite]
== schema + defaults ==
PASS schemaExists() true after install
PASS default board seeded
PASS status pipeline seeded (6)
PASS roadmap columns = 4
== posts + voting (hand-computed) ==
PASS two posts created (ids differ)
PASS A vote → count 1, voted true
PASS A unvote → count 0, voted false
PASS A re-vote → count 1
PASS p1 has exactly 3 votes (A,B,C — one each)
PASS UNIQUE(post,voter) — duplicate insert ignored
PASS top sort: p1 (3 votes) ranked above p2 (1 vote)
PASS me_voted reflects the asking voter
== comments + changelog ==
PASS public comments exclude internal notes
PASS admin view includes internal notes
PASS changelog published-only excludes drafts
PASS changelog all includes drafts
== stats ==
PASS stats.posts = 2
PASS stats.votes = 4 (3 on p1 + 1 on p2)
PASS stats.shipped = 1 published changelog
== voting controls (1.1.0) ==
PASS vote_mode defaults to anon (no signup wall, unchanged behavior)
PASS vote_mode reads email once set
PASS unknown vote_mode fails SAFE to anon
PASS email voter key is case/space insensitive (same person, same key)
PASS email voter key is 32 hex chars
PASS email voter key does NOT contain the address
PASS different emails give different keys
PASS one email = one vote
PASS same email in different casing un-votes the SAME vote (not a second one)
PASS two different emails = two votes
PASS UNIQUE(post_id,voter_key) blocks a duplicate even via direct INSERT
PASS hasVoted true for a key that voted
PASS hasVoted false for a key that did not
PASS rate limit 0 = unlimited (pre-1.1.0 behavior preserved)
PASS rate limit stops the 4th vote in an hour (3 allowed of 6 tried)
PASS a different IP is unaffected by the first IP limit
PASS vote_hits stores a HASH, never the raw IP
PASS no REMOTE_ADDR (CLI/tests) does not block
PASS voteVelocity reports an hour and a day figure
PASS upgrade() recreates vote_hits on a pre-1.1 database (rate limiter usable after upgrade)
PASS upgrade() is idempotent (second run is a no-op)
== helpers ==
PASS slugify strips + lowercases
PASS e() escapes html
== 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 both layouts link the manifest + register 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: post_no_email
PASS v2: internal_notes_hidden
PASS v2: status_rule
PASS v2: canny_votes
PASS v2: real_vote_on_top
PASS v2: csv_dry
PASS v2: routes registered
PASS v2: 2FA intercepts login + rate limit
PASS v2: API reuses the UI core (Board::createPost / addComment / statusMap)
PASS v2: internal notes never cross the API
PASS v2: Canny import synthesizes voter rows so migrated counts survive
PASS v2: zero clip() dependency (votera has no such helper - adapted to mb_substr)
PASS v2: no ternary key re-reads in ANY controller (PLAN item 10)
PASS v2: dark palette + zero hardcoded white backgrounds
PASS v2: theme.js + qr.js ship; BOTH layouts load theme.js; admin has a toggle
PASS v2: API.md ships + sw cache is v4.1.1
PASS v2: cli-server base guard present
PASS v3: v3_tables
PASS v3: v3_columns
PASS v3: upgrade_restores_v3_tables
PASS v3: update_on_a_missing_settings_row_silently_does_nothing
PASS v3: migrate_ensures_the_settings_singleton
PASS v3: verification_is_required_only_with_the_email_gate
PASS v3: verification_is_inert_in_anonymous_mode
PASS v3: an_unverified_key_is_not_verified
PASS v3: starting_verification_does_not_verify_anything
PASS v3: the_token_is_stored_only_as_a_hash
PASS v3: after_the_click_the_key_is_verified
PASS v3: a_verified_voter_can_vote
PASS v3: the_voters_address_appears_nowhere_in_the_database
PASS v3: the_verification_table_has_no_email_column
PASS v3: verification_and_vote_share_only_the_salted_key
PASS v3: keys_are_not_portable_between_installs
PASS v3: audit_never_records_the_voter_key
PASS v3: audit_never_records_the_address
PASS v3: there_is_no_cast_vote_tool
PASS v3: mcp_offers_the_six_read_and_write_tools
PASS v3: mcp_tally_matches_the_database
PASS v3: mcp_create_uses_the_public_forms_refusal
PASS v3: mcp_create_writes_the_post
PASS v3: mcp_set_status_works_by_label
PASS v3: mcp_unknown_status_names_the_valid_ones
PASS v3: post_detail_does_not_leak_author_emails
PASS v3: unknown_role_gets_the_most_restrictive
PASS v3: empty_role_is_treated_as_legacy_admin
PASS v3: member_cannot_moderate
PASS v3: member_can_still_write
PASS v3: last_admin_cannot_be_demoted
PASS v3: member_does_not_see_set_status
PASS v3: a_scheduled_entry_is_not_public_yet
PASS v3: scheduled_is_detected_by_its_own_date
PASS v3: when_its_time_arrives_it_publishes_through_the_same_path
PASS v3: scheduled_entries_are_listed_for_the_admin
PASS v3: gdpr_blanks_the_author
PASS v3: gdpr_keeps_the_post_and_the_thread
PASS v3: gdpr_never_touches_a_vote
PASS v3: the_vote_table_has_no_author_column
PASS v3: posts_have_no_attachment_column
PASS v3: brand_css_brands_the_light_theme
PASS v3: brand_css_brands_the_dark_theme_too
PASS v3: brand_dark_accent_is_legible_on_the_dark_canvas
PASS v3: unbranded_install_emits_no_brand_css
PASS v3: scheduled_backup_writes_a_dated_file
PASS v3: scheduled_backup_redacts_credential_material
PASS v3: scheduled_backup_prunes_to_the_retention_count
PASS v3: results_pdf_engine_renders
PASS v3: email_form_saves_the_mail_server
PASS v3: general_form_keeps_the_mail_server
PASS v3: slack_hook_sends_a_text_line
PASS v3: shipped_status_rule
PASS v3: refused_ship_email_stays_pending
PASS v3: ship_email_links_are_absolute
PASS v3: status_moves_email_followers_on_ship
PASS v3: smtp_fields_live_in_the_email_form
PASS v3: branding_form_carries_no_smtp_fields
PASS v3: db_files_cleaned
PASS v3: index.php calls Database::migrate() at boot (it called it NOWHERE before 3.0)
PASS v3: BOTH layouts inject the brand CSS (branding only the admin leaves the public board bare)
PASS v3: the branding form exists and posts to the branding route
PASS v3: the verification gate is wired INTO the existing vote path, not a second route
PASS v3: GET /mcp answers the transport hint BEFORE the key check
PASS v3: the bulk route is registered BEFORE the /admin/posts/{id} placeholder
PASS v3: no view posts to a route that does not exist
PASS v3: every route has a function to answer it
PASS v3: the backup redaction is one shared list, not an inline array
PASS v3: the deploy kit is filled and ships inside app/
PASS v3: the nine cores are byte-identical to the reference copy (or skipped when unavailable)
PASS v3: README documents 3.0 and API.md gains the MCP section
PASS v3: MCP domain refusals THROW McpRefusal, never return an error array
PASS v3: admin writes + api-settings are role-gated, not merely logged in
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 v1.0 (2026-08-19): inline triage + dashboard ==
PASS routes: POST /admin/posts/{id}/status registered (literal bulk stays before the wildcard)
PASS inline status: same permission (post.moderate) + csrf + audit row as the bulk path; JSON errors carry sentences
PASS fixture: a post can hold a status
PASS posts list: inline select per row + bulk bar on the SAME form; fetch guards network errors and restores the previous value
PASS dashboard: DB checklist + untriaged/unanswered needs-action + 14-day vote sparkline
PASS kit: grouped admin nav + who-line + theme toggle; public foot gets the toggle; app layer holds dark accent and tokenized colors (no stray hex inks)
== v3.1 P1: email-me-when-this-ships ==
PASS P1: idea_subscribers table + unique (post,email) + OFF-default toggle migrate idempotently
PASS P1: subscribe validates the address, rate-limits, is idempotent per (post,email), and 404s a missing idea
PASS P1: ship notifies subscribers ONCE (notified_at guard), honors the OFF toggle + configured SMTP, and never breaks the ship
PASS P1: every mail carries a one-click unsubscribe and the unsub route deletes by constant-time token match
PASS P1: the settings view carries the SMTP card again — the rebuilt view had DROPPED the smtp fields while the save still wrote them from POST, so every settings save silently WIPED the stored SMTP config (found in review, 2026-08-20)
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: every MCP tool declares whether it reads or writes, so a read-only key can see something
PASS scope: the key form lets an administrator mint a read-only key
PASS chat: Slack gets {"text": ...} naming the idea, its new status and its votes, with a link
PASS chat: Discord gets {"content": ...}
PASS chat: Teams gets {"text": ...}
PASS chat: the signed format still sends the JSON event unchanged
PASS chat: the create form's format is checked against the fixed list
PASS ribbon: with the demo offers map it reads "Own it — $59 once" and goes to the store's buy path
PASS ribbon: with no map it renders the product-page button exactly as before
PASS ribbon: a buy path that is not a site path is ignored, never linked
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 /admin/restore — 403 for every non-admin role, open to admin
PASS backup guard: POST /admin/restore — 403 for every non-admin role, open to admin
PASS backup guard: POST /admin/restore/commit — 403 for every non-admin role, open to admin
PASS backup guard: POST /admin/restore/backup-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 webhooks: "Add" reaches its route and saves the endpoint (was a 404)
PASS webhooks: "Pause" reaches its route and flips the endpoint
PASS webhooks: post.created is sent for an idea posted on the board (it was sent only for the API)
PASS webhooks: comment.created is sent for a board comment, never for an internal note
PASS webhooks: post.status_changed is sent when the admin sets a status on screen
PASS webhooks: "Delete" reaches its route and removes the endpoint
PASS demo card: the public board, idea, roadmap, changelog, submit and widget pages say what the page is
PASS demo card: without the .demo-mode marker the admin pages claim neither a sign-in nor a reset
PASS demo card: behind the marker the board says the sample resets hourly and the admin pages say "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 on the demo host only, 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, backup_state.s3_secret, invites.token_hash, settings.smtp_pass, settings.oidc_client_secret, settings.backup_token_hash, settings.feed_token_hash, settings.voter_sso_secret, settings.issue_hook_secret, settings.linear_hook_secret, settings.slack_signing_secret, updater_state.license_api_key, users.password_hash, users.totp_secret, users.totp_recovery, voter_verifications.token_hash, webhooks.secret)
PASS backup redaction: each arrives as [REDACTED] and no canary is anywhere in the JSON backup
PASS demo: /admin opens signed in as the demo administrator — no login wall, the dashboard renders
PASS demo: the first visit resets the board — the idea a visitor left is gone and the eleven sample ideas are back
PASS demo: a visit within the hour leaves the board alone
PASS demo: once the hour is up the next visit resets it again — content, public settings, and the demo administrator's role
PASS demo: without the .demo-mode marker (every buyer's install) /admin still sends the visitor to sign in, and nothing is reset
PASS demo: the command-line seeder still builds the sample board and its administrator
PASS license: every font ships the SIL OFL text for its own family beside it
PASS license: LICENSE.txt is present and carries the IF WE STOP (CONTINUITY) clause
== 3.2 · know who voted: the signed hand-off ==
PASS verify: a good token gives the cleaned claims (email lowercased, value in cents)
PASS verify: "sub" works in place of "id" (standard JWT)
PASS verify: a wrong secret is refused
PASS verify: a payload swapped under an old signature is refused
PASS verify: alg "none" is refused
PASS verify: no id is refused
PASS verify: no expiry is refused
PASS verify: an expired token is refused (beyond a minute of clock skew)
PASS verify: a token living longer than a day is refused
PASS verify: a short or missing secret means sign-in is not set up
PASS verify: garbage is refused, never thrown as anything else
PASS verify: an invalid email and a negative value are dropped
PASS voter key: stable, "u" + 31 hex, and different per user
PASS return path: only a path on this board
== 3.2 · voter records and who asked ==
PASS upsert: a new voter is recorded with their key
PASS upsert: fresh claims win, omitted ones keep what was known, still one row
PASS weights: identified, paying and value per idea (anonymous votes count toward neither)
PASS who asked: the paying customer first, then the free one; the anonymous vote is not named
PASS API shape: a post carries identified_votes, paying_votes and value
PASS the votes table still names nobody: an identified vote carries only the hashed key
PASS Board::voterKey(): a signed-in voter votes as themselves
PASS Board::voterKey(): switched off, the same browser is anonymous again
PASS schema: voter sign-in is off on an upgraded install
PASS backup: the signing secret is a credential the shareable backup redacts (pattern *_secret)
== 3.2 · the demo shows who asked ==
PASS demo: twelve customers the demo app signed in, three paying plans
PASS demo: their votes replace anonymous ones, so each idea keeps its count (Dark mode 47)
PASS demo: Dark mode shows 5 who asked, 3 paying, 347.00 a month
PASS demo: voter sign-in is on with a secret, anonymous voting still open
PASS http: hand-off: a good token signs the voter in and returns to the board page asked for
PASS http: the board says who is voting, and that the team sees the name
PASS http: a signed-in voter's vote counts
PASS http: hand-off: a return address off this board goes to the board's home instead
PASS http: hand-off: a token signed with another secret is refused with the reason (400)
PASS http: hand-off: an expired link is refused (400)
PASS http: an anonymous visitor can still vote (the default)
PASS http: owner signed in
PASS http: admin idea page: "Who asked" names Ava, her plan and monthly value
PASS http: admin posts: "Monthly value" puts the idea a paying customer asked for first
PASS http: admin posts: filtering by plan keeps only ideas that plan voted for
PASS http: settings: the voter sign-in section is there
PASS http: settings: a new secret is shown once, then never again
PASS http: settings: tokens signed with the old secret stop working
PASS http: only signed-in: an anonymous vote is refused and sent to the owner's sign-in page with the way back
PASS http: only signed-in: the board shows "Sign in with Acme to vote"
PASS http: embed.js passes data-token to the widget as ?sso=
PASS http: widget: opened with a token, it renders
PASS http: widget: that visitor votes as themselves even with "only signed-in" on
PASS http: sign out: afterward the browser is anonymous again (and refused under "only signed-in")
PASS http: API: /api/posts/{id}/voters names who asked, with plan and value
PASS http: API: every post carries identified_votes, paying_votes and value
PASS http: privacy export: includes what the owner's app said about the person
PASS http: privacy erase: the voter profile goes; the vote stays, and no longer names anyone
PASS http: security page: the shareable backup leaves out webhook URLs, and says to re-enter them
PASS http: the server log carries no PHP warning or fatal
PASS http: the walk made every check it planned (26)
== 3.2 pass B · the ship loop: issue addresses ==
PASS parse: a GitHub issue and a pull request of the same repo share one number space
PASS parse: GitLab on gitlab.com and on your own host, subgroups kept, issues (#) apart from merge requests (!)
PASS parse: Linear by its identifier, whatever the slug
PASS parse: Jira by its key, cloud or your own server
PASS parse: any other address is kept as a link; anything that is not http(s) is refused
PASS fit: a reference longer than its column keeps its start and a hash of the whole — same issue, same reference
PASS refFrom: a bare Jira key (what a Jira rule sends) or an address
== 3.2 pass B · the ship loop: each tracker's proof ==
PASS verify GitHub: X-Hub-Signature-256 over the raw body; a wrong one, a missing one or another body fails
PASS verify GitLab: X-Gitlab-Token equals the secret
PASS verify Linear: Linear-Signature is the HMAC-SHA256 hex of the raw body
PASS verify generic (Jira and others): X-Votera-Token
PASS verify: no secret, a short secret or an unknown door never verifies
== 3.2 pass B · the ship loop: what each tracker says ==
PASS GitHub: an issue closed as completed is done (and an older event with no reason too)
PASS GitHub: closed as "not planned" is dropped, never done
PASS GitHub: reopened is open; a label or a comment is not news
PASS GitHub: a pull request merged is done; closed without merging is dropped; ping answers
PASS GitLab: issue close is done, reopen is open; a merge request merged is done, closed is dropped
PASS GitLab: an address written without "/-/" (older GitLab, its sample payloads) is the same issue
PASS Linear: completed is done, canceled is dropped, started is open
PASS Linear: a delivery stamped more than ten minutes away is a replay, not news
PASS Linear: a comment or a project update is not an issue event
PASS generic: {"issue": "PROJ-12", "state": "done"} and friends; an unknown state is ignored
PASS ship only when every linked issue is done (none linked: never)
PASS ship loop: no outbound call to any tracker, and no tracker token stored
PASS ship loop: both secrets are redacted from the shareable backup by the *_secret pattern
== 3.2 pass B · duplicate suggestions (local text similarity) ==
PASS words: lower case, common words dropped, light English endings trimmed
PASS words: letters of any language, digits kept
PASS suggest: "Export reports to CSV" -> CSV export; "dark theme" -> Dark mode; "keyboard shortcut for search" -> Keyboard shortcuts
PASS suggest: nothing for an unrelated idea, nothing for common words alone
PASS suggest: best first, capped, scores between 0 and 1
PASS queue: the same request twice is paired (older first), different ones are not
PASS queue: a pair the team marked "not duplicates" is not suggested again
PASS no cloud: Similar.php makes no network call
== 3.2 pass B · custom fields on ideas and voters ==
PASS options: trimmed, first spelling wins, empties dropped
PASS choice: any case maps to the option as written; anything else is refused; empty clears
PASS number: "3,5" -> 3.5, "12.50" -> 12.5, "-4" -> -4; words refused
PASS text: spaces tidied, 200 characters at most; a list is not a value
PASS voter fields: names that are names, values that are values, ten at most, never an error
PASS the sign-in token carries them: VoterSso::verify() returns cleaned "fields"
== 3.2 pass B · the public board in the visitor's language ==
PASS packs: five languages, every English line in each, none blank, every placeholder kept, twelve months
PASS resolve: a ?lang= link, then the cookie, then the browser (by q), then the board's — only among the offered
PASS settings form: only known languages, never the default twice
PASS status names: the six seeds follow the language while unchanged; the team's own stay as typed
PASS plurals: French zero is singular; Polish 1 / 2–4 / 5 / 12 / 22
PASS dates and "ago": French and German forms; English exactly as before 3.2
PASS messages: a known sentence is translated, anything else unchanged
PASS the team's screens are never translated: only public paths resolve a language
== 3.2 pass B · CRM data (CSV or API; no CRM login) ==
PASS money: "1,250.50", "€49", "49,50", "1,250", 49 -> cents; words and negatives refused
PASS contact: email required (lower-cased), MRR wins over ARR, ARR / 12, fields cleaned
PASS CSV (HubSpot-style headers, BOM): email, plan, MRR read; other columns become fields; a bad email skipped; the last row for an email wins
PASS CSV (Salesforce-style, semicolons, ARR): read, ARR / 12
PASS CSV without an email column: nothing, and says so
PASS no CRM login, no CRM call: Crm.php holds no network code
PASS demo: Dark mode is linked to a merged merge request and an open issue (it waits for the last one), after two resets
PASS demo: the rest of pass B shows — two idea fields with values, a segment on every customer, a duplicate pair, a private Business board, five languages
PASS demo: no webhook secret, so the demo's tracker doors answer "not found"
== 3.2 pass B · private boards ==
PASS plans: "Business, beta , ,Pro" -> business, beta, pro
PASS a public board: everyone; a private one: not a visitor
PASS a voter signed in from the app: boards that allow voters, and only the plans listed
PASS a Pro voter: not a board kept for Business and Beta
PASS the team sees every board
PASS Board::posts() leaves out the boards it is told to hide (the public pages pass the private ones)
PASS schema: a board starts public
PASS db (SQLite): settings: no secrets, and ship-on-done on by default
PASS db (SQLite): link: two issues on one idea
PASS db (SQLite): link: the same issue twice is refused by the unique index
PASS db (SQLite): link: a non-address is refused
PASS db (SQLite): one of two done: recorded, not shipped
PASS db (SQLite): both done: the idea ships
PASS db (SQLite): both done: a changelog DRAFT (unpublished) with the idea's title and text
PASS db (SQLite): both done: not on the public changelog until the team publishes it
PASS db (SQLite): the ship is audited, as the tracker
PASS db (SQLite): the same event again: nothing more (no second ship, no second draft)
PASS db (SQLite): reopened: the link is open again; the idea stays Shipped (the team decides)
PASS db (SQLite): dropped (not planned / canceled): recorded, never shipped
PASS db (SQLite): an idea the team already closed (e.g. Declined) stays as the team left it
PASS db (SQLite): ship-on-done switched off: the state is recorded, the idea does not move
PASS db (SQLite): an idea with no links never ships on an event
PASS db (SQLite): unlink: removes the link and is audited
PASS db (SQLite): a 500-character address is stored whole under strict mode
PASS db (SQLite): merge: done
PASS db (SQLite): merge: votes move, a voter who voted for both counts once (3 + 2 - 1 = 4)
PASS db (SQLite): merge: followers move, one per address whatever its case
PASS db (SQLite): merge: comments and linked issues move; an internal note records it
PASS db (SQLite): merge: the merged idea keeps its words and leaves the lists
PASS db (SQLite): merge: audited
PASS db (SQLite): merge: refused into itself, twice, or into an idea that was itself merged
PASS db (SQLite): duplicates: a dismissed pair is stored once
PASS db (SQLite): fields: the submit form saves only the fields it asks for (a visitor cannot set the team's estimate)
PASS db (SQLite): fields: a value a field does not take is refused by name; the others save
PASS db (SQLite): fields: the public page shows only public fields
PASS db (SQLite): fields: an empty value clears the field
PASS db (SQLite): fields: filter Posts by a field value
PASS db (SQLite): voter fields: stored from the token, and ideas filter by who asked (segment = Enterprise)
PASS db (SQLite): voter fields: a token without fields keeps them; one with fields replaces them (120 multibyte characters stored whole)
PASS db (SQLite): crm: fills a signed-in voter's missing plan and value by email (any case); the app's own field wins over the CRM's
PASS db (SQLite): crm: a later import refreshes what the CRM filled (and keeps facts it did not repeat)
PASS db (SQLite): crm: once the app's token says the plan and value, the CRM never overrides them
PASS db (SQLite): crm: a voter who signs in after the import is filled at sign-in
PASS db (SQLite): crm: a contact without a value stores none (null), under strict mode
PASS db (SQLite): crm: "forget all CRM data" removes the contacts and what they filled in; the app's own stays
PASS db: the run made every check it planned (38)
PASS http: owner signed in
PASS http: idea page: a "Linked issues" card with an address box
PASS http: link: GitHub and Linear issues listed with their provider, label and state
PASS http: link: a non-address is refused with a reason, and never becomes a link
PASS http: trackers' doors: 404 before a secret exists (the install looks like one without the feature)
PASS http: settings: the Ship loop section with each tracker's address
PASS http: webhook secret: created, shown once, then gone
PASS http: GitHub: a wrong signature is refused (401) and changes nothing
PASS http: GitHub: the set-up ping is answered
PASS http: GitHub: issue closed as completed -> recorded; the idea waits for its Linear issue
PASS http: the trackers' doors set no cookie (no session for a webhook)
PASS http: Linear: signed with the GitHub/GitLab secret instead of Linear's -> refused
PASS http: Linear: completed -> both issues done -> Dark mode is Shipped
PASS http: the changelog entry waits as a draft: on the team's changelog page, not the public one
PASS http: idea page: both links now say done
PASS http: audit: the ship is recorded as the tracker's
PASS http: GitLab: an issue closed and a merge request merged each ship their idea
PASS http: GitLab: a wrong token is refused
PASS http: Jira rule: no token -> 401; with it, SEC-12 done -> SAML sign-in ships
PASS http: an event the loop does not use is acknowledged and ignored
PASS http: a body that is not JSON: 400
PASS http: a body over 1 MB: 413, before anything is read into the database
PASS http: an unknown door: 404
PASS http: API: POST /api/posts/{id}/issues links an issue (201) and lists the idea's issues
PASS http: API: a read-only key cannot link (403) but can list
PASS http: API: a non-address is a 422
PASS http: unlink from the idea page
PASS http: Linear secret forgotten: its door answers 404 again
PASS http: private boards: a board is public until the team says otherwise
PASS http: settings: each board has "who can see it"
PASS http: the team makes "Beta program" private: voters from the app on Business or Beta only
PASS http: a visitor: the board and its idea are "not found", like ones that do not exist
PASS http: a visitor: not on the home page, the board tabs, the roadmap or the sitemap
PASS http: a visitor cannot vote on, comment on or follow an idea on a private board (404, nothing stored)
PASS http: a visitor cannot post onto a private board: the idea lands on a public one, and the form never offers it
PASS http: the team sees the private board and its ideas
PASS http: a Business voter signed in from the app sees the board and can vote there
PASS http: a Pro voter does not (the board is for Business and Beta)
PASS http: the last public board cannot go private (visitors land there)
PASS http: the embedded widget shows a public board, never the private one
PASS http: /similar: a visitor typing "dark theme" is shown Dark mode, with votes and address
PASS http: /similar: never a private board's idea for a visitor; the team sees it
PASS http: /similar: under three characters, nothing
PASS http: submit form: the "Already asked? Vote there instead" box and its lookup
PASS http: duplicates queue: pairs the same requests (CSV export ~ Export reports as CSV, Webhooks ~ Webhooks on comments)
PASS http: duplicates queue: "Not duplicates" removes the pair for good
PASS http: duplicates queue: merge from the queue
PASS http: idea page: "Similar ideas" lists Dark mode with a merge button
PASS http: idea page: merge lands on the idea it joined, with the vote moved
PASS http: the merged idea's public address leads to the one it joined (301), and it is off the board
PASS http: the merged idea's admin page says where it went, and offers no more merges
PASS http: settings: the "Idea fields" section
PASS http: settings: two fields added; a choice with one option is refused
PASS http: submit form: asks for Area (optional), not the team's Estimate
PASS http: submit: Area saved as the option is written; a crafted Estimate is ignored
PASS http: public idea page: shows Area: Billing
PASS http: idea page: a Fields card with both fields
PASS http: idea page: the team sets Estimate 3,5 -> 3.5; "lots" is refused by name and changes nothing
PASS http: Posts: filter by Area = Reports
PASS http: voter fields from the token: Posts filters by segment = Enterprise, and "Who asked" shows them
PASS http: API: an idea carries its fields; its voters carry theirs
PASS http: API: create with fields by label (any case); a value the field does not take is a 422 and nothing is created
PASS http: CSV export: a column per field
PASS http: privacy: the export includes what the app said about the person; erase removes it
PASS http: settings: removing a field removes its values
PASS http: API: a viewer's full-scope key reads but cannot file, move, reply or link (403 each, nothing written)
PASS http: API: a member files and replies (201) but cannot move a status (403), as on the agent endpoint; an admin can (200)
PASS http: language: English by default, no language links in the footer
PASS http: settings: the "Board language" section
PASS http: language: the board in French — nav, sorts, a seeded status, footer links for each offered language
PASS http: language: what people wrote stays as written
PASS http: language: a ?lang=de link is remembered on the next page; the team's screens stay English
PASS http: language: a first visit follows the browser among the offered (de), else the board's (es is not offered -> fr)
PASS http: language: a message after a form is in the visitor's language (German)
PASS http: language: the vote answer is too (and votera.js gets its words)
PASS http: language: not-found page, widget and changelog in German
PASS http: language: back to English only — the remembered German no longer applies
PASS http: settings: the "CRM data" section
PASS http: CSV import: Cara (signed in, voted) gets the CRM's plan, value and company under "Who asked"; the flash says what was read
PASS http: "Open in CRM": an address without https:// is refused; a good one links each voter by email
PASS http: API: an admin's key sends contacts (201: stored 1, invalid 1, 1 voter filled; ARR / 12); a member's key cannot (403)
PASS http: privacy export: includes the CRM contact
PASS http: "Forget all CRM data": the contacts and what they filled in go
PASS http: the settings flash for a CSV with no email column
PASS http: slack: off by default — the door answers 404 and records nothing
PASS http: slack: the settings card gives the request address to paste into Slack
PASS http: slack: switched on with the signing secret, stored and never shown again
PASS http: slack: a wrong signature, or a request over five minutes old, is refused (401) and counts nothing
PASS http: slack: the top ideas, public boards only, each linked, only to the asker
PASS http: slack: a vote counts once per Slack person; the title is escaped for Slack
PASS http: slack: a vote taken back, once
PASS http: slack: a private board's idea is not there; anything else gets the help line
PASS http: hold for review: the setting is there, off by default
PASS http: hold for review: switched on from Settings
PASS http: a visitor's idea is held: they are told it appears once the team has looked, and sent to the board, not its page
PASS http: held: nobody else sees it — its page is 404, the board, similar ideas and the sitemap leave it out, and it takes no vote
PASS http: the team sees it: the dashboard counts it, the list marks it, its page offers Approve
PASS http: a member (who triages but does not moderate) cannot approve: no button, and the action is refused
PASS http: approved: on the board for everyone, and in the audit trail
PASS http: a signed-in team member's idea goes straight up
PASS http: the server log carries no PHP warning or fatal
PASS http: the pass B walk made every check it planned (101)
== customer voice · vote from Slack (research 28, Q291) ==
PASS slack signature: Slack's v0 scheme; a changed body, a wrong secret, a stale or odd timestamp, or no secret at all are refused
PASS slack command: "" and "top" list; "12", "#12", "vote 12" vote; "unvote 12" takes back; anything else is help
PASS slack voter: one key per person per workspace, salted, nothing readable in it
PASS slack door: fields Slack sends flat are read as strings only (a list in a signed body is noise, not a warning)
PASS slack text: &, < and > escaped; the reply goes only to the asker
PASS import dry run: with a board: 1 ready, the untitled row would be skipped, nothing written
PASS import dry run: with no board to put them on: the dry run says every row would be skipped, as the import then does
PASS import dry run: the upload's dry run goes through the same row rule, not the validate-only mode
----------------------------------------
== 4.0: the Update button is wired ==
PASS updates: the release public key is pinned
PASS updates: Database::migrate() creates the updater table (in code, not in a comment)
PASS updates: the updater table holds exactly one state row
PASS updates: index.php clears stale compiled code before anything else loads
PASS updates: index.php finishes or rolls back an update before any controller loads, with this app's canary
PASS updates: route get /admin/updates is updates_page()
PASS updates: route post /admin/updates/key is updates_key_save()
PASS updates: route post /admin/updates/check is updates_check()
PASS updates: route post /admin/updates/apply is updates_apply()
PASS updates: updates_page() opens with the restore page's admin gate
PASS updates: updates_key_save() opens with the restore page's admin gate
PASS updates: updates_check() opens with the restore page's admin gate
PASS updates: updates_apply() opens with the restore page's admin gate
PASS updates: every POST checks the CSRF token
PASS updates: the page is told why updates are off here (Updater::whyOff, F5 A1 pass)
PASS updates: the menu links to the page
PASS updates: bin/update.php refuses the web and finishes in a fresh process
PASS updates: where updates are off (Cloud, the demo, a missing extension) the page says why and offers no button
PASS updates: with a key and a newer signed release the page offers "Back up and update"
== 4.0 A2: sample data, backups, health ==
PASS A2: the owner's-install checks ran to the end
PASS A2: an owner's own install (no Cloud config, no demo marker)
PASS A2: a fresh install's sample tables are all empty, so the installer's sample data can go in
PASS A2: switched off: an install without "Start with sample data" gets none
PASS A2: first request: the marker is used once and the sample business is there
PASS A2: every one of the owner's settings is theirs, not the sample's
PASS A2: the sample logins cannot sign in: no password matches, and switched off where the product can
PASS A2: the owner's own login is untouched
PASS A2: the load is in the audit log
PASS A2: a second marker on a Votera with records seeds nothing and changes nothing
PASS A2: real use: one of the owner's own rows among the sample
PASS A2: remove: the owner's own row stays, and a sample row it points at is kept
PASS A2: remove: the report says what went, and what was kept because the owner's records use it
PASS A2: remove: the rest of the sample is gone
PASS A2: remove: every kept sample row keeps all its own parts
PASS A2: remove: no row is left pointing at a row that was removed
PASS A2: remove: the sample logins are gone but the ones kept rows use, the owner stays, and nothing counts as sample any more
PASS A2: remove: the sample's files are gone from disk (0 written; one a row that stays names stays with it)
PASS A2: remove: the owner's own uploaded file is untouched
PASS A2: "Load demo data" on a Votera with records refuses and deletes nothing
PASS A2: backup: written, sent to the bucket (a signed PUT), and read back
PASS A2: backup: the bucket secret is not in the backup it protects
PASS A2: backup: the owner's "keep" count is honored (2 kept)
PASS A2: switched off: with automatic backups off, a backup is never due however old the last one
PASS A2: the backup lines for Backup & restore: last backup fine, offsite fine, read-back fine
PASS A2: an offsite endpoint must be https for an owner (plain http only in tests)
PASS A2: all 25 owner's-install checks reported
PASS A2: Database::migrate() creates the sample-data and backup tables (in code, not in a comment)
PASS A2: index.php loads the three cores and the wiring
PASS A2: the first request seeds the installer's sample, and an owner's install backs itself up when due
PASS A2: route post /admin/sample-data/remove is sample_data_remove()
PASS A2: route post /admin/restore/backups is backups_save()
PASS A2: route post /admin/restore/backup-now is backup_now()
PASS A2: route get /admin/health is health_page()
PASS A2: sample_data_remove() opens with the restore page's admin gate
PASS A2: backups_save() opens with the restore page's admin gate
PASS A2: backup_now() opens with the restore page's admin gate
PASS A2: health_page() opens with the restore page's admin gate
PASS A2: every POST checks the CSRF token
PASS A2: off an owner's install (Cloud, the demo) the backup settings and "Back up now" refuse in words
PASS A2: the installer offers sample data, ticked, and only leaves the first-run marker (with every table's row count)
PASS A2: the first run is strictly once (a marker that cannot be removed seeds nothing), and treats the installer's rows as its defaults only while nothing has changed
PASS A2: the scheduled-backup URL runs the same backup (offsite copy and read-back included)
PASS A2: the menu links to Health
PASS A2: the sample-data banner shows only to an admin, only while sample data is there, with its Remove button, and stays put (nested: the UI kit toasts a bare .main > .alert away)
PASS A2: Backup & restore shows the automatic backups, "Back up now" and the bucket settings (none on Cloud)
SKIP A2: the Health page render (this suite loads no view helpers in its own process; the owner's-install check and update_e2e.sh run the real app)
PASS erase planting · sqlite: the subject is created and a marker is planted in all 15 personal columns
PASS erase planting · sqlite: the app's own erase runs through its route and reports no failure
PASS erase planting · sqlite: no planted marker survives anywhere (31 tables dumped), outside the 4 column(s) kept by ruling (possible legal records)
PASS erase planting · sqlite: the erase page (/admin/privacy) says what an erase keeps: "Kept: the ideas and comments they wrote and the answers they…"
PASS erase planting · sqlite: MANUAL.md says the same: "Kept: the ideas and comments they wrote and the answers they…"
550 passed, 0 failed
ALL GREEN
← Back to Votera · Manual · Quickstart · API