'array']); app()->setLocale('en'); $this->ledger = new Ledger(); } /** An open poll with a slot question, a scale 0-2, a ranking and a free text question. */ private function makePoll(array $attributes = []): Poll { $postId = DB::table('posts')->insertGetId([ 'postable_id' => 1, 'postable_type' => Admin::class, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('post_translations')->insert([ 'post_id' => $postId, 'locale' => 'en', 'slug' => 'verify-' . $postId, 'title' => 'Verified poll', 'from' => '2026-01-01', 'created_at' => now(), 'updated_at' => now(), ]); $poll = Poll::create(array_merge(['post_id' => $postId, 'status' => Poll::STATUS_OPEN, 'results_visibility' => Poll::RESULTS_AFTER_VOTE], $attributes)); foreach ([ ['type' => 'date_time_slots', 'settings' => ['allow_if_needed' => true, 'slots' => [['key' => 'k9', 'starts_at' => '2026-11-02 10:00', 'ends_at' => null], ['key' => 'a1', 'starts_at' => '2026-11-03 10:00', 'ends_at' => null]]]], ['type' => 'scale', 'settings' => ['min' => 0, 'max' => 2]], ['type' => 'ranking', 'settings' => ['options' => [['key' => 'x', 'label' => ['en' => 'X']], ['key' => 'y', 'label' => ['en' => 'Y']]], 'top_n' => null]], ['type' => 'text', 'settings' => ['multiline' => true, 'max_length' => 200]], ] as $position => $question) { PollQuestion::create($question + ['poll_id' => $poll->id, 'position' => $position + 1, 'required' => false, 'label' => ['en' => 'Q' . ($position + 1)]]); } return $poll->fresh(); } /** * Cast a vote the way PollForm does: response, answers and ledger entry together. * * @param array $answersByPosition keyed by question position (0-based) */ private function vote(Poll $poll, array $answersByPosition, ?string $receipt = null): array { $user = User::factory()->create(); $questions = $poll->questions()->get()->values(); $normalized = []; foreach ($answersByPosition as $position => $answer) { $normalized[$questions[$position]->id] = $answer; } $receipt = $receipt ?? $this->ledger->newReceiptCode(); $response = DB::transaction(function () use ($poll, $user, $normalized, $receipt) { $entry = $this->ledger->append($poll, PollLedgerEntry::CAST, $this->ledger->ballotPayload($normalized), $this->ledger->fingerprint($receipt)); $response = PollResponse::create([ 'poll_id' => $poll->id, 'voter_id' => $user->id, 'voter_type' => User::class, 'user_id' => $user->id, 'dedupe_key' => $poll->dedupeKey($user, $user->id), 'ledger_sequence' => $entry->sequence, 'submitted_at' => now(), ]); foreach ($normalized as $questionId => $answer) { PollAnswer::create(['poll_response_id' => $response->id, 'poll_question_id' => $questionId] + $answer); } return $response; }); return [$response, $receipt]; } private function populated(): Poll { $poll = $this->makePoll(); $this->vote($poll, [0 => ['value' => ['k9' => 'yes', 'a1' => 'if_needed'], 'text' => null], 1 => ['value' => 0, 'text' => null], 2 => ['value' => ['y', 'x'], 'text' => null], 3 => ['value' => null, 'text' => "Café \u{2028} \"quotes\" / ünïcode"]]); $this->vote($poll, [0 => ['value' => ['a1' => 'yes', 'k9' => 'no'], 'text' => null], 1 => ['value' => 2, 'text' => null], 2 => ['value' => ['x', 'y'], 'text' => null]]); $this->vote($poll, [1 => ['value' => 10 - 9, 'text' => null], 3 => ['value' => null, 'text' => 'short']]); return $poll; } /** Log in as one of the poll's voters (results are visible to them after voting). */ private function actAsVoter(Poll $poll): User { $user = User::findOrFail(PollResponse::where('poll_id', $poll->id)->value('user_id')); $this->actAs($user); return $user; } private function actAs(\Illuminate\Database\Eloquent\Model $profile, ?User $user = null): void { $this->actingAs($user ?? $profile, 'web'); session(['activeProfileType' => get_class($profile), 'activeProfileId' => $profile->getKey()]); } private function ledgerJson(Poll $poll): array { if (!auth('web')->check()) { $this->actAsVoter($poll); } $response = app(PollVerificationController::class)->ledger($poll, $this->ledger); return json_decode($response->getContent(), true); } /** Run resources/js/poll-ledger-verifier.js in Node on the given ledger JSON. */ private function browserVerify(array $data): array { $node = trim((string) shell_exec('command -v node')); if ($node === '') { $this->markTestSkipped('Node is not installed'); } $script = 'const fs = require("fs");' . 'eval(fs.readFileSync(process.argv[1], "utf8"));' . 'globalThis.PollLedgerVerifier.verify(JSON.parse(fs.readFileSync(0, "utf8")))' . '.then((r) => process.stdout.write(JSON.stringify(r)));'; $process = new Process([$node, '-e', $script, resource_path('js/poll-ledger-verifier.js')]); $process->setInput(json_encode($data)); $process->mustRun(); return json_decode($process->getOutput(), true); } private function checks(array $result): array { return array_column($result['checks'], 'ok', 'key'); } // ----------------------------------------------------------------- public function test_browser_verifier_accepts_an_untouched_ledger(): void { $poll = $this->populated(); $result = $this->browserVerify($this->ledgerJson($poll)); $this->assertTrue($result['valid'], json_encode($result)); $this->assertSame(['chain' => true, 'recount' => true], $this->checks($result)); } public function test_browser_verifier_detects_tampering(): void { $data = $this->ledgerJson($this->populated()); $tampered = $data; $tampered['entries'][1]['payload']['answers'][1]['value'] = 1; $this->assertFalse($this->checks($this->browserVerify($tampered))['chain']); $removed = $data; array_splice($removed['entries'], 1, 1); $this->assertFalse($this->checks($this->browserVerify($removed))['chain']); // A published result that differs from the ballots $inflated = $data; $inflated['published_tally'][0]['count']++; $result = $this->checks($this->browserVerify($inflated)); $this->assertTrue($result['chain']); $this->assertFalse($result['recount']); } public function test_seal_and_withdrawal_after_it(): void { $poll = $this->populated(); $poll->update(['status' => Poll::STATUS_CLOSED]); Artisan::call('polls:seal'); $poll->refresh(); $this->assertNotNull($poll->sealed_at); $seal = PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->sole(); $this->assertSame($seal->hash, $poll->seal_hash); $this->assertSame(3, $seal->payload['active_ballots']); // Idempotent Artisan::call('polls:seal'); $this->assertSame(1, PollLedgerEntry::where('poll_id', $poll->id)->where('entry_type', PollLedgerEntry::SEAL)->count()); // A profile deleted after closing: its ballot is withdrawn after the seal, the seal still verifies DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, 1)); PollResponse::where('poll_id', $poll->id)->where('ledger_sequence', 1)->delete(); $result = $this->browserVerify($this->ledgerJson($poll)); $this->assertTrue($result['valid'], json_encode($result)); $this->assertSame(['chain' => true, 'recount' => true, 'seal' => true], $this->checks($result)); // A different seal hash is caught $data = $this->ledgerJson($poll); $data['seal_hash'] = str_repeat('f', 64); $this->assertFalse($this->checks($this->browserVerify($data))['seal']); } public function test_polls_past_closing_time_are_sealed_and_open_ones_not(): void { $ended = $this->makePoll(['closes_at' => now()->subMinute()]); $open = $this->makePoll(['closes_at' => now()->addDay()]); Artisan::call('polls:seal'); $this->assertNotNull($ended->fresh()->sealed_at); $this->assertNull($open->fresh()->sealed_at); } public function test_page_shows_state_recount_and_anonymous_ballots(): void { $poll = $this->populated(); $this->actAsVoter($poll); $html = app(PollVerificationController::class)->show($poll, $this->ledger)->render(); $this->assertStringContainsString('Verified poll', $html); $this->assertStringContainsString('intact', $html); $this->assertStringContainsString('The recount of the ballots matches the published result.', $html); $this->assertStringContainsString('[text]', $html); $this->assertStringNotContainsString('ünïcode', $html); // free text stays hidden $this->assertStringContainsString('root.PollLedgerVerifier = {', $html); // the verifier code is on the page } public function test_receipts_level_hides_the_ballot_list_and_json(): void { $poll = $this->populated(); $poll->update(['verification' => Poll::VERIFICATION_RECEIPTS]); $user = User::factory()->create(); $this->actingAs($user, 'web'); session(['activeProfileType' => User::class, 'activeProfileId' => $user->id]); $html = app(PollVerificationController::class)->show($poll, $this->ledger)->render(); $this->assertStringContainsString('Check your vote', $html); $this->assertStringNotContainsString('All ballots', $html); $this->expectException(NotFoundHttpException::class); app(PollVerificationController::class)->ledger($poll, $this->ledger); } public function test_verification_off_or_draft_is_not_found(): void { $off = $this->makePoll(['verification' => Poll::VERIFICATION_OFF]); $this->assertSame('posts.not_found', app(PollVerificationController::class)->show($off, $this->ledger)->name()); $draft = $this->makePoll(['status' => Poll::STATUS_DRAFT]); $this->assertSame('posts.not_found', app(PollVerificationController::class)->show($draft, $this->ledger)->name()); } // ----------------------------------------------------------------- // Who may see what (security audit F1) // ----------------------------------------------------------------- private function showHtml(Poll $poll): ?string { $view = app(PollVerificationController::class)->show($poll, $this->ledger); return $view->name() === 'posts.not_found' ? null : $view->render(); } private function ledgerAllowed(Poll $poll): bool { try { app(PollVerificationController::class)->ledger($poll, $this->ledger); return true; } catch (NotFoundHttpException) { return false; } } public function test_profiles_the_poll_is_not_for_get_not_found(): void { $poll = $this->populated(); $poll->update(['target_profile_types' => [\App\Models\Organization::class]]); $user = User::factory()->create(); $this->actAs($user); $this->assertNull($this->showHtml($poll)); $this->assertFalse($this->ledgerAllowed($poll)); // Its organization may $organization = \App\Models\Organization::factory()->create(); $user->organizations()->attach($organization->id); $this->actAs($organization, $user); $this->assertNotNull($this->showHtml($poll)); } public function test_unpublished_poll_is_not_found(): void { $poll = $this->populated(); DB::table('post_translations')->where('post_id', $poll->post_id)->update(['from' => now()->addWeek()]); $this->actAsVoter($poll); $this->assertNull($this->showHtml($poll)); $this->assertFalse($this->ledgerAllowed($poll)); } public function test_results_never_hides_the_ballots_from_voters(): void { $poll = $this->populated(); $poll->update(['results_visibility' => Poll::RESULTS_NEVER]); $this->actAsVoter($poll); $html = $this->showHtml($poll); $this->assertNotNull($html); $this->assertStringContainsString('Check your vote', $html); // the receipt check stays $this->assertStringNotContainsString('All ballots', $html); $this->assertStringNotContainsString('root.PollLedgerVerifier', $html); $this->assertStringContainsString('when the results of this poll are visible to you', $html); $this->assertFalse($this->ledgerAllowed($poll)); } public function test_results_after_vote_only_for_those_who_voted(): void { $poll = $this->populated(); $nonVoter = User::factory()->create(); $this->actAs($nonVoter); $this->assertStringNotContainsString('All ballots', $this->showHtml($poll)); $this->assertFalse($this->ledgerAllowed($poll)); $this->actAsVoter($poll); $this->assertStringContainsString('All ballots', $this->showHtml($poll)); $this->assertTrue($this->ledgerAllowed($poll)); } public function test_results_after_close_only_once_closed(): void { $poll = $this->populated(); $poll->update(['results_visibility' => Poll::RESULTS_AFTER_CLOSE]); $this->actAsVoter($poll); $this->assertFalse($this->ledgerAllowed($poll)); $poll->update(['status' => Poll::STATUS_CLOSED]); $this->assertTrue($this->ledgerAllowed($poll->fresh())); } public function test_receipt_checks_are_rate_limited(): void { $poll = $this->makePoll(); $user = User::factory()->create(); $this->actAs($user); $component = Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id]); for ($i = 0; $i < 20; $i++) { $component->set('code', 'AAAAA-BBBBB-CCCCC-DDDD' . chr(65 + $i))->call('check')->assertHasNoErrors(); } $component->set('code', 'AAAAA-BBBBB-CCCCC-DDDDZ')->call('check') ->assertHasErrors('code') ->assertSee('Too many checks'); } public function test_receipt_check_shows_the_ballot_and_its_own_text(): void { $poll = $this->makePoll(); [, $receipt] = $this->vote($poll, [1 => ['value' => 2, 'text' => null], 3 => ['value' => null, 'text' => 'Bring cake']]); Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id]) ->set('code', strtolower($receipt)) ->call('check') ->assertSet('notFound', false) ->assertSee('Ballot #1') ->assertSee('Counted in the result.') ->assertSee('Bring cake') ->assertSee('text matches its fingerprint'); Livewire::test(ReceiptCheck::class, ['pollId' => $poll->id]) ->set('code', 'AAAAA-BBBBB-CCCCC-DDDDD') ->call('check') ->assertSet('notFound', true) ->assertSee('No ballot with this receipt code'); } }