Files

270 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature\Polls;
use App\Models\Admin;
use App\Models\Poll;
use App\Models\PollLedgerEntry;
use App\Polls\Ledger;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use LogicException;
use Tests\TestCase;
/**
* The append-only ballot ledger: hash chain, tamper detection, changes,
* withdrawals and receipts.
*/
class PollLedgerTest 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']);
$this->ledger = new Ledger();
}
private function makePoll(): Poll
{
$postId = DB::table('posts')->insertGetId([
'postable_id' => 1,
'postable_type' => Admin::class,
'created_at' => now(),
'updated_at' => now(),
]);
return Poll::create(['post_id' => $postId, 'status' => Poll::STATUS_OPEN]);
}
private function cast(Poll $poll, array $normalized, ?string $receipt = null, string $type = PollLedgerEntry::CAST, ?int $refersTo = null): PollLedgerEntry
{
return DB::transaction(fn () => $this->ledger->append(
$poll,
$type,
$this->ledger->ballotPayload($normalized),
$this->ledger->fingerprint($receipt ?? $this->ledger->newReceiptCode()),
$refersTo
));
}
public function test_canonical_format_is_stable(): void
{
// Golden value: the browser-side verification must produce exactly this hash.
// Changing it breaks every existing chain, see Ledger::FORMAT_VERSION.
$entry = [
'poll_id' => 7,
'sequence' => 1,
'entry_type' => 'cast',
'fingerprint' => str_repeat('a', 64),
'refers_to_sequence' => null,
'payload' => ['answers' => [
['question_id' => 3, 'value' => ['zz' => 'yes', 'ab' => 'no'], 'text' => null],
['question_id' => 5, 'value' => null, 'text' => 'Café / "quoted" ü'],
]],
'recorded_at' => '2026-10-10T17:00:00Z',
'previous_hash' => Ledger::GENESIS_HASH,
];
$expectedCanonical = '{"entry_type":"cast","fingerprint":"' . str_repeat('a', 64) . '","payload":{"answers":['
. '{"question_id":3,"text":null,"value":{"ab":"no","zz":"yes"}},'
. '{"question_id":5,"text":"Café / \"quoted\" ü","value":null}]},'
. '"poll_id":7,"previous_hash":"' . Ledger::GENESIS_HASH . '","recorded_at":"2026-10-10T17:00:00Z",'
. '"refers_to_sequence":null,"sequence":1,"v":1}';
$this->assertSame(hash('sha256', $expectedCanonical), $this->ledger->hash($entry));
}
public function test_ballot_payload_orders_answers_by_question(): void
{
$payload = $this->ledger->ballotPayload([
9 => ['value' => 'b', 'text' => null],
2 => ['value' => ['x', 'y'], 'text' => null],
]);
$this->assertSame([2, 9], array_column($payload['answers'], 'question_id'));
// Free text is committed to by its hash only
$payload = $this->ledger->ballotPayload([3 => ['value' => null, 'text' => 'Ask the neighbours']]);
$this->assertSame(['question_id' => 3, 'value' => null, 'text_sha256' => hash('sha256', 'Ask the neighbours')], $payload['answers'][0]);
$this->assertStringNotContainsString('neighbours', json_encode($payload));
}
public function test_chain_links_entries_and_verifies(): void
{
$poll = $this->makePoll();
$first = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
$second = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
$this->assertSame(1, $first->sequence);
$this->assertSame(Ledger::GENESIS_HASH, $first->previous_hash);
$this->assertSame($first->hash, $second->previous_hash);
$this->assertMatchesRegularExpression('/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', $second->recorded_at);
$result = $this->ledger->verifyChain($poll);
$this->assertTrue($result['valid']);
$this->assertSame(2, $result['entries']);
$this->assertSame($second->hash, $result['head']);
}
public function test_entries_are_recorded_with_the_day_only(): void
{
config(['app.timezone' => 'Europe/Paris']);
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 14:37:12', 'Europe/Paris'));
$ledger = new Ledger();
$poll = $this->makePoll();
$first = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'a')));
\Illuminate\Support\Carbon::setTestNow(\Illuminate\Support\Carbon::parse('2026-10-11 23:59:59', 'Europe/Paris'));
$second = DB::transaction(fn () => $ledger->append($poll, PollLedgerEntry::CAST, ['answers' => []], hash('sha256', 'b')));
\Illuminate\Support\Carbon::setTestNow();
// Start of the local day, stored in UTC: no hour, minute or second of the vote
$this->assertSame('2026-10-10T22:00:00Z', $first->recorded_at);
$this->assertSame($first->recorded_at, $second->recorded_at);
$this->assertTrue($ledger->verifyChain($poll)['valid']);
}
public function test_chains_are_per_poll(): void
{
$pollA = $this->makePoll();
$pollB = $this->makePoll();
$this->cast($pollA, [1 => ['value' => 'yes', 'text' => null]]);
$this->assertSame(1, $this->cast($pollB, [1 => ['value' => 'yes', 'text' => null]])->sequence);
}
public function test_tampering_with_a_payload_breaks_the_chain(): void
{
$poll = $this->makePoll();
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
$target = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
// Bypasses the model, as a direct DB edit would (the test DB user still has UPDATE)
DB::table('poll_ledger')->where('id', $target->id)
->update(['payload' => json_encode(['answers' => [['question_id' => 1, 'value' => 'yes', 'text' => null]]])]);
$result = $this->ledger->verifyChain($poll);
$this->assertFalse($result['valid']);
$this->assertSame(2, $result['broken_at']);
}
public function test_removing_an_entry_breaks_the_chain(): void
{
$poll = $this->makePoll();
$this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
$middle = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
DB::table('poll_ledger')->where('id', $middle->id)->delete();
$result = $this->ledger->verifyChain($poll);
$this->assertFalse($result['valid']);
$this->assertSame(3, $result['broken_at']);
}
public function test_rehashing_a_tampered_entry_still_breaks_the_next_link(): void
{
$poll = $this->makePoll();
$target = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]]);
$this->cast($poll, [1 => ['value' => 'no', 'text' => null]]);
// An attacker who also recomputes the edited entry's own hash
$attributes = $target->getAttributes();
$attributes['payload'] = ['answers' => [['question_id' => 1, 'value' => 'no', 'text' => null]]];
DB::table('poll_ledger')->where('id', $target->id)->update([
'payload' => json_encode($attributes['payload']),
'hash' => $this->ledger->hash($attributes),
]);
$result = $this->ledger->verifyChain($poll);
$this->assertFalse($result['valid']);
$this->assertSame(2, $result['broken_at']);
}
public function test_entries_cannot_be_changed_or_deleted_through_the_model(): void
{
$entry = $this->cast($this->makePoll(), [1 => ['value' => 'yes', 'text' => null]]);
try {
$entry->update(['payload' => null]);
$this->fail('Update was allowed');
} catch (LogicException $e) {
$this->assertStringContainsString('cannot be changed', $e->getMessage());
}
$this->expectException(LogicException::class);
$entry->delete();
}
public function test_append_requires_a_transaction(): void
{
$poll = $this->makePoll();
// DatabaseTransactions wraps the test in one; leave it to simulate a bare call
$level = DB::transactionLevel();
for ($i = 0; $i < $level; $i++) {
DB::rollBack();
}
try {
$this->expectException(LogicException::class);
$this->ledger->append($poll, PollLedgerEntry::CAST);
} finally {
for ($i = 0; $i < $level; $i++) {
DB::beginTransaction();
}
}
}
public function test_changes_and_withdrawals_decide_which_ballots_count(): void
{
$poll = $this->makePoll();
$a = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'AAAAA-AAAAA-AAAAA-AAAAA');
$b = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'BBBBB-BBBBB-BBBBB-BBBBB');
$c = $this->cast($poll, [1 => ['value' => 'yes', 'text' => null]], 'CCCCC-CCCCC-CCCCC-CCCCC');
// Voter A changes their vote, voter C's profile is deleted
$a2 = $this->cast($poll, [1 => ['value' => 'no', 'text' => null]], 'DDDDD-DDDDD-DDDDD-DDDDD', PollLedgerEntry::CHANGE, $a->sequence);
DB::transaction(fn () => $this->ledger->append($poll, PollLedgerEntry::WITHDRAW, ['reason' => 'profile_deleted'], null, $c->sequence));
$this->assertSame([$b->sequence, $a2->sequence], $this->ledger->activeBallots($poll)->pluck('sequence')->all());
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
$this->assertSame('replaced', $this->ledger->lookupReceipt($poll, 'AAAAA-AAAAA-AAAAA-AAAAA')['status']);
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'bbbbb bbbbb bbbbb bbbbb')['status']);
$this->assertSame('withdrawn', $this->ledger->lookupReceipt($poll, 'CCCCCCCCCCCCCCCCCCCC')['status']);
$this->assertSame('counted', $this->ledger->lookupReceipt($poll, 'DDDDD-DDDDD-DDDDD-DDDDD')['status']);
$this->assertNull($this->ledger->lookupReceipt($poll, 'EEEEE-EEEEE-EEEEE-EEEEE'));
// A receipt only works for its own poll
$this->assertNull($this->ledger->lookupReceipt($this->makePoll(), 'BBBBB-BBBBB-BBBBB-BBBBB'));
}
public function test_receipt_codes(): void
{
$code = $this->ledger->newReceiptCode();
$this->assertMatchesRegularExpression('/^[2-9A-HJKMNP-Z]{5}(-[2-9A-HJKMNP-Z]{5}){3}$/', $code);
$this->assertNotSame($code, $this->ledger->newReceiptCode());
$this->assertSame($this->ledger->fingerprint($code), $this->ledger->fingerprint(strtolower(str_replace('-', ' ', $code))));
}
public function test_payload_survives_the_json_column_round_trip(): void
{
$poll = $this->makePoll();
$this->cast($poll, [
4 => ['value' => ['k2' => 'yes', 'a1' => 'if_needed'], 'text' => null],
2 => ['value' => ['b', 'a', 'c'], 'text' => null],
3 => ['value' => 4, 'text' => null],
8 => ['value' => null, 'text' => "Two\nlines – with ünicode \u{2028} and / slash"],
9 => ['value' => [], 'text' => null],
]);
$this->assertTrue($this->ledger->verifyChain($poll)['valid']);
}
}