Home›Votera›Test run
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