From d451fce9c8ab0949e9975ddf254822a3b4cef0a1 Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Mon, 11 Mar 2024 15:04:09 -0700 Subject: [PATCH] feat: push objects on client sync (#81) --- apps/cacvote-jx-terminal/backend/src/db.rs | 39 +- apps/cacvote-jx-terminal/backend/src/sync.rs | 23 + apps/cacvote-mark/backend/bin/create-object | 17 + apps/cacvote-mark/backend/package.json | 1 + .../backend/src/bin/create-object/main.ts | 55 +++ .../backend/src/cacvote-server/client.test.ts | 348 +++++++++++++++ .../backend/src/cacvote-server/client.ts | 12 +- .../backend/src/cacvote-server/sync.test.ts | 402 ++++++++++++++++++ .../backend/src/cacvote-server/sync.ts | 148 +++++++ .../backend/src/cacvote-server/types.ts | 122 ++++-- apps/cacvote-mark/backend/src/server.ts | 77 +--- apps/cacvote-mark/backend/src/store.ts | 97 ++++- .../backend/test/mock_cacvote_server.ts | 40 ++ libs/auth/src/certs.ts | 39 +- libs/auth/src/index.ts | 2 + libs/types-rs/src/cacvote/mod.rs | 3 +- pnpm-lock.yaml | 3 + services/cacvote-server/bin/create-object.rs | 8 +- services/cacvote-server/src/app.rs | 20 +- services/cacvote-server/src/client.rs | 6 +- services/cacvote-server/src/db.rs | 11 +- vxsuite.code-workspace | 4 + 22 files changed, 1331 insertions(+), 146 deletions(-) create mode 100755 apps/cacvote-mark/backend/bin/create-object create mode 100644 apps/cacvote-mark/backend/src/bin/create-object/main.ts create mode 100644 apps/cacvote-mark/backend/src/cacvote-server/client.test.ts create mode 100644 apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts create mode 100644 apps/cacvote-mark/backend/src/cacvote-server/sync.ts create mode 100644 apps/cacvote-mark/backend/test/mock_cacvote_server.ts diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index bd1f44d6ae..83f94452d6 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -13,7 +13,7 @@ use base64_serde::base64_serde_type; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool}; use tracing::Level; -use types_rs::cacvote::{JournalEntry, JurisdictionCode}; +use types_rs::cacvote::{JournalEntry, JurisdictionCode, SignedObject}; use crate::config::Config; @@ -81,3 +81,40 @@ pub(crate) async fn get_latest_journal_entry( .fetch_optional(&mut *connection) .await?) } + +pub(crate) async fn get_unsynced_objects( + executor: &mut sqlx::PgConnection, +) -> color_eyre::eyre::Result> { + Ok(sqlx::query_as!( + SignedObject, + r#" + SELECT + id, + payload, + certificates, + signature + FROM objects + WHERE server_synced_at IS NULL + "#, + ) + .fetch_all(&mut *executor) + .await?) +} + +pub(crate) async fn mark_object_synced( + executor: &mut sqlx::PgConnection, + id: uuid::Uuid, +) -> color_eyre::eyre::Result<()> { + sqlx::query!( + r#" + UPDATE objects + SET server_synced_at = now() + WHERE id = $1 + "#, + id + ) + .execute(&mut *executor) + .await?; + + Ok(()) +} diff --git a/apps/cacvote-jx-terminal/backend/src/sync.rs b/apps/cacvote-jx-terminal/backend/src/sync.rs index 49112cab93..f015f7792d 100644 --- a/apps/cacvote-jx-terminal/backend/src/sync.rs +++ b/apps/cacvote-jx-terminal/backend/src/sync.rs @@ -41,6 +41,16 @@ pub(crate) async fn sync( ) -> color_eyre::eyre::Result<()> { client.check_status().await?; + push_objects(executor, client).await?; + pull_journal_entries(executor, client).await?; + + Ok(()) +} + +async fn pull_journal_entries( + executor: &mut sqlx::PgConnection, + client: &Client, +) -> color_eyre::eyre::Result<()> { let latest_journal_entry_id = db::get_latest_journal_entry(executor) .await? .map(|entry| entry.id); @@ -54,3 +64,16 @@ pub(crate) async fn sync( Ok(()) } + +async fn push_objects( + executor: &mut sqlx::PgConnection, + client: &Client, +) -> color_eyre::eyre::Result<()> { + let objects = db::get_unsynced_objects(executor).await?; + for object in objects { + let object_id = client.create_object(object).await?; + db::mark_object_synced(executor, object_id).await?; + } + + Ok(()) +} diff --git a/apps/cacvote-mark/backend/bin/create-object b/apps/cacvote-mark/backend/bin/create-object new file mode 100755 index 0000000000..457202c841 --- /dev/null +++ b/apps/cacvote-mark/backend/bin/create-object @@ -0,0 +1,17 @@ +#!/usr/bin/env node + +require('esbuild-runner/register'); + +require('../src/bin/create-object/main') + .main(process.argv, { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }) + .then((code) => { + process.exitCode = code; + }) + .catch((err) => { + console.error(err.stack); + process.exit(1); + }); diff --git a/apps/cacvote-mark/backend/package.json b/apps/cacvote-mark/backend/package.json index c902208341..01ecb34caf 100644 --- a/apps/cacvote-mark/backend/package.json +++ b/apps/cacvote-mark/backend/package.json @@ -71,6 +71,7 @@ "@types/uuid": "9.0.5", "@types/xml": "^1.0.9", "@votingworks/test-utils": "workspace:*", + "esbuild-runner": "2.2.2", "eslint": "8.51.0", "eslint-config-prettier": "^9.0.0", "eslint-import-resolver-node": "^0.3.9", diff --git a/apps/cacvote-mark/backend/src/bin/create-object/main.ts b/apps/cacvote-mark/backend/src/bin/create-object/main.ts new file mode 100644 index 0000000000..7db6e21b07 --- /dev/null +++ b/apps/cacvote-mark/backend/src/bin/create-object/main.ts @@ -0,0 +1,55 @@ +import { Buffer } from 'buffer'; +import { readFile } from 'fs/promises'; +import { cryptography } from '@votingworks/auth'; +import { v4 } from 'uuid'; +import { Readable } from 'stream'; +import { unsafeParse } from '@votingworks/types'; +import { join } from 'path'; +import { Payload, SignedObject, UuidSchema } from '../../cacvote-server/types'; +import { resolveWorkspace } from '../../workspace'; + +const DEV_CERTS_PATH = join(__dirname, '../../../../../../libs/auth/certs/dev'); +const PRIVATE_KEY_PATH = join(DEV_CERTS_PATH, 'vx-admin-private-key.pem'); +const VX_ADMIN_CERT_AUTHORITY_CERT_PATH = join( + DEV_CERTS_PATH, + 'vx-admin-cert-authority-cert.pem' +); + +export async function main(): Promise { + const workspace = await resolveWorkspace(); + + interface TestObject { + name: string; + description: string; + value: number; + } + + const object: TestObject = { + name: 'Test Object', + description: 'This is a test object', + value: 42, + }; + + const payload = new Payload( + 'TestObject', + Buffer.from(JSON.stringify(object)) + ); + + const certificatesPem = await readFile(VX_ADMIN_CERT_AUTHORITY_CERT_PATH); + const payloadBuffer = Buffer.from(JSON.stringify(payload)); + const signature = await cryptography.signMessage({ + message: Readable.from(payloadBuffer), + signingPrivateKey: { + source: 'file', + path: PRIVATE_KEY_PATH, + }, + }); + const signedObject = new SignedObject( + unsafeParse(UuidSchema, v4()), + payloadBuffer, + certificatesPem, + signature + ); + + console.log(await workspace.store.addObject(signedObject)); +} diff --git a/apps/cacvote-mark/backend/src/cacvote-server/client.test.ts b/apps/cacvote-mark/backend/src/cacvote-server/client.test.ts new file mode 100644 index 0000000000..17c43e8229 --- /dev/null +++ b/apps/cacvote-mark/backend/src/cacvote-server/client.test.ts @@ -0,0 +1,348 @@ +import { err, ok } from '@votingworks/basics'; +import { Buffer } from 'buffer'; +import { safeParseJson, unsafeParse } from '@votingworks/types'; +import { DateTime } from 'luxon'; +import { mockCacvoteServer } from '../../test/mock_cacvote_server'; +import { + JournalEntry, + JurisdictionCodeSchema, + SignedObject, + UuidSchema, +} from './types'; + +const uuid = unsafeParse(UuidSchema, '123e4567-e89b-12d3-a456-426614174000'); +const jurisdictionCode = unsafeParse( + JurisdictionCodeSchema, + 'st.test-jurisdiction' +); + +test('checkStatus success', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.checkStatus()).toEqual(ok()); + await server.stop(); +}); + +test('checkStatus failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.checkStatus()).toEqual( + err({ type: 'network', message: 'Internal Server Error' }) + ); + await server.stop(); +}); + +test('createObject success', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'POST /api/objects': { + expect(req.headers['content-type']).toEqual('application/json'); + let body = ''; + req.on('readable', () => { + const chunk = req.read(); + if (chunk) { + body += chunk.toString(); + } + }); + + req.on('end', () => { + const object = safeParseJson(body).unsafeUnwrap(); + expect(object).toEqual({ + id: uuid, + payload: Buffer.of(1, 2, 3).toString('base64'), + certificates: Buffer.of(4, 5, 6).toString('base64'), + signature: Buffer.of(7, 8, 9).toString('base64'), + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(uuid); + }); + + break; + } + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const object = new SignedObject( + uuid, + Buffer.of(1, 2, 3), + Buffer.of(4, 5, 6), + Buffer.of(7, 8, 9) + ); + expect(await server.client.createObject(object)).toEqual(ok(uuid)); + await server.stop(); +}); + +test('createObject network failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'POST /api/objects': + expect(req.headers['content-type']).toEqual('application/json'); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const object = new SignedObject( + uuid, + Buffer.of(1, 2, 3), + Buffer.of(4, 5, 6), + Buffer.of(7, 8, 9) + ); + expect(await server.client.createObject(object)).toEqual( + err({ type: 'network', message: 'Internal Server Error' }) + ); + await server.stop(); +}); + +test('createObject schema failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'POST /api/objects': + expect(req.headers['content-type']).toEqual('application/json'); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('not a uuid'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const object = new SignedObject( + uuid, + Buffer.of(1, 2, 3), + Buffer.of(4, 5, 6), + Buffer.of(7, 8, 9) + ); + expect(await server.client.createObject(object)).toEqual( + err( + expect.objectContaining({ type: 'schema', message: expect.any(String) }) + ) + ); + await server.stop(); +}); + +test('getObjectById success / no object', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case `GET /api/objects/${uuid}`: + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getObjectById(uuid)).toEqual(ok(undefined)); + await server.stop(); +}); + +test('getObjectById success / with object', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case `GET /api/objects/${uuid}`: + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify( + new SignedObject( + uuid, + Buffer.of(1, 2, 3), + Buffer.of(4, 5, 6), + Buffer.of(7, 8, 9) + ) + ) + ); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const object = new SignedObject( + uuid, + Buffer.of(1, 2, 3), + Buffer.of(4, 5, 6), + Buffer.of(7, 8, 9) + ); + expect(await server.client.getObjectById(uuid)).toEqual(ok(object)); + await server.stop(); +}); + +test('getObjectById network failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case `GET /api/objects/${uuid}`: + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getObjectById(uuid)).toEqual( + err({ type: 'network', message: 'Internal Server Error' }) + ); + await server.stop(); +}); + +test('getObjectById schema failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case `GET /api/objects/${uuid}`: + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('not a signed object'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getObjectById(uuid)).toEqual( + err( + expect.objectContaining({ type: 'schema', message: expect.any(String) }) + ) + ); + await server.stop(); +}); + +test('getJournalEntries success / no entries', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getJournalEntries()).toEqual(ok([])); + await server.stop(); +}); + +test('getJournalEntries success / with entries', async () => { + const createdAt = DateTime.now(); + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify([ + new JournalEntry( + uuid, + uuid, + jurisdictionCode, + 'objectType', + 'action', + createdAt + ), + ]) + ); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getJournalEntries()).toEqual( + ok([ + { + id: uuid, + objectId: uuid, + jurisdiction: jurisdictionCode, + objectType: 'objectType', + action: 'action', + createdAt, + }, + ]) + ); + await server.stop(); +}); + +test('getJournalEntries network failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/journal-entries': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getJournalEntries()).toEqual( + err({ type: 'network', message: 'Internal Server Error' }) + ); + await server.stop(); +}); + +test('getJournalEntries schema failure', async () => { + const createdAt = DateTime.now(); + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify([ + { + id: uuid, + objectId: uuid, + jurisdiction: 'invalid jurisdiction', + objectType: 'objectType', + action: 'action', + createdAt: createdAt.toISO(), + }, + ]) + ); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + expect(await server.client.getJournalEntries()).toEqual( + err( + expect.objectContaining({ type: 'schema', message: expect.any(String) }) + ) + ); + await server.stop(); +}); diff --git a/apps/cacvote-mark/backend/src/cacvote-server/client.ts b/apps/cacvote-mark/backend/src/cacvote-server/client.ts index 977bfbbb59..3b1d64e363 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/client.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/client.ts @@ -7,7 +7,7 @@ import { ok, } from '@votingworks/basics'; import fetch, { Headers, Request } from 'cross-fetch'; -import { safeParse } from '@votingworks/types'; +import { safeParse, safeParseJson } from '@votingworks/types'; import { ZodError, z } from 'zod'; import { JournalEntry, @@ -19,7 +19,7 @@ import { export type ClientError = | { type: 'network'; message: string } - | { type: 'schema'; error: ZodError; message: string }; + | { type: 'schema'; error: SyntaxError | ZodError; message: string }; export type ClientResult = Result; @@ -72,6 +72,7 @@ export class Client { uuid: Uuid ): Promise>> { const SignedObjectRawSchema = z.object({ + id: UuidSchema, payload: z.string(), certificates: z.string(), signature: z.string(), @@ -88,9 +89,9 @@ export class Client { bail({ type: 'network', message: response.statusText }); } - const signedObject = safeParse( - SignedObjectRawSchema, - await response.json() + const signedObject = safeParseJson( + await response.text(), + SignedObjectRawSchema ).okOrElse((error) => bail({ type: 'schema', @@ -100,6 +101,7 @@ export class Client { ); return new SignedObject( + signedObject.id, Buffer.from(signedObject.payload, 'base64'), Buffer.from(signedObject.certificates, 'base64'), Buffer.from(signedObject.signature, 'base64') diff --git a/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts b/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts new file mode 100644 index 0000000000..2ea822f4fd --- /dev/null +++ b/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts @@ -0,0 +1,402 @@ +import { Buffer } from 'buffer'; +import { fakeLogger } from '@votingworks/logging'; +import { deferred } from '@votingworks/basics'; +import { v4 } from 'uuid'; +import { unsafeParse } from '@votingworks/types'; +import { DateTime } from 'luxon'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { sync, syncPeriodically } from './sync'; +import { Store } from '../store'; +import { mockCacvoteServer } from '../../test/mock_cacvote_server'; +import { + JournalEntry, + JurisdictionCodeSchema, + Payload, + SignedObject, + UuidSchema, +} from './types'; + +test('syncPeriodically', async () => { + const getJournalEntriesDeferred = deferred(); + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + getJournalEntriesDeferred.resolve(); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + const stopSyncing = syncPeriodically(server.client, store, logger, 0); + + // wait for the server to receive the request + await getJournalEntriesDeferred.promise; + + // stop the sync loop + await stopSyncing(); + + // wait for the server to stop + await server.stop(); +}); + +test('syncPeriodically loops', async () => { + let statusCount = 0; + const done = deferred(); + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + statusCount += 1; + if (statusCount >= 4) { + done.resolve(); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + const stopSyncing = syncPeriodically(server.client, store, logger, 0); + + // wait for the sync loop to go a few times + await done.promise; + + // stop the sync loop + await stopSyncing(); + + // wait for the server to stop + await server.stop(); +}); + +test('sync / checkStatus failure', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('Internal Server Error'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Failed to check status/), + disposition: 'failure', + }) + ); +}); + +test('sync / getJournalEntries failure', async () => { + const getJournalEntriesDeferred = deferred(); + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('Internal Server Error'); + getJournalEntriesDeferred.resolve(); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Failed to get journal entries/), + disposition: 'failure', + }) + ); +}); + +test('sync / getJournalEntries success / no entries', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Got 0 journal entries/), + disposition: 'success', + }) + ); +}); + +test('sync / getJournalEntries success / with entries', async () => { + const journalEntryId = unsafeParse(UuidSchema, v4()); + const objectId = unsafeParse(UuidSchema, v4()); + const jurisdictionCode = unsafeParse( + JurisdictionCodeSchema, + 'st.test-jurisdiction' + ); + const journalEntry = new JournalEntry( + journalEntryId, + objectId, + jurisdictionCode, + 'objectType', + 'create', + DateTime.now() + ); + + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([journalEntry])); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Got 1 journal entries/), + disposition: 'success', + }) + ); + + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); +}); + +test('sync / createObject success / no objects', async () => { + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: 'No objects to push to CACVote Server', + disposition: 'success', + }) + ); +}); + +test('sync / createObject success / with objects', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const object = new SignedObject( + objectId, + Buffer.from(JSON.stringify(new Payload('objectType', Buffer.of(1, 2, 3)))), + await readFile( + join( + __dirname, + '../../../../../libs/auth/certs/dev/vx-admin-cert-authority-cert.pem' + ) + ), + Buffer.of(7, 8, 9) + ); + + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + case 'POST /api/objects': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(objectId); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + (await store.addObject(object)).unsafeUnwrap(); + expect(store.getUnsyncedObjects()).toHaveLength(1); + + const logger = fakeLogger(); + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: 'Pushing 1 objects to CACVote Server', + }) + ); + + expect(store.getUnsyncedObjects()).toHaveLength(0); +}); + +test('sync / createObject failure', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const object = new SignedObject( + objectId, + Buffer.from(JSON.stringify(new Payload('objectType', Buffer.of(1, 2, 3)))), + await readFile( + join( + __dirname, + '../../../../../libs/auth/certs/dev/vx-admin-cert-authority-cert.pem' + ) + ), + Buffer.of(7, 8, 9) + ); + + const server = await mockCacvoteServer((req, res) => { + switch (`${req.method} ${req.url}`) { + case 'GET /api/status': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + break; + + case 'GET /api/journal-entries': + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('[]'); + break; + + case 'POST /api/objects': + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end('Internal Server Error'); + break; + + default: + throw new Error(`Unexpected request: ${req.url}`); + } + }); + + const store = Store.memoryStore(); + (await store.addObject(object)).unsafeUnwrap(); + expect(store.getUnsyncedObjects()).toHaveLength(1); + + const logger = fakeLogger(); + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Failed to push object/), + disposition: 'failure', + }) + ); + + expect(store.getUnsyncedObjects()).toHaveLength(1); +}); diff --git a/apps/cacvote-mark/backend/src/cacvote-server/sync.ts b/apps/cacvote-mark/backend/src/cacvote-server/sync.ts new file mode 100644 index 0000000000..8633464007 --- /dev/null +++ b/apps/cacvote-mark/backend/src/cacvote-server/sync.ts @@ -0,0 +1,148 @@ +import { deferred, sleep } from '@votingworks/basics'; +import { LogEventId, Logger } from '@votingworks/logging'; +import { Client } from './client'; +import { Store } from '../store'; + +async function pullJournalEntries( + client: Client, + store: Store, + logger: Logger +): Promise { + const latestJournalEntry = store.getLatestJournalEntry(); + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Checking for journal entries from CACVote Server since ${ + latestJournalEntry?.getId() ?? 'the beginning of time' + }`, + }); + + const getEntriesResult = await client.getJournalEntries( + latestJournalEntry?.getId() + ); + + if (getEntriesResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to get journal entries from CACVote Server: ${ + getEntriesResult.err().message + }`, + disposition: 'failure', + }); + } else { + const newEntries = getEntriesResult.ok(); + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Got ${newEntries.length} journal entries from CACVote Server`, + disposition: 'success', + }); + + store.addJournalEntries(newEntries); + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: 'CACVote Server sync succeeded', + disposition: 'success', + }); + } +} + +async function pushObjects( + client: Client, + store: Store, + logger: Logger +): Promise { + const objects = store.getUnsyncedObjects(); + + if (objects.length === 0) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: 'No objects to push to CACVote Server', + disposition: 'success', + }); + return; + } + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Pushing ${objects.length} objects to CACVote Server`, + }); + + for (const object of objects) { + const pushResult = await client.createObject(object); + + if (pushResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to push object '${object.getId()}' to CACVote Server: ${pushResult.err()}`, + disposition: 'failure', + }); + continue; + } + + store.markObjectAsSynced(object.getId()); + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Pushed object with ID '${object.getId()}' to CACVote Server`, + disposition: 'success', + }); + } +} + +/** + * Perform a sync with the CACVote Server now. + */ +export async function sync( + client: Client, + store: Store, + logger: Logger +): Promise { + try { + const checkResult = await client.checkStatus(); + + if (checkResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to check status of CACVote Server: ${ + checkResult.err().message + }`, + disposition: 'failure', + }); + return; + } + + await pushObjects(client, store, logger); + await pullJournalEntries(client, store, logger); + } catch (err) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to sync with CACVote Server: ${err}`, + disposition: 'failure', + }); + } +} + +const SYNC_INTERVAL = 1000 * 5; + +/** + * Synchronizes with the CACVote Server periodically. Returns a function to stop + * syncing. + */ +export function syncPeriodically( + client: Client, + store: Store, + logger: Logger, + interval = SYNC_INTERVAL +): () => Promise { + const stopped = deferred(); + let stopping = false; + + void (async () => { + while (!stopping) { + await sync(client, store, logger); + + if (stopping) { + break; + } + + await sleep(interval); + } + + stopped.resolve(); + })(); + + return () => { + stopping = true; + return stopped.promise; + }; +} diff --git a/apps/cacvote-mark/backend/src/cacvote-server/types.ts b/apps/cacvote-mark/backend/src/cacvote-server/types.ts index 7b954617c7..118d12847f 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/types.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/types.ts @@ -1,9 +1,11 @@ // eslint-disable-next-line max-classes-per-file +import { certs } from '@votingworks/auth'; +import { Result } from '@votingworks/basics'; +import { NewType, safeParse, safeParseJson } from '@votingworks/types'; import { Buffer } from 'buffer'; -import { NewType } from '@votingworks/types'; -import { validate } from 'uuid'; import { DateTime } from 'luxon'; -import { z } from 'zod'; +import { validate } from 'uuid'; +import { ZodError, z } from 'zod'; export type Uuid = NewType; @@ -11,21 +13,87 @@ export const UuidSchema = z.string().refine(validate, { message: 'Invalid UUID', }) as unknown as z.ZodSchema; +export type JurisdictionCode = NewType; + +export const JurisdictionCodeSchema = z + .string() + .refine((s) => /^[a-z]{2}\.[-_a-z0-9]+$/i.test(s), { + message: 'Invalid jurisdiction code', + }) as unknown as z.ZodSchema; + +export type JournalEntryAction = 'create' | 'delete' | string; + export const DateTimeSchema = z .string() .transform(DateTime.fromISO) as unknown as z.ZodSchema; +export const JournalEntrySchema: z.ZodSchema<{ + id: Uuid; + objectId: Uuid; + jurisdiction: JurisdictionCode; + objectType: string; + action: string; + createdAt: DateTime; +}> = z.object({ + id: UuidSchema, + objectId: UuidSchema, + jurisdiction: JurisdictionCodeSchema, + objectType: z.string(), + action: z.string(), + createdAt: DateTimeSchema, +}); + +export class Payload { + constructor( + private readonly objectType: string, + private readonly data: Buffer + ) {} + + getObjectType(): string { + return this.objectType; + } + + getData(): Buffer { + return this.data; + } + + toJSON(): unknown { + return { + objectType: this.objectType, + data: this.data.toString('base64'), + }; + } +} + +export const PayloadSchema: z.ZodSchema = z + .object({ + objectType: z.string(), + data: z.string().transform((s) => Buffer.from(s, 'base64')), + }) + .transform( + (o) => new Payload(o.objectType, o.data) + ) as unknown as z.ZodSchema; + export class SignedObject { constructor( + private readonly id: Uuid, private readonly payload: Buffer, private readonly certificates: Buffer, private readonly signature: Buffer ) {} - getPayload(): Buffer { + getId(): Uuid { + return this.id; + } + + getPayloadRaw(): Buffer { return this.payload; } + getPayload(): Result { + return safeParseJson(this.payload.toString(), PayloadSchema); + } + getCertificates(): Buffer { return this.certificates; } @@ -34,8 +102,17 @@ export class SignedObject { return this.signature; } + async getJurisdictionCode(): Promise> { + const fields = await certs.getCertSubjectFields(this.certificates); + return safeParse( + JurisdictionCodeSchema, + fields.get(certs.VX_CUSTOM_CERT_FIELD.JURISDICTION) + ); + } + toJSON(): unknown { return { + id: this.id.toString(), payload: this.payload.toString('base64'), certificates: this.certificates.toString('base64'), signature: this.signature.toString('base64'), @@ -76,30 +153,15 @@ export class JournalEntry { getCreatedAt(): DateTime { return this.createdAt; } -} - -export type JurisdictionCode = NewType; - -export const JurisdictionCodeSchema = z - .string() - .refine((s) => /^[a-z]{2}\.[-_a-z0-9]+$/i.test(s), { - message: 'Invalid jurisdiction code', - }) as unknown as z.ZodSchema; - -export type JournalEntryAction = 'create' | 'delete' | string; -export const JournalEntrySchema: z.ZodSchema<{ - id: Uuid; - objectId: Uuid; - jurisdiction: JurisdictionCode; - objectType: string; - action: string; - createdAt: DateTime; -}> = z.object({ - id: UuidSchema, - objectId: UuidSchema, - jurisdiction: JurisdictionCodeSchema, - objectType: z.string(), - action: z.string(), - createdAt: DateTimeSchema, -}); + toJSON(): unknown { + return { + id: this.id.toString(), + objectId: this.objectId.toString(), + jurisdiction: this.jurisdiction, + objectType: this.objectType, + action: this.action, + createdAt: this.createdAt.toISO(), + }; + } +} diff --git a/apps/cacvote-mark/backend/src/server.ts b/apps/cacvote-mark/backend/src/server.ts index aca4ede4f3..0955055b1e 100644 --- a/apps/cacvote-mark/backend/src/server.ts +++ b/apps/cacvote-mark/backend/src/server.ts @@ -3,10 +3,11 @@ import { assertDefined, throwIllegalValue } from '@votingworks/basics'; import { LogEventId, Logger } from '@votingworks/logging'; import { Server } from 'http'; import { buildApp } from './app'; -import { Auth } from './types/auth'; -import { Workspace } from './workspace'; import { Client } from './cacvote-server/client'; +import { syncPeriodically } from './cacvote-server/sync'; import { CACVOTE_URL } from './globals'; +import { Auth } from './types/auth'; +import { Workspace } from './workspace'; export interface StartOptions { auth?: Auth; @@ -82,82 +83,14 @@ export function start({ auth, logger, port, workspace }: StartOptions): Server { workspace, auth: resolvedAuth, }); - const client = getCacvoteServerClient(); - - async function doCacvoteServerSync() { - try { - const checkResult = await client.checkStatus(); - - if (checkResult.isErr()) { - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Failed to check status of CACVote Server: ${checkResult.err()}`, - disposition: 'failure', - }); - } else { - const latestJournalEntry = workspace.store.getLatestJournalEntry(); - - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Checking for journal entries from CACVote Server since ${ - latestJournalEntry?.getId() ?? 'the beginning of time' - }`, - }); - - const getEntriesResult = await client.getJournalEntries( - latestJournalEntry?.getId() - ); - - if (getEntriesResult.isErr()) { - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Failed to get journal entries from CACVote Server: ${ - getEntriesResult.err().message - }`, - disposition: 'failure', - }); - } else { - const newEntries = getEntriesResult.ok(); - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Got ${newEntries.length} journal entries from CACVote Server`, - disposition: 'success', - }); - - workspace.store.addJournalEntries(newEntries); - - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: 'CACVote Server sync succeeded', - disposition: 'success', - }); - } - } - } catch (err) { - await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Failed to sync with CACVote Server: ${err}`, - disposition: 'failure', - }); - } - - // run again in 5 seconds - setTimeout(doCacvoteServerSync, 1000 * 5); - } - - void doCacvoteServerSync().then( - () => - logger.log(LogEventId.ApplicationStartup, 'system', { - message: 'Started CACVote Server sync', - disposition: 'success', - }), - (err) => - logger.log(LogEventId.ApplicationStartup, 'system', { - message: `Failed to start CACVote Server sync: ${err}`, - disposition: 'failure', - }) - ); + syncPeriodically(getCacvoteServerClient(), workspace.store, logger); return app.listen( port, /* istanbul ignore next */ async () => { await logger.log(LogEventId.ApplicationStartup, 'system', { - message: `RaveMark backend running at http://localhost:${port}/`, + message: `CACVote Mark backend running at http://localhost:${port}/`, disposition: 'success', }); } diff --git a/apps/cacvote-mark/backend/src/store.ts b/apps/cacvote-mark/backend/src/store.ts index 4cb6ad7386..c20ae7bbff 100644 --- a/apps/cacvote-mark/backend/src/store.ts +++ b/apps/cacvote-mark/backend/src/store.ts @@ -1,15 +1,20 @@ -import { Optional } from '@votingworks/basics'; +import { Optional, Result, asyncResultBlock } from '@votingworks/basics'; import { Client as DbClient } from '@votingworks/db'; import { + SystemSettings, safeParse, safeParseSystemSettings, - SystemSettings, + unsafeParse, } from '@votingworks/types'; -import { join } from 'path'; +import { Buffer } from 'buffer'; import { DateTime } from 'luxon'; +import { join } from 'path'; +import { ZodError } from 'zod'; import { JournalEntry, JurisdictionCodeSchema, + SignedObject, + Uuid, UuidSchema, } from './cacvote-server/types'; @@ -110,6 +115,33 @@ export class Store { : undefined; } + getJournalEntries(): JournalEntry[] { + const rows = this.client.all( + `select id, object_id, jurisdiction, object_type, action, created_at + from journal_entries + order by created_at` + ) as Array<{ + id: string; + object_id: string; + jurisdiction: string; + object_type: string; + action: string; + created_at: string; + }>; + + return rows.map( + (row) => + new JournalEntry( + unsafeParse(UuidSchema, row.id), + unsafeParse(UuidSchema, row.object_id), + unsafeParse(JurisdictionCodeSchema, row.jurisdiction), + row.object_type, + row.action, + DateTime.fromSQL(row.created_at) + ) + ); + } + /** * Adds journal entries to the store. */ @@ -132,4 +164,63 @@ export class Store { } }); } + + /** + * Adds an object to the store. + */ + async addObject( + object: SignedObject + ): Promise> { + return asyncResultBlock(async (bail) => { + const jurisdiction = (await object.getJurisdictionCode()).okOrElse(bail); + const payload = object.getPayload().okOrElse(bail); + + this.client.run( + `insert into objects (id, jurisdiction, object_type, payload, certificates, signature) + values (?, ?, ?, ?, ?, ?)`, + object.getId(), + jurisdiction, + payload.getObjectType(), + object.getPayloadRaw(), + object.getCertificates(), + object.getSignature() + ); + + return object.getId(); + }); + } + + /** + * Gets all unsynced objects from the store. + */ + getUnsyncedObjects(): SignedObject[] { + const rows = this.client.all( + `select id, payload, certificates, signature from objects where server_synced_at is null` + ) as Array<{ + id: string; + payload: Buffer; + certificates: Buffer; + signature: Buffer; + }>; + + return rows.map( + (row) => + new SignedObject( + unsafeParse(UuidSchema, row.id), + row.payload, + row.certificates, + row.signature + ) + ); + } + + /** + * Marks an object as synced with the server. + */ + markObjectAsSynced(id: Uuid): void { + this.client.run( + `update objects set server_synced_at = current_timestamp where id = ?`, + id + ); + } } diff --git a/apps/cacvote-mark/backend/test/mock_cacvote_server.ts b/apps/cacvote-mark/backend/test/mock_cacvote_server.ts new file mode 100644 index 0000000000..a71eb8bfc7 --- /dev/null +++ b/apps/cacvote-mark/backend/test/mock_cacvote_server.ts @@ -0,0 +1,40 @@ +import { + IncomingMessage, + RequestListener, + Server, + ServerResponse, + createServer, +} from 'http'; +import { AddressInfo } from 'net'; +import { deferred } from '@votingworks/basics'; +import { Client } from '../src/cacvote-server/client'; + +export interface MockCacvoteServer { + inner: Server; + client: Client; + stop(): Promise; +} + +export async function mockCacvoteServer< + Request extends typeof IncomingMessage, + Response extends typeof ServerResponse, +>(handler: RequestListener): Promise { + const listening = deferred(); + const server = createServer(handler).listen( + { host: '127.0.0.1', port: 0 }, + listening.resolve + ); + await listening.promise; + const address = server.address() as AddressInfo; + const url = new URL(`http://127.0.0.1:${address.port}`); + const client = new Client(url); + return { + inner: server, + client, + async stop() { + await new Promise((resolve) => { + server.close(resolve); + }); + }, + }; +} diff --git a/libs/auth/src/certs.ts b/libs/auth/src/certs.ts index efdd80c17e..62e72215eb 100644 --- a/libs/auth/src/certs.ts +++ b/libs/auth/src/certs.ts @@ -13,7 +13,7 @@ const VX_IANA_ENTERPRISE_OID = '1.3.6.1.4.1.59817'; /** * Instead of overloading existing X.509 cert fields, we're using our own custom cert fields. */ -const VX_CUSTOM_CERT_FIELD = { +export const VX_CUSTOM_CERT_FIELD = { /** * One of: admin, central-scan, mark, mark-scan, scan, card (the first five referring to * machines) @@ -175,32 +175,41 @@ export const CERT_EXPIRY_IN_DAYS = { } as const; /** - * Parses the provided cert and returns the custom cert fields. Throws an error if the cert doesn't - * follow VotingWorks's cert format. + * Gets the subject of a cert. */ -export async function parseCert(cert: Buffer): Promise { +export async function getCertSubjectFields( + cert: Buffer +): Promise> { const response = await openssl(['x509', '-noout', '-subject', '-in', cert]); - - const responseString = response.toString('utf-8'); - assert(responseString.startsWith('subject=')); - const certSubject = responseString.replace('subject=', '').trimEnd(); - + const certSubject = response + .toString('utf-8') + .replace('subject=', '') + .trimEnd(); const certFieldsList = certSubject .split(',') .map((field) => field.trimStart()); - const certFields: { [fieldName: string]: string } = {}; + const certFields = new Map(); for (const certField of certFieldsList) { const [fieldName, fieldValue] = certField.split(' = '); if (fieldName && fieldValue) { - certFields[fieldName] = fieldValue; + certFields.set(fieldName, fieldValue); } } + return certFields; +} + +/** + * Parses the provided cert and returns the custom cert fields. Throws an error if the cert doesn't + * follow VotingWorks's cert format. + */ +export async function parseCert(cert: Buffer): Promise { + const certFields = await getCertSubjectFields(cert); const certDetails = CustomCertFieldsSchema.parse({ - component: certFields[VX_CUSTOM_CERT_FIELD.COMPONENT], - jurisdiction: certFields[VX_CUSTOM_CERT_FIELD.JURISDICTION], - cardType: certFields[VX_CUSTOM_CERT_FIELD.CARD_TYPE], - electionHash: certFields[VX_CUSTOM_CERT_FIELD.ELECTION_HASH], + component: certFields.get(VX_CUSTOM_CERT_FIELD.COMPONENT), + jurisdiction: certFields.get(VX_CUSTOM_CERT_FIELD.JURISDICTION), + cardType: certFields.get(VX_CUSTOM_CERT_FIELD.CARD_TYPE), + electionHash: certFields.get(VX_CUSTOM_CERT_FIELD.ELECTION_HASH), }); return certDetails; diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index 8da6bb068f..1807855fda 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -2,7 +2,9 @@ export * from './artifact_authentication'; export * as cac from './cac'; export type { CardStatus } from './card'; export * from './cast_vote_record_hashes'; +export * as certs from './certs'; export * from './config'; +export * as cryptography from './cryptography'; export * from './dipped_smart_card_auth_api'; export * from './dipped_smart_card_auth'; export * from './inserted_smart_card_auth_api'; diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index 0707d2983c..8e57de305f 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -65,6 +65,7 @@ impl sqlx::Type for JurisdictionCode { #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SignedObject { + pub id: Uuid, #[serde(with = "Base64Standard")] pub payload: Vec, #[serde(with = "Base64Standard")] @@ -93,6 +94,7 @@ impl SignedObject { .concat(); Ok(Self { + id: Uuid::new_v4(), payload, certificates, signature, @@ -109,7 +111,6 @@ impl SignedObject { } #[cfg(feature = "openssl")] - #[must_use] pub fn verify(&self) -> Result { let public_key = match self.to_x509()?.first() { Some(x509) => x509.public_key()?, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6b708a50d..049a17e3e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@votingworks/test-utils': specifier: workspace:* version: link:../../../libs/test-utils + esbuild-runner: + specifier: 2.2.2 + version: 2.2.2(esbuild@0.17.11) eslint: specifier: 8.51.0 version: 8.51.0 diff --git a/services/cacvote-server/bin/create-object.rs b/services/cacvote-server/bin/create-object.rs index 0dc34c6cc6..0d4a8d6e90 100644 --- a/services/cacvote-server/bin/create-object.rs +++ b/services/cacvote-server/bin/create-object.rs @@ -7,6 +7,7 @@ use openssl::{ }; use serde::{Deserialize, Serialize}; use types_rs::cacvote::{Payload, SignedObject}; +use uuid::Uuid; #[derive(Debug, Serialize, Deserialize)] struct TestObject { @@ -32,11 +33,11 @@ fn sign_and_verify( public_key: &PKey, ) -> color_eyre::Result> { let mut signer = Signer::new(MessageDigest::sha256(), private_key)?; - signer.update(&payload)?; + signer.update(payload)?; let signature = signer.sign_to_vec()?; let mut verifier = Verifier::new(MessageDigest::sha256(), public_key)?; - verifier.update(&payload)?; + verifier.update(payload)?; assert!(verifier.verify(&signature)?); Ok(signature) } @@ -57,12 +58,13 @@ async fn main() -> color_eyre::Result<()> { let payload = serde_json::to_vec(&payload)?; let signature = sign_and_verify(&payload, &private_key, &public_key)?; let signed_object = SignedObject { + id: Uuid::new_v4(), payload, certificates, signature, }; - let client = Client::new("http://localhost:8000".parse()?); + let client = Client::localhost(); let object_id = client.create_object(signed_object).await?; println!("object_id: {object_id:?}"); diff --git a/services/cacvote-server/src/app.rs b/services/cacvote-server/src/app.rs index 79ec534f15..9b2a161145 100644 --- a/services/cacvote-server/src/app.rs +++ b/services/cacvote-server/src/app.rs @@ -115,23 +115,25 @@ enum Error { impl IntoResponse for Error { fn into_response(self) -> Response { - match self { + let (status, json) = match self { Error::Database(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })), - ) - .into_response(), + ), Error::Serde(e) => ( StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() })), - ) - .into_response(), - error @ Error::NotFound => (StatusCode::NOT_FOUND, error.to_string()).into_response(), + ), + error @ Error::NotFound => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": error.to_string() })), + ), Error::Other(e) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })), - ) - .into_response(), - } + ), + }; + tracing::error!("Responding with error: {status} {json:?}"); + (status, json).into_response() } } diff --git a/services/cacvote-server/src/client.rs b/services/cacvote-server/src/client.rs index 14c9852bbb..8218394a73 100644 --- a/services/cacvote-server/src/client.rs +++ b/services/cacvote-server/src/client.rs @@ -186,11 +186,11 @@ mod tests { public_key: &PKey, ) -> color_eyre::Result> { let mut signer = Signer::new(MessageDigest::sha256(), private_key)?; - signer.update(&payload)?; + signer.update(payload)?; let signature = signer.sign_to_vec()?; let mut verifier = Verifier::new(MessageDigest::sha256(), public_key)?; - verifier.update(&payload)?; + verifier.update(payload)?; assert!(verifier.verify(&signature)?); Ok(signature) } @@ -213,6 +213,7 @@ mod tests { // create the object let object_id = client .create_object(SignedObject { + id: Uuid::new_v4(), payload, certificates: certificates.clone(), signature: signature.clone(), @@ -260,6 +261,7 @@ mod tests { client .create_object(SignedObject { + id: Uuid::new_v4(), payload, // invalid certificates and signature certificates: vec![], diff --git a/services/cacvote-server/src/db.rs b/services/cacvote-server/src/db.rs index 1d83af9208..712914a0bc 100644 --- a/services/cacvote-server/src/db.rs +++ b/services/cacvote-server/src/db.rs @@ -49,19 +49,19 @@ pub async fn create_object( let mut txn = connection.begin().await?; - let object = sqlx::query!( + sqlx::query!( r#" - INSERT INTO objects (jurisdiction, object_type, payload, certificates, signature) - VALUES ($1, $2, $3, $4, $5) - RETURNING id + INSERT INTO objects (id, jurisdiction, object_type, payload, certificates, signature) + VALUES ($1, $2, $3, $4, $5, $6) "#, + &object.id, jurisdiction_code.as_str(), object_type, &object.payload, &object.certificates, &object.signature ) - .fetch_one(&mut *txn) + .execute(&mut *txn) .await?; tracing::debug!("Creating object with id {}", object.id); @@ -173,6 +173,7 @@ pub async fn get_object_by_id( .await?; Ok(object.map(|object| SignedObject { + id: object_id, payload: object.payload, certificates: object.certificates, signature: object.signature, diff --git a/vxsuite.code-workspace b/vxsuite.code-workspace index e5b3602625..ebf75220e6 100644 --- a/vxsuite.code-workspace +++ b/vxsuite.code-workspace @@ -140,6 +140,7 @@ "cSpell.words": [ "accuvote", "AGPL", + "cacvote", "canonicalization", "canonicalize", "Canonicalized", @@ -159,10 +160,12 @@ "pollworker", "previewable", "qrcode", + "reqwest", "SEMS", "serializable", "smartcard", "smartcards", + "sqlx", "stylelint", "superadmin", "testid", @@ -177,6 +180,7 @@ "unprogram", "unprogramming", "unprograms", + "unsynced", "usbstick", "votingworks", "VVSG",