From f542862fe9bd166a463868053602e0136799faa2 Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:23:28 -0700 Subject: [PATCH] feat: pull some objects from the server on sync --- .../db/migrations/20240305194646_objects.sql | 2 +- apps/cacvote-jx-terminal/backend/src/db.rs | 60 +++ apps/cacvote-jx-terminal/backend/src/sync.rs | 26 +- apps/cacvote-mark/backend/schema.sql | 2 +- .../backend/src/cacvote-server/sync.test.ts | 448 +++++++++++------- .../backend/src/cacvote-server/sync.ts | 51 +- apps/cacvote-mark/backend/src/store.ts | 65 ++- .../backend/test/mock_cacvote_server.ts | 104 +++- .../db/migrations/20240305194646_objects.sql | 2 +- 9 files changed, 579 insertions(+), 181 deletions(-) diff --git a/apps/cacvote-jx-terminal/backend/db/migrations/20240305194646_objects.sql b/apps/cacvote-jx-terminal/backend/db/migrations/20240305194646_objects.sql index 20de7f7d0..a9caab439 100644 --- a/apps/cacvote-jx-terminal/backend/db/migrations/20240305194646_objects.sql +++ b/apps/cacvote-jx-terminal/backend/db/migrations/20240305194646_objects.sql @@ -6,7 +6,7 @@ CREATE TABLE objects ( jurisdiction varchar(255) NOT NULL, -- what type of object is this. de-normalized out of `payload`, - -- e.g. "election" + -- e.g. "Election" object_type varchar(255) NOT NULL, -- raw object data, must be JSON with fields `object_type` and `data` diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index 83f94452d..dcf59265c 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -10,10 +10,12 @@ use std::time::Duration; use base64_serde::base64_serde_type; +use color_eyre::eyre::bail; use sqlx::postgres::PgPoolOptions; use sqlx::{Connection, PgPool}; use tracing::Level; use types_rs::cacvote::{JournalEntry, JurisdictionCode, SignedObject}; +use uuid::Uuid; use crate::config::Config; @@ -33,6 +35,41 @@ pub(crate) async fn setup(config: &Config) -> color_eyre::Result { Ok(pool) } +#[tracing::instrument(skip(connection, object))] +pub async fn add_object_from_server( + connection: &mut sqlx::PgConnection, + object: &SignedObject, +) -> color_eyre::Result { + if !object.verify()? { + bail!("Unable to verify signature/certificates") + } + + let Some(jurisdiction_code) = object.jurisdiction_code() else { + bail!("No jurisdiction found in certificate"); + }; + + let object_type = object.try_to_inner()?.object_type; + + sqlx::query!( + r#" + INSERT INTO objects (id, jurisdiction, object_type, payload, certificates, signature, server_synced_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) + "#, + &object.id, + jurisdiction_code.as_str(), + object_type, + &object.payload, + &object.certificates, + &object.signature + ) + .execute(connection) + .await?; + + tracing::debug!("Created object with id {}", object.id); + + Ok(object.id) +} + #[tracing::instrument(skip(connection, entries))] pub(crate) async fn add_journal_entries( connection: &mut sqlx::PgConnection, @@ -118,3 +155,26 @@ pub(crate) async fn mark_object_synced( Ok(()) } + +pub(crate) async fn get_journal_entries_for_objects_to_pull( + executor: &mut sqlx::PgConnection, +) -> color_eyre::eyre::Result> { + Ok(sqlx::query_as!( + JournalEntry, + r#" + SELECT + id, + object_id, + jurisdiction as "jurisdiction: JurisdictionCode", + object_type, + action, + created_at + FROM journal_entries + WHERE object_id IS NOT NULL + AND object_type IN ('RegistrationRequest') + AND object_id NOT IN (SELECT id FROM objects) + "#, + ) + .fetch_all(&mut *executor) + .await?) +} diff --git a/apps/cacvote-jx-terminal/backend/src/sync.rs b/apps/cacvote-jx-terminal/backend/src/sync.rs index f015f7792..11a0f9f68 100644 --- a/apps/cacvote-jx-terminal/backend/src/sync.rs +++ b/apps/cacvote-jx-terminal/backend/src/sync.rs @@ -1,7 +1,6 @@ //! CACVote Server synchronization utilities. use cacvote_server::client::Client; -use sqlx::PgPool; use tokio::time::sleep; use crate::{ @@ -11,7 +10,7 @@ use crate::{ /// Spawns an async loop that synchronizes with the CACVote Server on a fixed /// schedule. -pub(crate) async fn sync_periodically(pool: &PgPool, config: Config) { +pub(crate) async fn sync_periodically(pool: &sqlx::PgPool, config: Config) { let mut connection = pool .acquire() .await @@ -43,6 +42,7 @@ pub(crate) async fn sync( push_objects(executor, client).await?; pull_journal_entries(executor, client).await?; + pull_objects(executor, client).await?; Ok(()) } @@ -77,3 +77,25 @@ async fn push_objects( Ok(()) } + +async fn pull_objects( + executor: &mut sqlx::PgConnection, + client: &Client, +) -> color_eyre::eyre::Result<()> { + let journal_entries = db::get_journal_entries_for_objects_to_pull(executor).await?; + for journal_entry in journal_entries { + match client.get_object_by_id(journal_entry.object_id).await? { + Some(object) => { + db::add_object_from_server(executor, &object).await?; + } + None => { + tracing::warn!( + "Object with id {} not found on CACVote Server", + journal_entry.object_id + ); + } + } + } + + Ok(()) +} diff --git a/apps/cacvote-mark/backend/schema.sql b/apps/cacvote-mark/backend/schema.sql index b2b754381..cbd2ae727 100644 --- a/apps/cacvote-mark/backend/schema.sql +++ b/apps/cacvote-mark/backend/schema.sql @@ -12,7 +12,7 @@ create table objects ( jurisdiction varchar(255) not null, -- what type of object is this. de-normalized out of `payload`, - -- e.g. "election" + -- e.g. "Election" object_type varchar(255) not null, -- raw object data, must be JSON with fields `object_type` and `data` diff --git a/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts b/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts index 2ea822f4f..16e626b76 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts @@ -1,14 +1,17 @@ -import { Buffer } from 'buffer'; +import { deferred, err } from '@votingworks/basics'; import { fakeLogger } from '@votingworks/logging'; -import { deferred } from '@votingworks/basics'; -import { v4 } from 'uuid'; import { unsafeParse } from '@votingworks/types'; -import { DateTime } from 'luxon'; +import { Buffer } from 'buffer'; import { readFile } from 'fs/promises'; +import { DateTime } from 'luxon'; import { join } from 'path'; -import { sync, syncPeriodically } from './sync'; +import { v4 } from 'uuid'; +import { + MockCacvoteAppBuilder, + mockCacvoteServer, +} from '../../test/mock_cacvote_server'; import { Store } from '../store'; -import { mockCacvoteServer } from '../../test/mock_cacvote_server'; +import { sync, syncPeriodically } from './sync'; import { JournalEntry, JurisdictionCodeSchema, @@ -17,25 +20,24 @@ import { UuidSchema, } from './types'; +async function getCertificates(): Promise { + return await readFile( + join( + __dirname, + '../../../../../libs/auth/certs/dev/vx-admin-cert-authority-cert.pem' + ) + ); +} + 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('[]'); + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onGetJournalEntries(() => { getJournalEntriesDeferred.resolve(); - break; - - default: - throw new Error(`Unexpected request: ${req.url}`); - } - }); + }) + .build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -53,28 +55,18 @@ test('syncPeriodically', async () => { }); test('syncPeriodically loops', async () => { - let statusCount = 0; + let requestCount = 0; const done = deferred(); - const server = await mockCacvoteServer((req, res) => { - switch (`${req.method} ${req.url}`) { - case 'GET /api/status': - statusCount += 1; - if (statusCount >= 4) { + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onGetJournalEntries(() => { + requestCount += 1; + if (requestCount >= 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}`); - } - }); + }) + .build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -92,17 +84,13 @@ test('syncPeriodically loops', async () => { }); 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 server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onStatusCheck((res) => { + res.status(500).end('Internal Server Error'); + }) + .build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -124,23 +112,14 @@ test('sync / checkStatus failure', async () => { 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'); + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onGetJournalEntries((res) => { + res.status(500).end('Internal Server Error'); getJournalEntriesDeferred.resolve(); - break; - - default: - throw new Error(`Unexpected request: ${req.url}`); - } - }); + }) + .build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -161,22 +140,9 @@ test('sync / getJournalEntries failure', async () => { }); 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 server = await mockCacvoteServer( + new MockCacvoteAppBuilder().withJournalEntries([]).build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -212,22 +178,9 @@ test('sync / getJournalEntries success / with entries', async () => { 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 server = await mockCacvoteServer( + new MockCacvoteAppBuilder().withJournalEntries([journalEntry]).build() + ); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -251,22 +204,7 @@ test('sync / getJournalEntries success / with entries', async () => { }); 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 server = await mockCacvoteServer(new MockCacvoteAppBuilder().build()); const store = Store.memoryStore(); const logger = fakeLogger(); @@ -291,40 +229,21 @@ test('sync / createObject success / with objects', async () => { 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' - ) - ), + await getCertificates(), 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 server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onPostObject((_req, res) => { + res.status(201).send(objectId); + }) + .build() + ); const store = Store.memoryStore(); (await store.addObject(object)).unsafeUnwrap(); - expect(store.getUnsyncedObjects()).toHaveLength(1); + expect(store.getObjectsToPush()).toHaveLength(1); const logger = fakeLogger(); await sync(server.client, store, logger); @@ -340,7 +259,7 @@ test('sync / createObject success / with objects', async () => { }) ); - expect(store.getUnsyncedObjects()).toHaveLength(0); + expect(store.getObjectsToPush()).toHaveLength(0); }); test('sync / createObject failure', async () => { @@ -348,55 +267,238 @@ test('sync / createObject failure', async () => { 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' - ) - ), + await getCertificates(), 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; + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .onPostObject((_req, res) => { + res.status(500).end('Internal Server Error'); + }) + .build() + ); - case 'GET /api/journal-entries': - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end('[]'); - break; + const store = Store.memoryStore(); + (await store.addObject(object)).unsafeUnwrap(); + expect(store.getObjectsToPush()).toHaveLength(1); - case 'POST /api/objects': - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end('Internal Server Error'); - break; + const logger = fakeLogger(); + await sync(server.client, store, logger); - default: - throw new Error(`Unexpected request: ${req.url}`); - } - }); + // 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.getObjectsToPush()).toHaveLength(1); +}); + +test.each(['RegistrationRequest', 'Registration', 'Election'])( + 'sync / fetches %s objects', + async (objectType) => { + const objectId = unsafeParse(UuidSchema, v4()); + const object = new SignedObject( + objectId, + Buffer.from( + JSON.stringify(new Payload('RegistrationRequest', Buffer.from('{}'))) + ), + await getCertificates(), + Buffer.of(7, 8, 9) + ); + const journalEntry = new JournalEntry( + unsafeParse(UuidSchema, v4()), + objectId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + objectType, + 'create', + DateTime.now() + ); + + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .withJournalEntries([journalEntry]) + .onGetObjectById((req, res) => { + const requestObjectId = unsafeParse(UuidSchema, req.params['id']); + expect(requestObjectId).toEqual(objectId.toString()); + res.json(object); + }) + .build() + ); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); + expect(store.getObjectById(objectId)).toEqual(object); + } +); + +test('sync / fetch ignores unknown object types', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const journalEntry = new JournalEntry( + unsafeParse(UuidSchema, v4()), + objectId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + 'UnknownType', + 'create', + DateTime.now() + ); + + const app = new MockCacvoteAppBuilder() + .withJournalEntries([journalEntry]) + .build(); + const server = await mockCacvoteServer(app); 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(); + + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); + expect(store.getObjectById(objectId)).toBeUndefined(); +}); + +test('sync / fetch failing to get object', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const journalEntry = new JournalEntry( + unsafeParse(UuidSchema, v4()), + objectId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + 'Registration', + 'create', + DateTime.now() + ); + + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .withJournalEntries([journalEntry]) + .onGetObjectById((_req, res) => { + res.status(500).end('Internal Server Error'); + }) + .build() + ); + + const store = Store.memoryStore(); const logger = fakeLogger(); await sync(server.client, store, logger); // wait for the server to stop await server.stop(); + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); + expect(store.getObjectById(objectId)).toBeUndefined(); + expect(logger.log).toHaveBeenCalledWith( expect.anything(), 'system', expect.objectContaining({ - message: expect.stringMatching(/Failed to push object/), + message: expect.stringMatching(/Failed to get object/), + disposition: 'failure', + }) + ); +}); + +test('sync / fetch object but object does not exist', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const journalEntry = new JournalEntry( + unsafeParse(UuidSchema, v4()), + objectId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + 'Registration', + 'create', + DateTime.now() + ); + + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder().withJournalEntries([journalEntry]).build() + ); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); + expect(store.getObjectById(objectId)).toBeUndefined(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/not found/), disposition: 'failure', }) ); +}); + +test('sync / fetch object but cannot add to store', async () => { + const objectId = unsafeParse(UuidSchema, v4()); + const object = new SignedObject( + objectId, + Buffer.from( + JSON.stringify(new Payload('Registration', Buffer.of(1, 2, 3))) + ), + await getCertificates(), + Buffer.of(7, 8, 9) + ); + const journalEntry = new JournalEntry( + unsafeParse(UuidSchema, v4()), + objectId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + 'Registration', + 'create', + DateTime.now() + ); - expect(store.getUnsyncedObjects()).toHaveLength(1); + const server = await mockCacvoteServer( + new MockCacvoteAppBuilder() + .withJournalEntries([journalEntry]) + .withObject(object) + .build() + ); + + const store = Store.memoryStore(); + const logger = fakeLogger(); + + jest + .spyOn(store, 'addObject') + .mockResolvedValue(err(new SyntaxError('bad object!'))); + + await sync(server.client, store, logger); + + // wait for the server to stop + await server.stop(); + + const entries = store.getJournalEntries(); + expect(entries).toEqual([journalEntry]); + expect(store.getObjectById(objectId)).toBeUndefined(); + + expect(logger.log).toHaveBeenCalledWith( + expect.anything(), + 'system', + expect.objectContaining({ + message: expect.stringMatching(/Failed to add object/), + disposition: 'failure', + }) + ); }); diff --git a/apps/cacvote-mark/backend/src/cacvote-server/sync.ts b/apps/cacvote-mark/backend/src/cacvote-server/sync.ts index 863346400..a4f3a045f 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/sync.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/sync.ts @@ -48,7 +48,7 @@ async function pushObjects( store: Store, logger: Logger ): Promise { - const objects = store.getUnsyncedObjects(); + const objects = store.getObjectsToPush(); if (objects.length === 0) { await logger.log(LogEventId.ApplicationStartup, 'system', { @@ -81,6 +81,54 @@ async function pushObjects( } } +async function pullObjects( + client: Client, + store: Store, + logger: Logger +): Promise { + const journalEntriesForObjectsToFetch = + store.getJournalEntriesForObjectsToPull(); + + for (const journalEntry of journalEntriesForObjectsToFetch) { + const getObjectResult = await client.getObjectById( + journalEntry.getObjectId() + ); + + if (getObjectResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to get object with ID '${journalEntry.getObjectId()}' from CACVote Server: ${getObjectResult.err()}`, + disposition: 'failure', + }); + continue; + } + + const object = getObjectResult.ok(); + + if (!object) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Object with ID '${journalEntry.getObjectId()}' not found on CACVote Server`, + disposition: 'failure', + }); + continue; + } + + const addObjectResult = await store.addObject(object); + + if (addObjectResult.isErr()) { + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Failed to add object with ID '${object.getId()}' to the store: ${addObjectResult.err()}`, + disposition: 'failure', + }); + continue; + } + + await logger.log(LogEventId.ApplicationStartup, 'system', { + message: `Got object with ID '${object.getId()}' from CACVote Server`, + disposition: 'success', + }); + } +} + /** * Perform a sync with the CACVote Server now. */ @@ -104,6 +152,7 @@ export async function sync( await pushObjects(client, store, logger); await pullJournalEntries(client, store, logger); + await pullObjects(client, store, logger); } catch (err) { await logger.log(LogEventId.ApplicationStartup, 'system', { message: `Failed to sync with CACVote Server: ${err}`, diff --git a/apps/cacvote-mark/backend/src/store.ts b/apps/cacvote-mark/backend/src/store.ts index c20ae7bbf..3b0464725 100644 --- a/apps/cacvote-mark/backend/src/store.ts +++ b/apps/cacvote-mark/backend/src/store.ts @@ -190,10 +190,73 @@ export class Store { }); } + /** + * Gets an object from the store by its ID. + */ + getObjectById(objectId: Uuid): Optional { + const row = this.client.one( + `select id, payload, certificates, signature from objects where id = ?`, + objectId + ) as Optional<{ + id: string; + payload: Buffer; + certificates: Buffer; + signature: Buffer; + }>; + + return row + ? new SignedObject( + unsafeParse(UuidSchema, row.id), + row.payload, + row.certificates, + row.signature + ) + : undefined; + } + + getJournalEntriesForObjectsToPull(): JournalEntry[] { + const objectTypesToPull = [ + 'RegistrationRequest', + 'Registration', + 'Election', + ]; + const action = 'create'; + + const rows = this.client.all( + `select je.id, je.object_id, je.jurisdiction, je.object_type, je.created_at + from journal_entries je + left join objects o on je.object_id = o.id + where je.object_type in (${objectTypesToPull.map(() => '?').join(', ')}) + and je.action = ? + and o.id is null + order by je.created_at`, + ...objectTypesToPull, + action + ) as Array<{ + id: string; + object_id: string; + jurisdiction: string; + object_type: 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, + action, + DateTime.fromSQL(row.created_at) + ) + ); + } + /** * Gets all unsynced objects from the store. */ - getUnsyncedObjects(): SignedObject[] { + getObjectsToPush(): SignedObject[] { const rows = this.client.all( `select id, payload, certificates, signature from objects where server_synced_at is null` ) as Array<{ diff --git a/apps/cacvote-mark/backend/test/mock_cacvote_server.ts b/apps/cacvote-mark/backend/test/mock_cacvote_server.ts index a71eb8bfc..f14cc26f8 100644 --- a/apps/cacvote-mark/backend/test/mock_cacvote_server.ts +++ b/apps/cacvote-mark/backend/test/mock_cacvote_server.ts @@ -1,3 +1,6 @@ +import { deferred } from '@votingworks/basics'; +import { unsafeParse } from '@votingworks/types'; +import app, { Application, Request, Response } from 'express'; import { IncomingMessage, RequestListener, @@ -6,8 +9,13 @@ import { createServer, } from 'http'; import { AddressInfo } from 'net'; -import { deferred } from '@votingworks/basics'; import { Client } from '../src/cacvote-server/client'; +import { + JournalEntry, + SignedObject, + Uuid, + UuidSchema, +} from '../src/cacvote-server/types'; export interface MockCacvoteServer { inner: Server; @@ -38,3 +46,97 @@ export async function mockCacvoteServer< }, }; } + +export class MockCacvoteAppBuilder { + private journalEntries: JournalEntry[] = []; + private readonly objects = new Map(); + private onGetJournalEntriesCallback: (res: Response) => void = () => + undefined; + private onStatusCheckCallback: (res: Response) => void = () => undefined; + private onPostObjectCallback: (req: Request, res: Response) => void = () => + undefined; + private onGetObjectByIdCallback: (req: Request, res: Response) => void = () => + undefined; + + withJournalEntries(journalEntries: JournalEntry[]): this { + this.journalEntries = journalEntries; + return this; + } + + withObject(object: SignedObject): this { + this.objects.set(object.getId(), object); + return this; + } + + onGetJournalEntries(cb: (res: Response) => void): this { + this.onGetJournalEntriesCallback = cb; + return this; + } + + onStatusCheck(cb: (res: Response) => void): this { + this.onStatusCheckCallback = cb; + return this; + } + + onPostObject(cb: (req: Request, res: Response) => void): this { + this.onPostObjectCallback = cb; + return this; + } + + onGetObjectById(cb: (req: Request, res: Response) => void): this { + this.onGetObjectByIdCallback = cb; + return this; + } + + build(): Application { + const server = app(); + + server.get('/api/status', (_req, res) => { + this.onStatusCheckCallback(res); + + if (res.headersSent) { + return; + } + + res.status(200).send('{}'); + }); + + server.get('/api/journal-entries', (_req, res) => { + this.onGetJournalEntriesCallback(res); + + if (res.headersSent) { + return; + } + + res.status(200).json(this.journalEntries); + }); + + server.post('/api/objects', (req, res) => { + this.onPostObjectCallback(req, res); + + if (res.headersSent) { + return; + } + + res.status(201).end(); + }); + + server.get('/api/objects/:id', (req, res) => { + this.onGetObjectByIdCallback(req, res); + + if (res.headersSent) { + return; + } + + const id = unsafeParse(UuidSchema, req.params.id); + const object = this.objects.get(id); + if (object) { + res.status(200).json(object); + } else { + res.status(404).end(); + } + }); + + return server; + } +} diff --git a/services/cacvote-server/db/migrations/20240305194646_objects.sql b/services/cacvote-server/db/migrations/20240305194646_objects.sql index cabd4c7e5..671b36fe5 100644 --- a/services/cacvote-server/db/migrations/20240305194646_objects.sql +++ b/services/cacvote-server/db/migrations/20240305194646_objects.sql @@ -6,7 +6,7 @@ CREATE TABLE objects ( jurisdiction varchar(255) NOT NULL, -- what type of object is this. de-normalized out of `payload`, - -- e.g. "election" + -- e.g. "Election" object_type varchar(255) NOT NULL, -- raw object data, must be JSON with fields `object_type` and `data`