Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

@@ -0,0 +1,392 @@
<?php
namespace Tests\Feature\Polls;
use App\Http\Controllers\PollVerificationController;
use App\Http\Livewire\Polls\ReceiptCheck;
use App\Models\Admin;
use App\Models\Poll;
use App\Models\PollAnswer;
use App\Models\PollLedgerEntry;
use App\Models\PollQuestion;
use App\Models\PollResponse;
use App\Models\User;
use App\Polls\Ledger;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Symfony\Component\Process\Process;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
/**
* The verification page, the public ledger JSON, sealing, the receipt check,
* and the browser verifier (run in Node against ledgers built here).
*/
class PollVerificationTest extends TestCase
{
use DatabaseTransactions;
private Ledger $ledger;
protected function setUp(): void
{
parent::setUp();
// phpunit.xml configures Redis, which is the dev site's cache too
config(['cache.default' => '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<int, array{value: mixed, text: ?string}> $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');
}
}