'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']); } }