Ownware
HomeVoteraTest run
Votera · Test run · run 2026-09-06

Votera Test run, as shipped in the download

167 checks passed, 0 failed, when the suite that ships in the zip was run on 2026-09-06 with php tests/run.php. Run it yourself after you download.

== 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 behaviour)
  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 behaviour 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 synthesises 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 v3.1.2
  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: 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 tokenised colours (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), honours 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

----------------------------------------
  167 passed, 0 failed
  ALL GREEN
  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

← Back to Votera · Manual · Quickstart · API

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →