Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user