270 lines
11 KiB
PHP
270 lines
11 KiB
PHP
<?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']);
|
||
}
|
||
}
|