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