From 388fccf45b2a37ac479c87ce50e120a7d52674cc Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Thu, 14 Mar 2024 14:46:23 -0700 Subject: [PATCH] Basic Mocking (#83) * chore(jx): remove `lib.rs` It was making some things show up as unused because, in the library, they were unused. * refactor(jx): setup basic card mocking Will be useful later, but not going too deep right now. * feat(mark): registration request progress * test: ignore `sync` result for now --- Cargo.lock | 72 ++++++ Cargo.toml | 1 + apps/cacvote-jx-terminal/backend/Cargo.toml | 1 + apps/cacvote-jx-terminal/backend/src/app.rs | 5 +- apps/cacvote-jx-terminal/backend/src/lib.rs | 1 - apps/cacvote-jx-terminal/backend/src/main.rs | 18 +- .../backend/src/smartcard.rs | 17 +- apps/cacvote-jx-terminal/backend/src/sync.rs | 58 +++++ apps/cacvote-mark/backend/src/app.ts | 151 +++++++++---- .../backend/src/cacvote-server/client.ts | 14 +- .../backend/src/cacvote-server/sync.test.ts | 12 +- .../backend/src/cacvote-server/types.ts | 213 ++++++++++++++++-- apps/cacvote-mark/backend/src/index.ts | 4 +- apps/cacvote-mark/backend/src/store.test.ts | 83 ++++++- apps/cacvote-mark/backend/src/store.ts | 100 +++++++- apps/cacvote-mark/backend/src/types/db.ts | 157 ------------- apps/cacvote-mark/frontend/src/api.ts | 12 +- .../src/screens/registration/start_screen.tsx | 27 ++- 18 files changed, 655 insertions(+), 291 deletions(-) delete mode 100644 apps/cacvote-jx-terminal/backend/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5394d98496..e07f4a5987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,6 +432,7 @@ dependencies = [ "color-eyre", "dotenvy", "futures-core", + "mockall", "openssl", "pcsc", "pretty_assertions", @@ -991,6 +992,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "either" version = "1.9.0" @@ -1157,6 +1164,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "futures-channel" version = "0.3.30" @@ -1888,6 +1901,33 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mockall" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43766c2b5203b10de348ffe19f7e54564b64f3d6018ff7648d1e2d6d3a0f0a48" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cbce79ec385a1d4f54baa90a76401eb15d9cab93685f62e7e9f942aa00ae2" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.46", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -2235,6 +2275,32 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -3121,6 +3187,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.56" diff --git a/Cargo.toml b/Cargo.toml index 949baccaae..dcf385214e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ js-sys = "0.3.64" lazy_static = "1.4.0" log = "0.4.19" logging_timer = "1.1.0" +mockall = "0.12.1" nanoid = "0.4.0" neon = { version = "0.10", default-features = false, features = ["napi-6"] } num_enum = "0.7.1" diff --git a/apps/cacvote-jx-terminal/backend/Cargo.toml b/apps/cacvote-jx-terminal/backend/Cargo.toml index d783f1c4e2..5005609653 100644 --- a/apps/cacvote-jx-terminal/backend/Cargo.toml +++ b/apps/cacvote-jx-terminal/backend/Cargo.toml @@ -34,5 +34,6 @@ types-rs = { workspace = true, features = ["backend"] } uuid = { workspace = true } [dev-dependencies] +mockall = { workspace = true } pretty_assertions = { workspace = true } proptest = { workspace = true } diff --git a/apps/cacvote-jx-terminal/backend/src/app.rs b/apps/cacvote-jx-terminal/backend/src/app.rs index 16f827c852..d48d0c971f 100644 --- a/apps/cacvote-jx-terminal/backend/src/app.rs +++ b/apps/cacvote-jx-terminal/backend/src/app.rs @@ -22,14 +22,15 @@ use tracing::Level; use crate::config::{Config, MAX_REQUEST_SIZE}; use crate::smartcard; -type AppState = (Config, PgPool, smartcard::StatusGetter); +// type AppState = (Config, PgPool, smartcard::StatusGetter); +type AppState = (Config, PgPool, smartcard::DynStatusGetter); /// Prepares the application with all the routes. Run the application with /// `app::run(…)` once you have it. pub(crate) fn setup( pool: PgPool, config: Config, - smartcard_status: smartcard::StatusGetter, + smartcard_status: smartcard::DynStatusGetter, ) -> Router { let _entered = tracing::span!(Level::DEBUG, "Setting up application").entered(); diff --git a/apps/cacvote-jx-terminal/backend/src/lib.rs b/apps/cacvote-jx-terminal/backend/src/lib.rs deleted file mode 100644 index db7e584100..0000000000 --- a/apps/cacvote-jx-terminal/backend/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod smartcard; diff --git a/apps/cacvote-jx-terminal/backend/src/main.rs b/apps/cacvote-jx-terminal/backend/src/main.rs index 94585983c0..4753143efb 100644 --- a/apps/cacvote-jx-terminal/backend/src/main.rs +++ b/apps/cacvote-jx-terminal/backend/src/main.rs @@ -41,6 +41,9 @@ #![cfg_attr(test, allow(clippy::float_cmp))] #![cfg_attr(not(test), warn(clippy::print_stdout, clippy::dbg_macro))] +use std::sync::Arc; + +use auth_rs::Watcher; use clap::Parser; mod app; @@ -52,7 +55,6 @@ mod smartcard; mod sync; use crate::smartcard::StatusGetter; -pub use smartcard::watch; #[tokio::main] async fn main() -> color_eyre::Result<()> { @@ -62,14 +64,8 @@ async fn main() -> color_eyre::Result<()> { log::setup(&config)?; let pool = db::setup(&config).await?; sync::sync_periodically(&pool, config.clone()).await; - let smartcard_watcher = smartcard::watch(); - app::run( - app::setup( - pool, - config.clone(), - StatusGetter::new(smartcard_watcher.readers_with_cards()), - ), - &config, - ) - .await + let smartcard_watcher = Watcher::watch(); + let smartcard_status = StatusGetter::new(smartcard_watcher.readers_with_cards()); + let smartcard_status = Arc::new(smartcard_status) as smartcard::DynStatusGetter; + app::run(app::setup(pool, config.clone(), smartcard_status), &config).await } diff --git a/apps/cacvote-jx-terminal/backend/src/smartcard.rs b/apps/cacvote-jx-terminal/backend/src/smartcard.rs index f56ff6cf4b..8ca46e4c22 100644 --- a/apps/cacvote-jx-terminal/backend/src/smartcard.rs +++ b/apps/cacvote-jx-terminal/backend/src/smartcard.rs @@ -3,12 +3,15 @@ use std::ops::Deref; use std::sync::{Arc, Mutex}; use auth_rs::card_details::CardDetails; -use auth_rs::{CardReader, SharedCardReaders, Watcher}; +use auth_rs::{CardReader, SharedCardReaders}; use types_rs::cacvote::SmartcardStatus; -/// Watches for smartcard events. -pub fn watch() -> Watcher { - Watcher::watch() +pub(crate) type DynStatusGetter = Arc; + +#[cfg_attr(test, mockall::automock)] +pub(crate) trait StatusGetterTrait { + fn get(&self) -> SmartcardStatus; + fn get_card_details(&self) -> Option; } /// Provides access to the current smartcard status. @@ -40,11 +43,13 @@ impl StatusGetter { last_selected_card_reader_info: Arc::new(Mutex::new(None)), } } +} +impl StatusGetterTrait for StatusGetter { /// Gets the current smartcard status. #[allow(dead_code)] #[must_use] - pub(crate) fn get(&self) -> SmartcardStatus { + fn get(&self) -> SmartcardStatus { let readers = self.readers_with_cards.lock().unwrap(); if readers.is_empty() { @@ -56,7 +61,7 @@ impl StatusGetter { #[allow(dead_code)] #[must_use] - pub(crate) fn get_card_details(&self) -> Option { + fn get_card_details(&self) -> Option { let readers = self.readers_with_cards.lock().unwrap(); let reader_name = readers.first()?; diff --git a/apps/cacvote-jx-terminal/backend/src/sync.rs b/apps/cacvote-jx-terminal/backend/src/sync.rs index 11a0f9f68e..8aecbef069 100644 --- a/apps/cacvote-jx-terminal/backend/src/sync.rs +++ b/apps/cacvote-jx-terminal/backend/src/sync.rs @@ -99,3 +99,61 @@ async fn pull_objects( Ok(()) } + +#[cfg(test)] +mod tests { + use std::{net::TcpListener, sync::Arc}; + + use reqwest::Url; + use tracing::Level; + use types_rs::cacvote::SmartcardStatus; + + use crate::{ + app, + smartcard::{DynStatusGetter, MockStatusGetterTrait}, + }; + + use super::*; + + fn setup(pool: sqlx::PgPool, smartcard_status: DynStatusGetter) -> color_eyre::Result { + let listener = TcpListener::bind("0.0.0.0:0")?; + let addr = listener.local_addr()?; + let cacvote_url: Url = format!("http://{addr}").parse()?; + let config = Config { + cacvote_url: cacvote_url.clone(), + database_url: "".to_string(), + machine_id: "".to_string(), + port: addr.port(), + public_dir: None, + log_level: Level::DEBUG, + }; + + tokio::spawn(async move { + let app = app::setup(pool, config, smartcard_status); + axum::Server::from_tcp(listener) + .unwrap() + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + Ok(Client::new(cacvote_url)) + } + + #[sqlx::test(migrations = "db/migrations")] + async fn test_sync(pool: sqlx::PgPool) -> color_eyre::Result<()> { + let mut connection = pool.acquire().await?; + + let mut smartcard_status = MockStatusGetterTrait::new(); + smartcard_status + .expect_get() + .returning(|| SmartcardStatus::Card); + + let client = setup(pool, Arc::new(smartcard_status))?; + + // TODO: actually test `sync` + let _ = sync(&mut connection, &client).await; + + Ok(()) + } +} diff --git a/apps/cacvote-mark/backend/src/app.ts b/apps/cacvote-mark/backend/src/app.ts index 033f056dcb..de169503b2 100644 --- a/apps/cacvote-mark/backend/src/app.ts +++ b/apps/cacvote-mark/backend/src/app.ts @@ -1,18 +1,34 @@ import { cac } from '@votingworks/auth'; -import { err, ok, Optional, Result } from '@votingworks/basics'; +import { + err, + ok, + Optional, + Result, + throwIllegalValue, +} from '@votingworks/basics'; import * as grout from '@votingworks/grout'; import { BallotStyleId, ElectionDefinition, - Id, PrecinctId, + unsafeParse, VotesDict, } from '@votingworks/types'; import express, { Application } from 'express'; import { isDeepStrictEqual } from 'util'; +import { v4 } from 'uuid'; +import { DateTime } from 'luxon'; import { Auth, AuthStatus } from './types/auth'; -import { ClientId, RegistrationRequest, ServerId } from './types/db'; import { Workspace } from './workspace'; +import { + JurisdictionCode, + Payload, + RegistrationRequest, + SignedObject, + Uuid, + UuidSchema, +} from './cacvote-server/types'; +import { RegistrationRequestObjectType } from './store'; export type VoterStatus = | 'unregistered' @@ -20,40 +36,13 @@ export type VoterStatus = | 'registered' | 'voted'; -export interface CreateTestVoterInput { - jurisdictionId?: string; - - registrationRequest?: { - /** - * Voter's given name, i.e. first name. - */ - givenName?: string; - - /** - * Voter's family name, i.e. last name. - */ - familyName?: string; - }; - - registration?: { - /** - * Election definition as a JSON string. - */ - electionData?: string; - - /** - * Precinct ID to register the voter to. - */ - precinctId?: PrecinctId; - - /** - * Ballot style ID to register the voter to. - */ - ballotStyleId?: BallotStyleId; - }; -} - -function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { +function buildApi({ + auth, + workspace: { store }, +}: { + auth: Auth; + workspace: Workspace; +}) { async function getAuthStatus(): Promise { return await auth.getAuthStatus(); } @@ -65,6 +54,10 @@ function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { return auth.checkPin(input.pin); }, + getJurisdictions() { + return store.getJurisdictions(); + }, + async getVoterStatus(): Promise> { const authStatus: AuthStatus = await getAuthStatus(); @@ -72,8 +65,30 @@ function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { return undefined; } - // TODO: get voter status for the user - return undefined; + const { commonAccessCardId } = authStatus.card; + + // TODO: support more than one registration request for a given voter + const registrationRequest = store + .forEachRegistrationRequest({ commonAccessCardId }) + .first(); + + if (!registrationRequest) { + return { status: 'unregistered' }; + } + + const registration = store + .forEachRegistration({ + commonAccessCardId, + registrationRequestObjectId: registrationRequest.object.getId(), + }) + .first(); + + if (!registration) { + return { status: 'registration_pending' }; + } + + // TODO: check for a submitted ballot to see if the voter has voted + return { status: 'registered' }; }, async getRegistrationRequests(): Promise { @@ -88,13 +103,13 @@ function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { }, async createVoterRegistration(input: { - jurisdictionId: ServerId; + jurisdictionCode: JurisdictionCode; givenName: string; familyName: string; pin: string; }): Promise< Result< - { id: Id }, + { id: Uuid }, { type: 'not_logged_in' | 'incorrect_pin'; message: string } > > { @@ -108,11 +123,55 @@ function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { return err({ type: 'incorrect_pin', message: 'Incorrect PIN' }); } - const id = ClientId(); + const registrationRequest = new RegistrationRequest( + authStatus.card.commonAccessCardId, + input.jurisdictionCode, + input.givenName, + input.familyName, + DateTime.now() + ); + + const payload = Payload.of( + RegistrationRequestObjectType, + registrationRequest + ).toBuffer(); + const generateSignatureResult = await auth.generateSignature(payload, { + pin: input.pin, + }); + + if (generateSignatureResult.isErr()) { + const error = generateSignatureResult.err(); + switch (error.type) { + case 'card_error': + return err({ + type: 'not_logged_in', + message: `Card error: ${generateSignatureResult.err().message}`, + }); + + case 'incorrect_pin': + return err({ type: 'incorrect_pin', message: 'Incorrect PIN' }); + + default: + throwIllegalValue(error); + } + } + + // FIXME: this is wrong. the certificate does not have the jurisdiction field in it + // so the `store.addObject` call below will fail. we probably want a certificate + // that this machine has that is then used to sign the registration request, and + // the signature and certificate of the CAC get stored within the payload + const certificates = await auth.getCertificate(); + const objectId = unsafeParse(UuidSchema, v4()); + const object = new SignedObject( + objectId, + payload, + certificates, + generateSignatureResult.ok() + ); - // TODO: create registration request + (await store.addObject(object)).unsafeUnwrap(); - return ok({ id }); + return ok({ id: objectId }); }, async getElectionConfiguration(): Promise< @@ -136,9 +195,9 @@ function buildApi({ auth }: { auth: Auth; workspace: Workspace }) { castBallot(_input: { votes: VotesDict; pin: string; - }): Promise> { + }): Promise> { // TODO: cast and print the ballot - return Promise.resolve(ok(ClientId())); + throw new Error('Not implemented'); }, logOut() { diff --git a/apps/cacvote-mark/backend/src/cacvote-server/client.ts b/apps/cacvote-mark/backend/src/cacvote-server/client.ts index 3b1d64e363..30cc8fe1d7 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/client.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/client.ts @@ -134,7 +134,7 @@ export class Client { bail({ type: 'network', message: response.statusText }); } - const entries = safeParse( + return safeParse( z.array(JournalEntrySchema), await response.json() ).okOrElse((error) => @@ -144,18 +144,6 @@ export class Client { message: error.message, }) ); - - return entries.map( - (entry) => - new JournalEntry( - entry.id, - entry.objectId, - entry.jurisdiction, - entry.objectType, - entry.action, - entry.createdAt - ) - ); }); } 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 16e626b76f..2d06d0b4c4 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/sync.test.ts @@ -228,7 +228,7 @@ 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)))), + Payload.of('objectType', {}).toBuffer(), await getCertificates(), Buffer.of(7, 8, 9) ); @@ -266,7 +266,7 @@ 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)))), + Payload.of('objectType', {}).toBuffer(), await getCertificates(), Buffer.of(7, 8, 9) ); @@ -307,9 +307,7 @@ test.each(['RegistrationRequest', 'Registration', 'Election'])( const objectId = unsafeParse(UuidSchema, v4()); const object = new SignedObject( objectId, - Buffer.from( - JSON.stringify(new Payload('RegistrationRequest', Buffer.from('{}'))) - ), + Payload.of('RegistrationRequest', {}).toBuffer(), await getCertificates(), Buffer.of(7, 8, 9) ); @@ -455,9 +453,7 @@ 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))) - ), + Payload.of('Registration', {}).toBuffer(), await getCertificates(), Buffer.of(7, 8, 9) ); diff --git a/apps/cacvote-mark/backend/src/cacvote-server/types.ts b/apps/cacvote-mark/backend/src/cacvote-server/types.ts index 118d12847f..dc992f3ada 100644 --- a/apps/cacvote-mark/backend/src/cacvote-server/types.ts +++ b/apps/cacvote-mark/backend/src/cacvote-server/types.ts @@ -1,7 +1,15 @@ // 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 { Optional, Result, resultBlock } from '@votingworks/basics'; +import { + BallotStyleId, + BallotStyleIdSchema, + NewType, + PrecinctId, + PrecinctIdSchema, + safeParse, + safeParseJson, +} from '@votingworks/types'; import { Buffer } from 'buffer'; import { DateTime } from 'luxon'; import { validate } from 'uuid'; @@ -27,7 +35,53 @@ export const DateTimeSchema = z .string() .transform(DateTime.fromISO) as unknown as z.ZodSchema; -export const JournalEntrySchema: z.ZodSchema<{ +export class JournalEntry { + constructor( + private readonly id: Uuid, + private readonly objectId: Uuid, + private readonly jurisdiction: JurisdictionCode, + private readonly objectType: string, + private readonly action: JournalEntryAction, + private readonly createdAt: DateTime + ) {} + + getId(): Uuid { + return this.id; + } + + getObjectId(): Uuid { + return this.objectId; + } + + getJurisdiction(): JurisdictionCode { + return this.jurisdiction; + } + + getObjectType(): string { + return this.objectType; + } + + getAction(): JournalEntryAction { + return this.action; + } + + getCreatedAt(): DateTime { + return this.createdAt; + } + + toJSON(): unknown { + return { + id: this.id.toString(), + objectId: this.objectId.toString(), + jurisdiction: this.jurisdiction, + objectType: this.objectType, + action: this.action, + createdAt: this.createdAt.toISO(), + }; + } +} + +export const RawJournalEntrySchema: z.ZodSchema<{ id: Uuid; objectId: Uuid; jurisdiction: JurisdictionCode; @@ -43,6 +97,19 @@ export const JournalEntrySchema: z.ZodSchema<{ createdAt: DateTimeSchema, }); +export const JournalEntrySchema: z.ZodSchema = + RawJournalEntrySchema.transform( + (o) => + new JournalEntry( + o.id, + o.objectId, + o.jurisdiction, + o.objectType, + o.action, + o.createdAt + ) + ) as unknown as z.ZodSchema; + export class Payload { constructor( private readonly objectType: string, @@ -63,6 +130,14 @@ export class Payload { data: this.data.toString('base64'), }; } + + toBuffer(): Buffer { + return Buffer.from(JSON.stringify(this)); + } + + static of(objectType: string, serializable: unknown): Payload { + return new Payload(objectType, Buffer.from(JSON.stringify(serializable))); + } } export const PayloadSchema: z.ZodSchema = z @@ -94,6 +169,20 @@ export class SignedObject { return safeParseJson(this.payload.toString(), PayloadSchema); } + parsePayloadAs( + expectedObjectType: string, + schema: z.ZodSchema + ): Result, ZodError | SyntaxError> { + return resultBlock((bail) => { + const payload = this.getPayload().okOrElse(bail); + if (payload.getObjectType() !== expectedObjectType) { + return undefined; + } + const jsonData = payload.getData().toString('utf-8'); + return safeParseJson(jsonData, schema); + }); + } + getCertificates(): Buffer { return this.certificates; } @@ -120,34 +209,40 @@ export class SignedObject { } } -export class JournalEntry { +export const SignedObjectSchema: z.ZodSchema = z + .object({ + id: UuidSchema, + payload: z.instanceof(Buffer), + certificates: z.instanceof(Buffer), + signature: z.instanceof(Buffer), + }) + .transform( + (o) => new SignedObject(o.id, o.payload, o.certificates, o.signature) + ) as unknown as z.ZodSchema; + +export class RegistrationRequest { constructor( - private readonly id: Uuid, - private readonly objectId: Uuid, + private readonly commonAccessCardId: string, private readonly jurisdiction: JurisdictionCode, - private readonly objectType: string, - private readonly action: JournalEntryAction, + private readonly givenName: string, + private readonly familyName: string, private readonly createdAt: DateTime ) {} - getId(): Uuid { - return this.id; - } - - getObjectId(): Uuid { - return this.objectId; + getCommonAccessCardId(): string { + return this.commonAccessCardId; } getJurisdiction(): JurisdictionCode { return this.jurisdiction; } - getObjectType(): string { - return this.objectType; + getGivenName(): string { + return this.givenName; } - getAction(): JournalEntryAction { - return this.action; + getFamilyName(): string { + return this.familyName; } getCreatedAt(): DateTime { @@ -156,12 +251,86 @@ export class JournalEntry { toJSON(): unknown { return { - id: this.id.toString(), - objectId: this.objectId.toString(), + commonAccessCardId: this.commonAccessCardId, jurisdiction: this.jurisdiction, - objectType: this.objectType, - action: this.action, + givenName: this.givenName, + familyName: this.familyName, createdAt: this.createdAt.toISO(), }; } } + +export const RegistrationRequestSchema: z.ZodSchema = z + .object({ + commonAccessCardId: z.string(), + jurisdiction: JurisdictionCodeSchema, + givenName: z.string(), + familyName: z.string(), + createdAt: DateTimeSchema, + }) + .transform( + (o) => + new RegistrationRequest( + o.commonAccessCardId, + o.jurisdiction, + o.givenName, + o.familyName, + o.createdAt + ) + ) as unknown as z.ZodSchema; + +export class Registration { + constructor( + private readonly commonAccessCardId: string, + private readonly jurisdiction: JurisdictionCode, + private readonly registrationRequestObjectId: Uuid, + private readonly electionObjectId: Uuid, + private readonly ballotStyleId: BallotStyleId, + private readonly precinctId: PrecinctId + ) {} + + getCommonAccessCardId(): string { + return this.commonAccessCardId; + } + + getJurisdiction(): JurisdictionCode { + return this.jurisdiction; + } + + getRegistrationRequestObjectId(): Uuid { + return this.registrationRequestObjectId; + } + + getElectionObjectId(): Uuid { + return this.electionObjectId; + } + + getBallotStyleId(): BallotStyleId { + return this.ballotStyleId; + } + + getPrecinctId(): PrecinctId { + return this.precinctId; + } +} + +export const RegistrationSchema: z.ZodSchema = z + .object({ + commonAccessCardId: z.string(), + jurisdiction: JurisdictionCodeSchema, + registrationRequestObjectId: UuidSchema, + electionObjectId: UuidSchema, + ballotStyleId: BallotStyleIdSchema, + precinctId: PrecinctIdSchema, + }) + .transform( + (o) => + new Registration( + o.commonAccessCardId, + o.jurisdiction, + o.registrationRequestObjectId, + o.electionObjectId, + o.ballotStyleId, + o.precinctId + ) + ) as unknown as z.ZodSchema; diff --git a/apps/cacvote-mark/backend/src/index.ts b/apps/cacvote-mark/backend/src/index.ts index 3fa6dff0a3..83ef8be1a3 100644 --- a/apps/cacvote-mark/backend/src/index.ts +++ b/apps/cacvote-mark/backend/src/index.ts @@ -3,9 +3,9 @@ import { PORT, CACVOTE_MARK_WORKSPACE } from './globals'; import * as server from './server'; import { Workspace, createWorkspace } from './workspace'; -export type { Api, CreateTestVoterInput } from './app'; +export type { Api } from './app'; export type { AuthStatus } from './types/auth'; -export type { ServerId } from './types/db'; +export type { JurisdictionCode } from './cacvote-server/types'; const logger = new Logger(LogSource.VxMarkBackend); diff --git a/apps/cacvote-mark/backend/src/store.test.ts b/apps/cacvote-mark/backend/src/store.test.ts index e288851283..3c5aba4da6 100644 --- a/apps/cacvote-mark/backend/src/store.test.ts +++ b/apps/cacvote-mark/backend/src/store.test.ts @@ -1,14 +1,36 @@ +import { Buffer } from 'buffer'; import { electionTwoPartyPrimaryFixtures } from '@votingworks/fixtures'; import { DEFAULT_SYSTEM_SETTINGS, SystemSettings, safeParseSystemSettings, + unsafeParse, } from '@votingworks/types'; -import { Store } from './store'; +import { v4 } from 'uuid'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { DateTime } from 'luxon'; +import { + JurisdictionCodeSchema, + Payload, + RegistrationRequest, + SignedObject, + UuidSchema, +} from './cacvote-server/types'; +import { RegistrationRequestObjectType, Store } from './store'; // We pause in some of these tests so we need to increase the timeout jest.setTimeout(20000); +async function getCertificates(): Promise { + return await readFile( + join( + __dirname, + '../../../../libs/auth/certs/dev/vx-admin-cert-authority-cert.pem' + ) + ); +} + test('getDbPath', () => { const store = Store.memoryStore(); expect(store.getDbPath()).toEqual(':memory:'); @@ -60,3 +82,62 @@ test('reset clears the database', () => { const store = Store.memoryStore(); store.reset(); }); + +test('forEachObjectOfType', async () => { + const store = Store.memoryStore(); + const objectType = 'Test'; + + const object = new SignedObject( + unsafeParse(UuidSchema, v4()), + Payload.of(objectType, { test: 'data' }).toBuffer(), + await getCertificates(), + Buffer.from('signature') + ); + + expect(store.forEachObjectOfType(objectType).isEmpty()).toBeTruthy(); + + (await store.addObject(object)).unsafeUnwrap(); + + expect(store.forEachObjectOfType(objectType).count()).toEqual(1); + expect(store.forEachObjectOfType(objectType).first()).toEqual(object); + expect(store.forEachObjectOfType('NonExistent').count()).toEqual(0); +}); + +test('forEachRegistrationRequest', async () => { + const store = Store.memoryStore(); + const commonAccessCardId = '1234567890'; + + const registrationRequest = new RegistrationRequest( + commonAccessCardId, + unsafeParse(JurisdictionCodeSchema, 'st.test-jurisdiction'), + 'Given Name', + 'Family Name', + DateTime.now() + ); + const object = new SignedObject( + unsafeParse(UuidSchema, v4()), + Payload.of(RegistrationRequestObjectType, registrationRequest).toBuffer(), + await getCertificates(), + Buffer.from('signature') + ); + + expect( + store.forEachRegistrationRequest({ commonAccessCardId }).isEmpty() + ).toBeTruthy(); + + (await store.addObject(object)).unsafeUnwrap(); + + expect( + store.forEachRegistrationRequest({ commonAccessCardId }).count() + ).toEqual(1); + expect( + store.forEachRegistrationRequest({ commonAccessCardId }).first() + ).toEqual({ object, registrationRequest }); + expect( + store + .forEachRegistrationRequest({ + commonAccessCardId: `${commonAccessCardId}1`, + }) + .isEmpty() + ).toBeTruthy(); +}); diff --git a/apps/cacvote-mark/backend/src/store.ts b/apps/cacvote-mark/backend/src/store.ts index 3b0464725f..3a7b79a433 100644 --- a/apps/cacvote-mark/backend/src/store.ts +++ b/apps/cacvote-mark/backend/src/store.ts @@ -1,4 +1,11 @@ -import { Optional, Result, asyncResultBlock } from '@votingworks/basics'; +import { + IteratorPlus, + Optional, + Result, + assert, + asyncResultBlock, + iter, +} from '@votingworks/basics'; import { Client as DbClient } from '@votingworks/db'; import { SystemSettings, @@ -12,12 +19,21 @@ import { join } from 'path'; import { ZodError } from 'zod'; import { JournalEntry, + JurisdictionCode, JurisdictionCodeSchema, + Registration, + RegistrationRequest, + RegistrationRequestSchema, + RegistrationSchema, SignedObject, + SignedObjectSchema, Uuid, UuidSchema, } from './cacvote-server/types'; +export const RegistrationRequestObjectType = 'RegistrationRequest'; +export const RegistrationObjectType = 'Registration'; + const SchemaPath = join(__dirname, '../schema.sql'); /** @@ -216,7 +232,7 @@ export class Store { getJournalEntriesForObjectsToPull(): JournalEntry[] { const objectTypesToPull = [ - 'RegistrationRequest', + RegistrationRequestObjectType, 'Registration', 'Election', ]; @@ -286,4 +302,84 @@ export class Store { id ); } + + forEachRegistrationRequest({ + commonAccessCardId, + }: { + commonAccessCardId: string; + }): IteratorPlus<{ + object: SignedObject; + registrationRequest: RegistrationRequest; + }> { + return this.forEachObjectOfType(RegistrationRequestObjectType).filterMap( + (object) => { + const registrationRequest = object + .parsePayloadAs( + RegistrationRequestObjectType, + RegistrationRequestSchema + ) + .unsafeUnwrap(); + assert( + registrationRequest, + 'payload matches object type because we used forEachObjectType' + ); + if ( + registrationRequest.getCommonAccessCardId() === commonAccessCardId + ) { + return { object, registrationRequest }; + } + } + ); + } + + forEachRegistration({ + commonAccessCardId, + registrationRequestObjectId, + }: { + commonAccessCardId: string; + registrationRequestObjectId?: Uuid; + }): IteratorPlus<{ + object: SignedObject; + registration: Registration; + }> { + return this.forEachObjectOfType(RegistrationObjectType).filterMap( + (object) => { + const registration = object + .parsePayloadAs(RegistrationObjectType, RegistrationSchema) + .unsafeUnwrap(); + assert( + registration, + 'payload matches object type because we used forEachObjectType' + ); + if ( + registration.getCommonAccessCardId() === commonAccessCardId && + (!registrationRequestObjectId || + registrationRequestObjectId === + registration.getRegistrationRequestObjectId()) + ) { + return { object, registration }; + } + } + ); + } + + forEachObjectOfType(objectType: string): IteratorPlus { + return iter( + this.client.each( + `select id, payload, certificates, signature from objects + where json_extract(payload, '$.objectType') = ?`, + objectType + ) + ).map((row) => unsafeParse(SignedObjectSchema, row)); + } + + getJurisdictions(): JurisdictionCode[] { + const rows = this.client.all( + `select distinct jurisdiction from objects` + ) as Array<{ jurisdiction: string }>; + + return rows.map((row) => + unsafeParse(JurisdictionCodeSchema, row.jurisdiction) + ); + } } diff --git a/apps/cacvote-mark/backend/src/types/db.ts b/apps/cacvote-mark/backend/src/types/db.ts index 81bbdf94f5..86547fc9fa 100644 --- a/apps/cacvote-mark/backend/src/types/db.ts +++ b/apps/cacvote-mark/backend/src/types/db.ts @@ -78,65 +78,6 @@ export function deserializeJurisdiction(row: JurisdictionRow): Jurisdiction { }; } -export interface RegistrationRequest { - /** - * Database ID for a registration request record. - */ - id: ClientId; - - /** - * Server-side ID for this registration request record, if sync'ed. - */ - serverId?: ServerId; - - /** - * Client-side ID for this registration request record. - */ - clientId: ClientId; - - /** - * Machine ID for the machine that created this registration request record. - */ - machineId: Id; - - /** - * Jurisdiction ID for the jurisdiction that this registration request. - */ - jurisdictionId: ServerId; - - /** - * Common Access Card ID for the voter who created this registration request. - */ - commonAccessCardId: Id; - - /** - * Voter's given name, i.e. first name. - */ - givenName: string; - - /** - * Voter's family name, i.e. last name. - */ - familyName: string; - - /** - * Date and time when the voter registered for the election. - */ - createdAt: DateTime; -} - -export interface RegistrationRequestRow { - id: string; - serverId: string | null; - clientId: string; - machineId: string; - jurisdictionId: string; - commonAccessCardId: string; - givenName: string; - familyName: string; - createdAt: string; -} - export interface Election { /** * Database ID for an election record. @@ -344,101 +285,3 @@ export function deserializePrintedBallot(row: PrintedBallotRow): PrintedBallot { createdAt: DateTime.fromSQL(row.createdAt), }; } - -export interface ServerSyncAttempt { - /** - * Database ID for a server sync attempt record. - */ - id: ClientId; - - /** - * Creator for the user who initiated the server sync attempt. - */ - creator: string; - - /** - * Trigger type for the server sync attempt. - */ - trigger: string; - - /** - * Status message for the server sync attempt. - */ - statusMessage: string; - - /** - * Date and time when the server sync attempt was made. - */ - createdAt: DateTime; - - /** - * Whether or not the server sync attempt was successful. - */ - success?: boolean; - - /** - * Date and time when the server sync attempt was completed. - */ - completedAt?: DateTime; -} - -export interface ServerSyncAttemptRow { - id: string; - creator: string; - trigger: string; - statusMessage: string; - success: 0 | 1 | null; - createdAt: string; - completedAt: string | null; -} - -export function deserializeRegistrationRequest( - row: RegistrationRequestRow -): RegistrationRequest { - return { - id: row.id as ClientId, - serverId: (row.serverId ?? undefined) as Optional, - clientId: row.clientId as ClientId, - machineId: row.machineId, - jurisdictionId: row.jurisdictionId as ServerId, - // because these are just strings of digits, sqlite may return them as - // numbers, so we have to convert them back to strings - commonAccessCardId: row.commonAccessCardId.toString(), - givenName: row.givenName, - familyName: row.familyName, - createdAt: DateTime.fromSQL(row.createdAt), - }; -} - -export function deserializeRegistration(row: RegistrationRow): Registration { - return { - id: row.id as ClientId, - serverId: (row.serverId ?? undefined) as Optional, - clientId: row.clientId as ClientId, - machineId: row.machineId, - // because these are just strings of digits, sqlite may return them as - // numbers, so we have to convert them back to strings - commonAccessCardId: row.commonAccessCardId.toString(), - registrationRequestId: row.registrationRequestId as ClientId, - electionId: row.electionId as ClientId, - precinctId: row.precinctId, - ballotStyleId: row.ballotStyleId, - createdAt: DateTime.fromSQL(row.createdAt), - }; -} - -export function deserializeServerSyncAttempt( - row: ServerSyncAttemptRow -): ServerSyncAttempt { - return { - id: row.id as ClientId, - creator: row.creator, - trigger: row.trigger, - statusMessage: row.statusMessage, - createdAt: DateTime.fromSQL(row.createdAt), - success: row.success === 1 ? true : row.success === 0 ? false : undefined, - completedAt: row.completedAt - ? DateTime.fromSQL(row.completedAt) - : undefined, - }; -} diff --git a/apps/cacvote-mark/frontend/src/api.ts b/apps/cacvote-mark/frontend/src/api.ts index 35a8814ff4..f19ce8acb1 100644 --- a/apps/cacvote-mark/frontend/src/api.ts +++ b/apps/cacvote-mark/frontend/src/api.ts @@ -39,14 +39,10 @@ export const getJurisdictions = { return ['getJurisdictions']; }, useQuery() { - // const apiClient = useApiClient(); - return useQuery( - this.queryKey(), - () => Array.of<{ id: string; name: string }>(), - { - staleTime: Infinity, - } - ); + const apiClient = useApiClient(); + return useQuery(this.queryKey(), () => apiClient.getJurisdictions(), { + staleTime: Infinity, + }); }, } as const; diff --git a/apps/cacvote-mark/frontend/src/screens/registration/start_screen.tsx b/apps/cacvote-mark/frontend/src/screens/registration/start_screen.tsx index 48531cbddc..ca5eb4e8a8 100644 --- a/apps/cacvote-mark/frontend/src/screens/registration/start_screen.tsx +++ b/apps/cacvote-mark/frontend/src/screens/registration/start_screen.tsx @@ -1,7 +1,7 @@ import { extractErrorMessage } from '@votingworks/basics'; import { Button, H1, Main, P, Screen, Select } from '@votingworks/ui'; import { useState } from 'react'; -import { ServerId } from '@votingworks/cacvote-mark-backend'; +import { JurisdictionCode } from '@votingworks/cacvote-mark-backend'; import { createVoterRegistration, getAuthStatus, @@ -18,7 +18,7 @@ export function StartScreen(): JSX.Element { authStatusQuery.data?.status === 'has_card' ? authStatusQuery.data.card : undefined; - const [jurisdictionId, setJurisdictionId] = useState(); + const [jurisdictionCode, setJurisdictionCode] = useState(); const [givenName, setGivenName] = useState(cardDetails?.givenName ?? ''); const [familyName, setFamilyName] = useState(cardDetails?.familyName ?? ''); const [isShowingPinModal, setIsShowingPinModal] = useState(false); @@ -26,16 +26,16 @@ export function StartScreen(): JSX.Element { const error = createVoterRegistrationMutation.data?.err(); function onChangeJurisdictionId(event: React.ChangeEvent) { - setJurisdictionId(event.target.value as ServerId); + setJurisdictionCode(event.target.value as JurisdictionCode); } function onSubmitRegistrationForm() { setIsShowingPinModal(true); } - function onEnterPin(pin: string, jxId: ServerId) { + function onEnterPin(pin: string, jxId: JurisdictionCode) { createVoterRegistrationMutation.mutate({ - jurisdictionId: jxId, + jurisdictionCode: jxId, givenName, familyName, pin, @@ -64,21 +64,24 @@ export function StartScreen(): JSX.Element { setFamilyName(newValue); }} /> - + {getJurisdictionsQuery.data?.map((jx) => ( - ))} - - {isShowingPinModal && jurisdictionId && ( + {isShowingPinModal && jurisdictionCode && ( onEnterPin(pin, jurisdictionId)} + onEnter={(pin) => onEnterPin(pin, jurisdictionCode)} onDismiss={() => setIsShowingPinModal(false)} disabled={createVoterRegistrationMutation.isLoading} error={error ? extractErrorMessage(error) : undefined}