From 99d59d835c170709d3568c986af5467b37b17db5 Mon Sep 17 00:00:00 2001 From: Emmanuel Jones Date: Sun, 16 Aug 2026 17:00:13 -0600 Subject: [PATCH] Add idempotent v2 vote endpoint --- src/api/SETUP.md | 10 + .../2026-08-16-vote-idempotency.sql | 4 + src/api/setup-database-prod.sql | 3 + src/api/v2/README.md | 41 ++++ src/api/v2/votes.php | 214 ++++++++++++++++++ test/contract/live-php-api.test.js | 27 +++ test/php/V2VoteTest.php | 148 ++++++++++++ test/php/schema-sqlite.sql | 5 +- 8 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 src/api/migrations/2026-08-16-vote-idempotency.sql create mode 100644 src/api/v2/README.md create mode 100644 src/api/v2/votes.php create mode 100644 test/php/V2VoteTest.php diff --git a/src/api/SETUP.md b/src/api/SETUP.md index 09fc584..942998c 100644 --- a/src/api/SETUP.md +++ b/src/api/SETUP.md @@ -32,6 +32,16 @@ This will: **That's it!** Skip to the [Verify Setup](#verify-setup) section below. +### Upgrade an existing database + +Do not re-run the full schema against an existing database. Apply any SQL files +in `src/api/migrations/` that have not already been applied, in filename order. +For example, Phase 1 vote idempotency requires: + +```bash +mysql -u rcv_user -p rcv_db < src/api/migrations/2026-08-16-vote-idempotency.sql +``` + ## Manual Setup (Alternative) If you prefer to set up step-by-step or need to customize the process: diff --git a/src/api/migrations/2026-08-16-vote-idempotency.sql b/src/api/migrations/2026-08-16-vote-idempotency.sql new file mode 100644 index 0000000..957d6ef --- /dev/null +++ b/src/api/migrations/2026-08-16-vote-idempotency.sql @@ -0,0 +1,4 @@ +ALTER TABLE `votes` + ADD COLUMN `requestKey` varchar(64) DEFAULT NULL AFTER `group_answers`, + ADD COLUMN `requestHash` char(64) DEFAULT NULL AFTER `requestKey`, + ADD UNIQUE KEY `idx_ballot_request` (`ballotId`, `requestKey`); diff --git a/src/api/setup-database-prod.sql b/src/api/setup-database-prod.sql index cfc760c..fe26054 100644 --- a/src/api/setup-database-prod.sql +++ b/src/api/setup-database-prod.sql @@ -145,8 +145,11 @@ CREATE TABLE `votes` ( `date_created` datetime DEFAULT (UTC_TIMESTAMP()), `fingerprint` varchar(64) NOT NULL DEFAULT '', `group_answers` json DEFAULT NULL, + `requestKey` varchar(64) DEFAULT NULL, + `requestHash` char(64) DEFAULT NULL, PRIMARY KEY (`vote_id`), UNIQUE KEY `NoDuplicates` (`ballotId`,`voteIds`(25),`name`,`ipAddress`,`date_created`), + UNIQUE KEY `idx_ballot_request` (`ballotId`,`requestKey`), KEY `idx_ballot_fingerprint` (`ballotId`,`fingerprint`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3; diff --git a/src/api/v2/README.md b/src/api/v2/README.md new file mode 100644 index 0000000..62d2431 --- /dev/null +++ b/src/api/v2/README.md @@ -0,0 +1,41 @@ +# API v2 + +Version 2 endpoints use one JSON envelope for success and failure: + +```json +{ + "data": {}, + "error": null +} +``` + +```json +{ + "data": null, + "error": { + "code": "machine_readable_code", + "message": "Human-readable message" + } +} +``` + +## `POST /api/v2/votes.php` + +The Phase 1 anonymous-vote endpoint accepts: + +```json +{ + "key": "ballot-shortcode", + "requestId": "client_generated_16_to_64_chars", + "ranking": [42, 17, 23], + "fingerprint": "optional-installation-identifier" +} +``` + +Candidate names and the ballot ID are derived on the server. A request ID is +scoped to its ballot and can be safely retried with the same ranking. Reusing +it with a different ranking returns `idempotency_conflict`. + +This endpoint intentionally stops at the Phase 1 anonymous flow. Ballots that +require a voter name, voter code, or grouping answers return typed states for +the client; collecting those values remains Phase 2 work. diff --git a/src/api/v2/votes.php b/src/api/v2/votes.php new file mode 100644 index 0000000..bcc15ad --- /dev/null +++ b/src/api/v2/votes.php @@ -0,0 +1,214 @@ + $data, 'error' => $error]); + exit; +} + +function fail(int $status, string $code, string $message, ?array $fields = null): void +{ + $error = ['code' => $code, 'message' => $message]; + if ($fields !== null) { + $error['fields'] = $fields; + } + respond($status, null, $error); +} + +function normalizeRequestId($value): ?string +{ + if (!is_string($value)) { + return null; + } + + $requestId = trim($value); + if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $requestId)) { + return null; + } + return $requestId; +} + +function normalizeRanking($value): ?array +{ + if (!is_array($value) || count($value) === 0) { + return null; + } + + $ids = []; + foreach ($value as $candidateId) { + if (is_int($candidateId)) { + $id = $candidateId; + } elseif (is_string($candidateId) && ctype_digit($candidateId)) { + $id = (int) $candidateId; + } else { + return null; + } + + if ($id <= 0 || in_array($id, $ids, true)) { + return null; + } + $ids[] = $id; + } + + return $ids; +} + +function findIdempotentVote(PDO $dbh, int $ballotId, string $requestId): ?array +{ + $statement = $dbh->prepare( + 'SELECT vote_id, requestHash FROM votes WHERE ballotId = :ballotId AND requestKey = :requestKey LIMIT 1' + ); + $statement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT); + $statement->bindValue(':requestKey', $requestId, PDO::PARAM_STR); + $statement->execute(); + $row = $statement->fetch(PDO::FETCH_ASSOC); + return $row ?: null; +} + +if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] !== 'POST') { + fail(405, 'method_not_allowed', 'Use POST to submit a vote.'); +} + +$input = json_decode(file_get_contents('php://input'), true); +if (!is_array($input)) { + fail(400, 'invalid_json', 'The request body must be a JSON object.'); +} + +$fields = []; +$key = isset($input['key']) && is_string($input['key']) ? trim($input['key']) : ''; +if ($key === '') { + $fields['key'] = 'A ballot shortcode is required.'; +} + +$requestId = normalizeRequestId($input['requestId'] ?? null); +if ($requestId === null) { + $fields['requestId'] = 'A 16-64 character request ID is required.'; +} + +$ranking = normalizeRanking($input['ranking'] ?? null); +if ($ranking === null) { + $fields['ranking'] = 'Rank at least one candidate using unique candidate IDs.'; +} + +if ($fields !== []) { + fail(422, 'validation_failed', 'The vote request is invalid.', $fields); +} + +$ballotStatement = $dbh->prepare( + 'SELECT id, register, oneDeviceOneVote, isSecure, allowGrouping, voteCutoff FROM ballots WHERE `key` = :key LIMIT 1' +); +$ballotStatement->bindValue(':key', $key, PDO::PARAM_STR); +$ballotStatement->execute(); +$ballot = $ballotStatement->fetch(PDO::FETCH_ASSOC); + +if (!$ballot) { + fail(404, 'ballot_not_found', 'The ballot could not be found.'); +} + +$ballotId = (int) $ballot['id']; +$requestHash = hash('sha256', json_encode(['ranking' => $ranking], JSON_UNESCAPED_SLASHES)); +$existingVote = findIdempotentVote($dbh, $ballotId, $requestId); +if ($existingVote) { + if (!hash_equals((string) $existingVote['requestHash'], $requestHash)) { + fail(409, 'idempotency_conflict', 'This request ID was already used for a different vote.'); + } + respond(200, [ + 'status' => 'accepted', + 'voteId' => (int) $existingVote['vote_id'], + 'replayed' => true, + ], null); +} + +if ($ballot['voteCutoff'] !== null && $ballot['voteCutoff'] < gmdate('Y-m-d H:i:s')) { + fail(409, 'voting_closed', 'Voting has closed for this ballot.'); +} + +if ((int) $ballot['register'] === 1) { + fail(409, 'voter_name_required', 'This ballot requires a voter name, which is not supported in the anonymous flow.'); +} + +if ((int) $ballot['isSecure'] === 1) { + fail(409, 'secure_code_required', 'This ballot requires a voter code.'); +} + +if ((int) $ballot['allowGrouping'] === 1) { + fail(409, 'group_answers_required', 'This ballot requires voter questions.'); +} + +$fingerprint = isset($input['fingerprint']) && is_string($input['fingerprint']) + ? substr(trim($input['fingerprint']), 0, 64) + : ''; + +if ((int) $ballot['oneDeviceOneVote'] === 1) { + if ($fingerprint === '') { + fail(422, 'fingerprint_required', 'A device identifier is required for this ballot.'); + } + + $duplicateStatement = $dbh->prepare( + 'SELECT vote_id FROM votes WHERE ballotId = :ballotId AND fingerprint = :fingerprint LIMIT 1' + ); + $duplicateStatement->bindValue(':ballotId', $ballotId, PDO::PARAM_INT); + $duplicateStatement->bindValue(':fingerprint', $fingerprint, PDO::PARAM_STR); + $duplicateStatement->execute(); + if ($duplicateStatement->fetch()) { + fail(409, 'duplicate_device', 'This device has already voted on this ballot.'); + } +} + +$candidatePlaceholders = implode(',', array_fill(0, count($ranking), '?')); +$candidateStatement = $dbh->prepare( + "SELECT entry_id, name FROM entries WHERE ballotId = ? AND entry_id IN ($candidatePlaceholders)" +); +$candidateStatement->execute(array_merge([$ballotId], $ranking)); +$candidateRows = $candidateStatement->fetchAll(PDO::FETCH_ASSOC); +$candidateNames = []; +foreach ($candidateRows as $candidate) { + $candidateNames[(int) $candidate['entry_id']] = (string) $candidate['name']; +} + +if (count($candidateNames) !== count($ranking)) { + fail(422, 'invalid_ranking', 'One or more ranked candidates do not belong to this ballot.'); +} + +$voteNames = array_map(fn (int $candidateId): string => $candidateNames[$candidateId], $ranking); +$voteJson = json_encode($voteNames, JSON_UNESCAPED_SLASHES); +$voteIds = implode(',', $ranking); + +try { + $insert = $dbh->prepare( + 'INSERT INTO votes ' + . '(ballotId, date_created, vote, voteIds, ipAddress, name, fingerprint, group_answers, requestKey, requestHash) ' + . 'VALUES (:ballotId, UTC_TIMESTAMP(), :vote, :voteIds, :ipAddress, \'\', :fingerprint, NULL, :requestKey, :requestHash)' + ); + $insert->bindValue(':ballotId', $ballotId, PDO::PARAM_INT); + $insert->bindValue(':vote', $voteJson, PDO::PARAM_STR); + $insert->bindValue(':voteIds', $voteIds, PDO::PARAM_STR); + $insert->bindValue(':ipAddress', $_SERVER['REMOTE_ADDR'] ?? '', PDO::PARAM_STR); + $insert->bindValue(':fingerprint', $fingerprint, PDO::PARAM_STR); + $insert->bindValue(':requestKey', $requestId, PDO::PARAM_STR); + $insert->bindValue(':requestHash', $requestHash, PDO::PARAM_STR); + $insert->execute(); + $voteId = (int) $dbh->lastInsertId(); +} catch (PDOException $exception) { + $existingVote = findIdempotentVote($dbh, $ballotId, $requestId); + if (!$existingVote || !hash_equals((string) $existingVote['requestHash'], $requestHash)) { + error_log('v2 vote insert failed: ' . $exception->getMessage()); + fail(500, 'server_error', 'The vote could not be recorded.'); + } + respond(200, [ + 'status' => 'accepted', + 'voteId' => (int) $existingVote['vote_id'], + 'replayed' => true, + ], null); +} + +respond(201, [ + 'status' => 'accepted', + 'voteId' => $voteId, + 'replayed' => false, +], null); diff --git a/test/contract/live-php-api.test.js b/test/contract/live-php-api.test.js index 36456e4..9227e53 100644 --- a/test/contract/live-php-api.test.js +++ b/test/contract/live-php-api.test.js @@ -120,6 +120,33 @@ afterEach(async () => { }); describe('live PHP API contracts', () => { + it('records and safely replays an idempotent v2 anonymous vote', async () => { + const ballot = await createBallot(); + const candidates = await requestApi('GET', 'get-candidates.php', { + query: { key: ballot.key } + }); + const ranking = candidates.json.candidates.map((candidate) => Number(candidate.entry_id)); + const body = { + key: ballot.key, + requestId: `contract_request_${Date.now()}`, + ranking + }; + + const first = await requestApi('POST', 'v2/votes.php', { body }); + const replay = await requestApi('POST', 'v2/votes.php', { body }); + + expect(first.status).toBe(201); + expect(first.json).toMatchObject({ + data: { status: 'accepted', replayed: false }, + error: null + }); + expect(replay.status).toBe(200); + expect(replay.json).toEqual({ + data: { ...first.json.data, replayed: true }, + error: null + }); + }); + it('creates a ballot, returns candidates, records a vote, and returns results', async () => { const ballot = await createBallot(); diff --git a/test/php/V2VoteTest.php b/test/php/V2VoteTest.php new file mode 100644 index 0000000..fc5970c --- /dev/null +++ b/test/php/V2VoteTest.php @@ -0,0 +1,148 @@ + $key, + 'requestId' => 'request_1234567890', + 'ranking' => $ranking, + ], $overrides); + } + + public function testRecordsAnAnonymousVoteWithTypedResponse(): void + { + $key = 'v2-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key, 'register' => 2]); + $entryIds = $this->seedEntries($ballotId, ['Alice', 'Bob']); + + $result = $this->callApi('v2/votes.php', $this->validRequest($key, array_reverse($entryIds))); + + $this->assertSame('accepted', $result['body']['data']['status']); + $this->assertGreaterThan(0, $result['body']['data']['voteId']); + $this->assertFalse($result['body']['data']['replayed']); + $this->assertNull($result['body']['error']); + + $vote = $this->db->query('SELECT vote, voteIds, requestKey, requestHash FROM votes')->fetch(PDO::FETCH_ASSOC); + $this->assertSame(json_encode(['Bob', 'Alice']), $vote['vote']); + $this->assertSame(implode(',', array_reverse($entryIds)), $vote['voteIds']); + $this->assertSame('request_1234567890', $vote['requestKey']); + $this->assertSame(64, strlen($vote['requestHash'])); + } + + public function testReplaysTheFirstResponseForTheSameRequest(): void + { + $key = 'replay-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key]); + $entryIds = $this->seedEntries($ballotId, ['Alice', 'Bob']); + $request = $this->validRequest($key, $entryIds); + + $first = $this->callApi('v2/votes.php', $request); + $second = $this->callApi('v2/votes.php', $request); + + $this->assertFalse($first['body']['data']['replayed']); + $this->assertTrue($second['body']['data']['replayed']); + $this->assertSame($first['body']['data']['voteId'], $second['body']['data']['voteId']); + $this->assertSame(1, (int) $this->db->query('SELECT COUNT(*) FROM votes')->fetchColumn()); + } + + public function testRejectsAReusedRequestIdWithDifferentRanking(): void + { + $key = 'conflict-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key]); + $entryIds = $this->seedEntries($ballotId, ['Alice', 'Bob']); + + $this->callApi('v2/votes.php', $this->validRequest($key, $entryIds)); + $result = $this->callApi('v2/votes.php', $this->validRequest($key, array_reverse($entryIds))); + + $this->assertSame('idempotency_conflict', $result['body']['error']['code']); + $this->assertSame(1, (int) $this->db->query('SELECT COUNT(*) FROM votes')->fetchColumn()); + } + + public function testRejectsInvalidRequestFields(): void + { + $result = $this->callApi('v2/votes.php', [ + 'requestId' => 'short', + 'ranking' => [1, 1], + ]); + + $this->assertSame('validation_failed', $result['body']['error']['code']); + $this->assertSame(['key', 'requestId', 'ranking'], array_keys($result['body']['error']['fields'])); + } + + public function testRejectsAnUnknownBallot(): void + { + $result = $this->callApi('v2/votes.php', $this->validRequest('missing', [1])); + + $this->assertSame('ballot_not_found', $result['body']['error']['code']); + } + + public function testRejectsCandidatesFromAnotherBallot(): void + { + $key = 'foreign-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key]); + $this->seedEntries($ballotId, ['Alice']); + $otherBallotId = $this->seedBallot(); + $foreignIds = $this->seedEntries($otherBallotId, ['Mallory']); + + $result = $this->callApi('v2/votes.php', $this->validRequest($key, $foreignIds)); + + $this->assertSame('invalid_ranking', $result['body']['error']['code']); + $this->assertSame(0, (int) $this->db->query('SELECT COUNT(*) FROM votes')->fetchColumn()); + } + + public function testReturnsPhaseSpecificBallotStates(): void + { + $cases = [ + [['voteCutoff' => '2000-01-01 00:00:00'], 'voting_closed'], + [['register' => 1], 'voter_name_required'], + [['isSecure' => 1], 'secure_code_required'], + [['allowGrouping' => 1], 'group_answers_required'], + ]; + + foreach ($cases as $index => [$settings, $expectedCode]) { + $key = "state-$index-" . uniqid(); + $ballotId = $this->seedBallot(array_merge(['key' => $key], $settings)); + $entryIds = $this->seedEntries($ballotId, ['Alice']); + + $result = $this->callApi( + 'v2/votes.php', + $this->validRequest($key, $entryIds, ['requestId' => "state_request_12345_$index"]) + ); + + $this->assertSame($expectedCode, $result['body']['error']['code']); + } + } + + public function testEnforcesOneVotePerDevice(): void + { + $key = 'device-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key, 'oneDeviceOneVote' => 1]); + $entryIds = $this->seedEntries($ballotId, ['Alice', 'Bob']); + $fingerprint = 'installation_1234567890'; + + $this->callApi('v2/votes.php', $this->validRequest($key, $entryIds, [ + 'fingerprint' => $fingerprint, + ])); + $result = $this->callApi('v2/votes.php', $this->validRequest($key, array_reverse($entryIds), [ + 'requestId' => 'another_request_12345', + 'fingerprint' => $fingerprint, + ])); + + $this->assertSame('duplicate_device', $result['body']['error']['code']); + } + + public function testRequiresADeviceIdentifierWhenConfigured(): void + { + $key = 'fingerprint-' . uniqid(); + $ballotId = $this->seedBallot(['key' => $key, 'oneDeviceOneVote' => 1]); + $entryIds = $this->seedEntries($ballotId, ['Alice']); + + $result = $this->callApi('v2/votes.php', $this->validRequest($key, $entryIds)); + + $this->assertSame('fingerprint_required', $result['body']['error']['code']); + } +} diff --git a/test/php/schema-sqlite.sql b/test/php/schema-sqlite.sql index 43a0b4e..1e1677a 100644 --- a/test/php/schema-sqlite.sql +++ b/test/php/schema-sqlite.sql @@ -85,7 +85,10 @@ CREATE TABLE votes ( name varchar(40) NOT NULL DEFAULT '', date_created datetime DEFAULT CURRENT_TIMESTAMP, fingerprint varchar(64) NOT NULL DEFAULT '', - group_answers text DEFAULT NULL + group_answers text DEFAULT NULL, + requestKey varchar(64) DEFAULT NULL, + requestHash char(64) DEFAULT NULL, + UNIQUE (ballotId, requestKey) ); CREATE INDEX idx_ballot_fingerprint ON votes (ballotId, fingerprint);