From 142a1080c09131211646a5cb587d14cfa9d33c2f Mon Sep 17 00:00:00 2001 From: Kevin Shen Date: Fri, 17 Mar 2023 11:19:49 -0700 Subject: [PATCH] Kevin/vxadmin system settings zip import (#3147) * [vxadmin] load and export system settings * PR feedback * return null instead of undefined when system settings not found * update setup package zip with new 'arePollWorkerCardPinsEnabled' naming --- apps/admin/backend/schema.sql | 8 +- apps/admin/backend/src/app.test.ts | 121 ++++++++++ apps/admin/backend/src/app.ts | 70 +++++- apps/admin/backend/src/store.test.ts | 37 ++- apps/admin/backend/src/store.ts | 35 +++ apps/admin/backend/src/types.ts | 11 + apps/admin/frontend/package.json | 4 +- apps/admin/frontend/src/api.ts | 22 ++ apps/admin/frontend/src/app.test.tsx | 13 ++ .../frontend/src/app_write_in_flows.test.tsx | 1 + ...ction_ballot_package_modal_button.test.tsx | 75 +++--- ...t_election_ballot_package_modal_button.tsx | 22 +- .../print_all_ballots_button.test.tsx | 3 + .../admin/frontend/src/lib/read_file_async.ts | 11 - .../screens/definition_contests_screen.tsx | 4 +- .../src/screens/unconfigured_screen.test.tsx | 57 +++++ .../src/screens/unconfigured_screen.tsx | 84 +++++-- .../src/utils/cast_vote_record_files.ts | 4 +- .../src/utils/initial_setup_package.test.ts | 20 ++ .../src/utils/initial_setup_package.ts | 43 ++++ apps/admin/frontend/test/helpers/api_mock.ts | 12 + .../data/AdminInitialSetupPackage.zip | Bin 0 -> 1424 bytes .../election.json | 213 ++++++++++++++++++ .../systemSettings.json | 3 + .../src/data/AdminInitialSetupPackage.zip.ts | 50 ++++ .../electionMinimalExhaustiveSample/index.ts | 1 + .../election.json.ts | 76 +++++++ .../systemSettings.json.ts | 59 +++++ libs/fixtures/src/index.test.ts | 1 + libs/fixtures/src/index.ts | 1 + .../VotingWorksLoggingDocumentation.md | 16 ++ libs/logging/src/log_documentation.test.ts | 2 +- libs/logging/src/log_event_ids.ts | 41 ++++ libs/types/src/index.ts | 1 + libs/types/src/system_settings.ts | 24 ++ libs/utils/src/ballot_package.ts | 75 ++---- libs/utils/src/file_reading.ts | 80 +++++++ libs/utils/src/index.ts | 1 + 38 files changed, 1179 insertions(+), 122 deletions(-) delete mode 100644 apps/admin/frontend/src/lib/read_file_async.ts create mode 100644 apps/admin/frontend/src/screens/unconfigured_screen.test.tsx create mode 100644 apps/admin/frontend/src/utils/initial_setup_package.test.ts create mode 100644 apps/admin/frontend/src/utils/initial_setup_package.ts create mode 100644 libs/fixtures/data/AdminInitialSetupPackage.zip create mode 100644 libs/fixtures/data/sampleAdminInitialSetupPackage/election.json create mode 100644 libs/fixtures/data/sampleAdminInitialSetupPackage/systemSettings.json create mode 100644 libs/fixtures/src/data/AdminInitialSetupPackage.zip.ts create mode 100644 libs/fixtures/src/data/sampleAdminInitialSetupPackage/election.json.ts create mode 100644 libs/fixtures/src/data/sampleAdminInitialSetupPackage/systemSettings.json.ts create mode 100644 libs/types/src/system_settings.ts create mode 100644 libs/utils/src/file_reading.ts diff --git a/apps/admin/backend/schema.sql b/apps/admin/backend/schema.sql index ee3d8804b0..ed7f0b42bc 100644 --- a/apps/admin/backend/schema.sql +++ b/apps/admin/backend/schema.sql @@ -80,6 +80,12 @@ create table printed_ballots ( on delete cascade ); +create table system_settings ( + -- enforce singleton table + id integer primary key check (id = 1), + are_poll_worker_card_pins_enabled boolean not null default false +); + create table settings ( -- enforce singleton table id integer primary key check (id = 1), @@ -87,4 +93,4 @@ create table settings ( foreign key (current_election_id) references elections(id) ); -insert into settings default values; \ No newline at end of file +insert into settings default values; diff --git a/apps/admin/backend/src/app.test.ts b/apps/admin/backend/src/app.test.ts index e6e7282e93..ea4c4008e3 100644 --- a/apps/admin/backend/src/app.test.ts +++ b/apps/admin/backend/src/app.test.ts @@ -521,3 +521,124 @@ test('auth before election definition has been configured', async () => { electionHash: undefined, }); }); + +test('setSystemSettings happy path', async () => { + const { apiClient, auth, logger } = buildTestEnvironment(); + + const { electionDefinition, systemSettings } = + electionMinimalExhaustiveSampleFixtures; + await configureMachine(apiClient, auth, electionDefinition); + + mockSystemAdministratorAuth(auth); + + const result = await apiClient.setSystemSettings({ + systemSettings: systemSettings.asText(), + }); + assert(result.isOk()); + + // Logger call 1 is made by configureMachine when loading the election definition + expect(logger.log).toHaveBeenNthCalledWith( + 2, + LogEventId.SystemSettingsSaveInitiated, + 'system_administrator', + { disposition: 'na' } + ); + expect(logger.log).toHaveBeenNthCalledWith( + 3, + LogEventId.SystemSettingsSaved, + 'system_administrator', + { disposition: 'success' } + ); +}); + +test('setSystemSettings throws error when store.saveSystemSettings fails', async () => { + const { apiClient, auth, workspace, logger } = buildTestEnvironment(); + const { electionDefinition, systemSettings } = + electionMinimalExhaustiveSampleFixtures; + await configureMachine(apiClient, auth, electionDefinition); + const errorString = 'db error at saveSystemSettings'; + workspace.store.saveSystemSettings = jest.fn(() => { + throw new Error(errorString); + }); + + mockSystemAdministratorAuth(auth); + // https://jestjs.io/docs/expect#rejects + await expect( + apiClient.setSystemSettings({ systemSettings: systemSettings.asText() }) + ).rejects.toThrow(errorString); + + // Logger call 1 is made by configureMachine when loading the election definition + expect(logger.log).toHaveBeenNthCalledWith( + 2, + LogEventId.SystemSettingsSaveInitiated, + 'system_administrator', + { disposition: 'na' } + ); + expect(logger.log).toHaveBeenNthCalledWith( + 3, + LogEventId.SystemSettingsSaved, + 'system_administrator', + { disposition: 'failure', error: errorString } + ); +}); + +test('setSystemSettings returns an error for malformed input', async () => { + const { apiClient, auth } = buildTestEnvironment(); + + const { electionDefinition } = electionMinimalExhaustiveSampleFixtures; + await configureMachine(apiClient, auth, electionDefinition); + + mockSystemAdministratorAuth(auth); + + const malformedInput = { + invalidField: 'hello', + } as const; + + const result = await apiClient.setSystemSettings({ + systemSettings: JSON.stringify(malformedInput), + }); + assert(result.isErr()); + const err = result.err(); + expect(err.type).toEqual('parsing'); + expect(JSON.parse(err.message)).toMatchObject([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'undefined', + path: ['arePollWorkerCardPinsEnabled'], + message: 'Required', + }, + ]); +}); + +test('getSystemSettings happy path', async () => { + const { apiClient, auth } = buildTestEnvironment(); + + const { electionDefinition, systemSettings } = + electionMinimalExhaustiveSampleFixtures; + await configureMachine(apiClient, auth, electionDefinition); + + mockSystemAdministratorAuth(auth); + + // configure with well-formed system settings + const setResult = await apiClient.setSystemSettings({ + systemSettings: systemSettings.asText(), + }); + assert(setResult.isOk()); + + const systemSettingsResult = await apiClient.getSystemSettings(); + assert(systemSettingsResult); + expect(systemSettingsResult.arePollWorkerCardPinsEnabled).toEqual(true); +}); + +test('getSystemSettings returns null when no `system settings` are found', async () => { + const { apiClient, auth } = buildTestEnvironment(); + + const { electionDefinition } = electionMinimalExhaustiveSampleFixtures; + await configureMachine(apiClient, auth, electionDefinition); + + mockSystemAdministratorAuth(auth); + + const systemSettingsResult = await apiClient.getSystemSettings(); + expect(systemSettingsResult).toBeNull(); +}); diff --git a/apps/admin/backend/src/app.ts b/apps/admin/backend/src/app.ts index d3693e635f..e5c2163693 100644 --- a/apps/admin/backend/src/app.ts +++ b/apps/admin/backend/src/app.ts @@ -15,6 +15,8 @@ import { safeParseElectionDefinition, safeParseJson, safeParseNumber, + SystemSettings, + SystemSettingsSchema, } from '@votingworks/types'; import { assert, assertDefined, err, ok, iter } from '@votingworks/basics'; import express, { Application } from 'express'; @@ -26,7 +28,11 @@ import * as grout from '@votingworks/grout'; import { promises as fs, Stats } from 'fs'; import { basename } from 'path'; import { parseCvrFileInfoFromFilename } from '@votingworks/utils'; -import { AddCastVoteRecordFileResult, ConfigureResult } from './types'; +import { + AddCastVoteRecordFileResult, + ConfigureResult, + SetSystemSettingsResult, +} from './types'; import { Workspace } from './util/workspace'; import { listCastVoteRecordFilesOnUsb } from './cvr_files'; import { Usb } from './util/usb'; @@ -123,6 +129,68 @@ function buildApi({ ); }, + async setSystemSettings(input: { + systemSettings: string; + }): Promise { + await logger.log( + LogEventId.SystemSettingsSaveInitiated, + assertDefined(await getUserRole()), + { disposition: 'na' } + ); + + const { systemSettings } = input; + const validatedSystemSettings = safeParseJson( + systemSettings, + SystemSettingsSchema + ); + if (validatedSystemSettings.isErr()) { + return err({ + type: 'parsing', + message: validatedSystemSettings.err()?.message, + }); + } + + try { + store.saveSystemSettings(validatedSystemSettings.ok()); + } catch (error) { + const typedError = error as Error; + await logger.log( + LogEventId.SystemSettingsSaved, + assertDefined(await getUserRole()), + { disposition: 'failure', error: typedError.message } + ); + throw error; + } + + await logger.log( + LogEventId.SystemSettingsSaved, + assertDefined(await getUserRole()), + { disposition: 'success' } + ); + + return ok({}); + }, + + async getSystemSettings(): Promise { + try { + const settings = store.getSystemSettings(); + await logger.log( + LogEventId.SystemSettingsRetrieved, + assertDefined(await getUserRole()), + { disposition: 'success' } + ); + return settings || null; + } catch (error) { + await logger.log( + LogEventId.SystemSettingsRetrieved, + assertDefined(await getUserRole()), + { disposition: 'failure' } + ); + throw error; + } + }, + + // `configure` and `unconfigure` handle changes to the election definition async configure(input: { electionData: string }): Promise { const parseResult = safeParseElectionDefinition(input.electionData); if (parseResult.isErr()) { diff --git a/apps/admin/backend/src/store.test.ts b/apps/admin/backend/src/store.test.ts index 08026edf53..8e5a902e64 100644 --- a/apps/admin/backend/src/store.test.ts +++ b/apps/admin/backend/src/store.test.ts @@ -9,7 +9,12 @@ import { arbitraryBallotStyleId, arbitraryPrecinctId, } from '@votingworks/test-utils'; -import { BallotId, CastVoteRecord } from '@votingworks/types'; +import { + BallotId, + CastVoteRecord, + SystemSettingsSchema, + safeParseJson, +} from '@votingworks/types'; import { typedAs } from '@votingworks/basics'; import fc from 'fast-check'; import { promises as fs } from 'fs'; @@ -1118,6 +1123,7 @@ test('write-in adjudication lifecycle', async () => { "elections" => 1, "printed_ballots" => 0, "settings" => 1, + "system_settings" => 0, "write_in_adjudications" => 1, "write_ins" => 1, } @@ -1133,6 +1139,7 @@ test('write-in adjudication lifecycle', async () => { "elections" => 1, "printed_ballots" => 0, "settings" => 1, + "system_settings" => 0, "write_in_adjudications" => 0, "write_ins" => 0, } @@ -1247,3 +1254,31 @@ test('current election id', () => { store.setCurrentElectionId(undefined); expect(store.getCurrentElectionId()).toBeUndefined(); }); + +/** + * System settings tests + */ +function makeSystemSettings() { + const systemSettingsString = + electionMinimalExhaustiveSampleFixtures.systemSettings.asText(); + return safeParseJson( + systemSettingsString, + SystemSettingsSchema + ).unsafeUnwrap(); +} + +test('saveSystemSettings and getSystemSettings write and read system settings', () => { + const store = Store.memoryStore(); + const systemSettings = makeSystemSettings(); + store.saveSystemSettings(systemSettings); + const retrievedSystemSettings = store.getSystemSettings(); + expect(retrievedSystemSettings?.arePollWorkerCardPinsEnabled).toEqual( + systemSettings.arePollWorkerCardPinsEnabled + ); +}); + +test('getSystemSettings returns undefined when no system settings exist', () => { + const store = Store.memoryStore(); + const retrievedSystemSettings = store.getSystemSettings(); + expect(retrievedSystemSettings).toBeUndefined(); +}); diff --git a/apps/admin/backend/src/store.ts b/apps/admin/backend/src/store.ts index 09d45b2fdc..acc93ae77f 100644 --- a/apps/admin/backend/src/store.ts +++ b/apps/admin/backend/src/store.ts @@ -18,6 +18,8 @@ import { safeParse, safeParseElectionDefinition, safeParseJson, + SystemSettings, + SystemSettingsDbRow, } from '@votingworks/types'; import { ParseCastVoteRecordResult, parseCvrs } from '@votingworks/utils'; import * as fs from 'fs'; @@ -235,6 +237,39 @@ export class Store { return settings?.currentElectionId ?? undefined; } + /** + * Creates a system settings record and returns its ID. + * Note `system_settings` are logical settings that span other machines eg. VxScan. + * `settings` are local to VxAdmin + */ + saveSystemSettings(systemSettings: SystemSettings): void { + this.client.run( + 'insert into system_settings (are_poll_worker_card_pins_enabled) values (?)', + systemSettings.arePollWorkerCardPinsEnabled ? 1 : 0 // No booleans in sqlite3 + ); + } + + /** + * Gets a specific system settings record. + */ + getSystemSettings(): SystemSettings | undefined { + const result = this.client.one( + ` + select + are_poll_worker_card_pins_enabled as arePollWorkerCardPinsEnabled + from system_settings + ` + ) as SystemSettingsDbRow | undefined; + + if (!result) { + return undefined; + } + + return { + arePollWorkerCardPinsEnabled: result.arePollWorkerCardPinsEnabled === 1, + }; + } + private convertCvrParseErrorsToApiError( cvrParseResult: ParseCastVoteRecordResult ): AddCastVoteRecordError { diff --git a/apps/admin/backend/src/types.ts b/apps/admin/backend/src/types.ts index 66f4b3d6cb..9cede6ba2b 100644 --- a/apps/admin/backend/src/types.ts +++ b/apps/admin/backend/src/types.ts @@ -20,6 +20,17 @@ export type ConfigureResult = Result< { type: 'parsing'; message: string } >; +/** + * Result of attempt to store and apply system settings + */ +export type SetSystemSettingsResult = Result< + Record, + { + type: 'parsing' | 'database'; + message: string; + } +>; + /** * Errors that may occur when loading a cast vote record file from a path */ diff --git a/apps/admin/frontend/package.json b/apps/admin/frontend/package.json index 85a2de91bb..7ef18e5acb 100644 --- a/apps/admin/frontend/package.json +++ b/apps/admin/frontend/package.json @@ -19,8 +19,8 @@ "stylelint:run": "stylelint 'src/**/*.{js,jsx,ts,tsx}' && stylelint 'src/**/*.css' --config .stylelintrc-css.js", "stylelint:run:fix": "stylelint 'src/**/*.{js,jsx,ts,tsx}' --fix && stylelint 'src/**/*.css' --config .stylelintrc-css.js --fix", "test": "is-ci test:ci test:watch", - "test:ci": "TZ=UTC node scripts/test.js --coverage --watchAll=false --reporters=default --reporters=jest-junit --maxWorkers=7", - "test:coverage": "TZ=UTC node scripts/test.js --coverage --watchAll=false", + "test:ci": "TZ=UTC node scripts/test.js --coverage --watchAll=false --reporters=default --reporters=jest-junit --maxWorkers=7 --env=jest-environment-jsdom-sixteen", + "test:coverage": "TZ=UTC node scripts/test.js --coverage --watchAll=false --env=jest-environment-jsdom-sixteen", "test:watch": "TZ=UTC node scripts/test.js --env=jest-environment-jsdom-sixteen", "type-check": "tsc --build" }, diff --git a/apps/admin/frontend/src/api.ts b/apps/admin/frontend/src/api.ts index 945c36db65..2d909a1056 100644 --- a/apps/admin/frontend/src/api.ts +++ b/apps/admin/frontend/src/api.ts @@ -246,6 +246,16 @@ export const getPrintedBallots = { }, } as const; +export const getSystemSettings = { + queryKey(): QueryKey { + return ['getSystemSettings']; + }, + useQuery() { + const apiClient = useApiClient(); + return useQuery(this.queryKey(), () => apiClient.getSystemSettings()); + }, +} as const; + // Grouped Invalidations function invalidateCastVoteRecordQueries(queryClient: QueryClient) { @@ -404,3 +414,15 @@ export const addPrintedBallots = { }); }, } as const; + +export const setSystemSettings = { + useMutation() { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + return useMutation(apiClient.setSystemSettings, { + async onSuccess() { + await queryClient.invalidateQueries(getSystemSettings.queryKey()); + }, + }); + }, +} as const; diff --git a/apps/admin/frontend/src/app.test.tsx b/apps/admin/frontend/src/app.test.tsx index fd7021bb77..7b5f107e6f 100644 --- a/apps/admin/frontend/src/app.test.tsx +++ b/apps/admin/frontend/src/app.test.tsx @@ -176,6 +176,7 @@ test('authentication works', async () => { const { renderApp, hardware } = buildApp(apiMock); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); renderApp(); await screen.findByText('VxAdmin is Locked'); @@ -268,6 +269,7 @@ test('L&A (logic and accuracy) flow', async () => { electionDefinition, }); apiMock.expectGetCastVoteRecordFileMode(Admin.CvrFileMode.Unlocked); + apiMock.expectGetSystemSettings(); renderApp(); await apiMock.authenticateAsElectionManager(electionDefinition); @@ -376,6 +378,7 @@ test('printing ballots', async () => { const { renderApp, hardware, logger } = buildApp(apiMock); hardware.setPrinterConnected(false); apiMock.expectGetCastVoteRecords([]); + apiMock.expectGetSystemSettings(); apiMock.expectGetCurrentElectionMetadata({ electionDefinition, }); @@ -443,6 +446,7 @@ test('marking results as official', async () => { apiMock.expectGetCurrentElectionMetadata({ electionDefinition, }); + apiMock.expectGetSystemSettings(); apiMock.expectGetOfficialPrintedBallots([]); apiMock.expectGetCastVoteRecordFileMode(Admin.CvrFileMode.Official); apiMock.expectGetWriteInSummaryAdjudicated([]); @@ -473,6 +477,7 @@ test('marking results as official', async () => { test('tabulating CVRs', async () => { const electionDefinition = eitherNeitherElectionDefinition; const { renderApp, logger } = buildApp(apiMock, 'ms-sems'); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords( await fileDataToCastVoteRecords(EITHER_NEITHER_CVR_DATA, electionDefinition) ); @@ -612,6 +617,7 @@ test('tabulating CVRs with manual data', async () => { apiMock.expectGetCastVoteRecords( await fileDataToCastVoteRecords(EITHER_NEITHER_CVR_DATA, electionDefinition) ); + apiMock.expectGetSystemSettings(); apiMock.expectGetCurrentElectionMetadata({ electionDefinition, isOfficialResults: false, @@ -787,6 +793,7 @@ test('reports screen shows appropriate summary data about ballot counts and prin await fileDataToCastVoteRecords(cvrData, electionDefinition) ); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetOfficialPrintedBallots([ mockPrintedBallotRecord, mockPrintedBallotRecord, @@ -830,6 +837,7 @@ test('removing election resets cvr and manual data files', async () => { const { renderApp, backend } = buildApp(apiMock); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); const manualTally = convertTalliesByPrecinctToFullExternalTally( { 'precinct-1': { contestTallies: {}, numberOfBallotsCounted: 100 } }, @@ -884,6 +892,7 @@ test('clearing results', async () => { { ...mockCastVoteRecordFileRecord, numCvrsImported: 3000 }, ]); apiMock.expectGetCastVoteRecordFileMode(Admin.CvrFileMode.Test); + apiMock.expectGetSystemSettings(); const manualTally = convertTalliesByPrecinctToFullExternalTally( { 'precinct-1': { contestTallies: {}, numberOfBallotsCounted: 100 } }, @@ -952,6 +961,7 @@ test('Can not view or print ballots when using an election with gridlayouts (lik const { renderApp } = buildApp(apiMock); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); renderApp(); await apiMock.authenticateAsSystemAdministrator(); @@ -974,6 +984,7 @@ test('election manager UI has expected nav', async () => { const { renderApp } = buildApp(apiMock); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecordFileMode(Admin.CvrFileMode.Unlocked); apiMock.expectGetCastVoteRecordFiles([]); apiMock.expectGetOfficialPrintedBallots([]); @@ -1142,6 +1153,7 @@ test('system administrator Ballots tab and election manager Ballots tab have exp const { renderApp } = buildApp(apiMock); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); renderApp(); await apiMock.authenticateAsSystemAdministrator(); @@ -1228,6 +1240,7 @@ test('primary election with nonpartisan contests', async () => { const { renderApp } = buildApp(apiMock); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords( await fileDataToCastVoteRecords(cvrData, electionDefinition) ); diff --git a/apps/admin/frontend/src/app_write_in_flows.test.tsx b/apps/admin/frontend/src/app_write_in_flows.test.tsx index c511b7cd21..a34cf5ebe7 100644 --- a/apps/admin/frontend/src/app_write_in_flows.test.tsx +++ b/apps/admin/frontend/src/app_write_in_flows.test.tsx @@ -61,6 +61,7 @@ test('manual write-in data end-to-end test', async () => { electionMinimalExhaustiveSampleFixtures; const { renderApp } = buildApp(apiMock); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords( await fileDataToCastVoteRecords( partial1CvrFile.asText(), diff --git a/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.test.tsx b/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.test.tsx index 723f791d4d..6c4b03e1cb 100644 --- a/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.test.tsx +++ b/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.test.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { BallotPageLayoutWithImage, BallotType } from '@votingworks/types'; import { UsbDriveStatus } from '@votingworks/ui'; import { iter } from '@votingworks/basics'; +import userEvent from '@testing-library/user-event'; import { fireEvent, screen, @@ -19,12 +20,18 @@ import { import { ExportElectionBallotPackageModalButton } from './export_election_ballot_package_modal_button'; import { pdfToImages } from '../utils/pdf_to_images'; import { mockUsbDrive } from '../../test/helpers/mock_usb_drive'; +import { ApiMock, createApiMock } from '../../test/helpers/api_mock'; jest.mock('@votingworks/ballot-interpreter-vx'); jest.mock('../components/hand_marked_paper_ballot'); jest.mock('../utils/pdf_to_images'); +let apiMock: ApiMock; + beforeEach(() => { + apiMock = createApiMock(); + apiMock.expectGetSystemSettings(); + const mockKiosk = fakeKiosk(); mockKiosk.getUsbDriveInfo.mockResolvedValue([fakeUsbDrive()]); const fileWriter = fakeFileWriter(); @@ -84,31 +91,36 @@ beforeEach(() => { }); afterEach(() => { + apiMock.assertComplete(); delete window.kiosk; }); test('Button renders properly when not clicked', () => { const { queryByTestId } = renderInAppContext( - + , + { + apiMock, + } ); screen.getButton('Save Ballot Package'); expect(queryByTestId('modal')).toBeNull(); }); -test('Modal renders insert usb screen appropriately', async () => { - const usbStatuses: UsbDriveStatus[] = ['absent', 'ejected', 'bad_format']; - - for (const usbStatus of usbStatuses) { - const { - unmount, - getByText, - queryAllByText, - queryAllByAltText, - queryAllByTestId, - } = renderInAppContext(, { - usbDrive: mockUsbDrive(usbStatus), - }); +test.each<{ + usbStatus: UsbDriveStatus; +}>([ + { usbStatus: 'absent' }, + { usbStatus: 'ejected' }, + { usbStatus: 'bad_format' }, +])( + 'Modal renders insert usb screen appropriately for status $usbStatus', + async ({ usbStatus }) => { + const { getByText, queryAllByText, queryAllByAltText, queryAllByTestId } = + renderInAppContext(, { + usbDrive: mockUsbDrive(usbStatus), + apiMock, + }); fireEvent.click(getByText('Save Ballot Package')); await waitFor(() => getByText('No USB Drive Detected')); expect(queryAllByAltText('Insert USB Image')).toHaveLength(1); @@ -121,16 +133,15 @@ test('Modal renders insert usb screen appropriately', async () => { fireEvent.click(getByText('Cancel')); expect(queryAllByTestId('modal')).toHaveLength(0); - - unmount(); } -}); +); test('Modal renders export confirmation screen when usb detected and manual link works as expected', async () => { const logger = fakeLogger(); renderInAppContext(, { usbDrive: mockUsbDrive('mounted'), logger, + apiMock, }); fireEvent.click(screen.getByText('Save Ballot Package')); const modal = await screen.findByRole('alertdialog'); @@ -168,14 +179,16 @@ test('Modal renders export confirmation screen when usb detected and manual link expect(screen.queryAllByTestId('modal')).toHaveLength(0); }); -test('Modal renders loading screen when usb drive is mounting or ejecting', async () => { - const usbStatuses: UsbDriveStatus[] = ['mounting', 'ejecting']; - - for (const usbStatus of usbStatuses) { - const { unmount, queryAllByTestId, getByText } = renderInAppContext( +test.each<{ + usbStatus: UsbDriveStatus; +}>([{ usbStatus: 'mounting' }, { usbStatus: 'ejecting' }])( + 'Modal renders loading screen when usb drive is $usbStatus', + async ({ usbStatus }) => { + const { queryAllByTestId, getByText } = renderInAppContext( , { usbDrive: mockUsbDrive(usbStatus), + apiMock, } ); fireEvent.click(screen.getButton('Save Ballot Package')); @@ -184,9 +197,8 @@ test('Modal renders loading screen when usb drive is mounting or ejecting', asyn expect(queryAllByTestId('modal')).toHaveLength(1); expect(screen.getButton('Cancel')).toBeDisabled(); - unmount(); } -}); +); test('Modal renders error message appropriately', async () => { const logger = fakeLogger(); @@ -194,6 +206,7 @@ test('Modal renders error message appropriately', async () => { const { queryAllByTestId, getByText, queryAllByText } = renderInAppContext( , { + apiMock, usbDrive: mockUsbDrive('mounted'), logger, } @@ -223,18 +236,16 @@ test('Modal renders error message appropriately', async () => { test('Modal renders renders loading message while rendering ballots appropriately', async () => { const usbDrive = mockUsbDrive('mounted'); - const { queryAllByTestId, getByText, queryByText } = renderInAppContext( - , - { + const { queryAllByTestId, getByText, queryByText, getByRole } = + renderInAppContext(, { + apiMock, usbDrive, - } - ); + }); fireEvent.click(getByText('Save Ballot Package')); await waitFor(() => getByText('Save')); + userEvent.click(getByRole('button', { name: /Save/ })); - fireEvent.click(getByText('Save')); - - await screen.findByText('Ballot Package Saved'); + await waitFor(() => screen.findByText('Ballot Package Saved')); expect(window.kiosk!.writeFile).toHaveBeenCalledTimes(1); expect(window.kiosk!.makeDirectory).toHaveBeenCalledTimes(1); diff --git a/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.tsx b/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.tsx index 20eaabf7ea..8e2aa4cca9 100644 --- a/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.tsx +++ b/apps/admin/frontend/src/components/export_election_ballot_package_modal_button.tsx @@ -1,5 +1,6 @@ import { Buffer } from 'buffer'; import React, { useContext, useEffect, useState } from 'react'; +import { UseQueryResult } from '@tanstack/react-query'; import pluralize from 'pluralize'; import styled from 'styled-components'; import { join } from 'path'; @@ -10,6 +11,7 @@ import { getElectionLocales, getPrecinctById, HmpbBallotPageMetadata, + SystemSettings, } from '@votingworks/types'; import { Admin } from '@votingworks/api'; @@ -31,6 +33,7 @@ import { printElementToPdfWhenReady, } from '@votingworks/ui'; import { LogEventId } from '@votingworks/logging'; +import { getSystemSettings } from '../api'; import { DEFAULT_LOCALE } from '../config/globals'; import { getHumanBallotLanguageFormat } from '../utils/election'; import { pdfToImages } from '../utils/pdf_to_images'; @@ -51,6 +54,11 @@ const UsbImage = styled.img` export function ExportElectionBallotPackageModalButton(): JSX.Element { const { electionDefinition, usbDrive, auth, logger } = useContext(AppContext); assert(electionDefinition); + const systemSettingsQuery = + getSystemSettings.useQuery() as UseQueryResult; + const systemSettings = systemSettingsQuery.isSuccess + ? systemSettingsQuery.data + : undefined; assert(isElectionManagerAuth(auth) || isSystemAdministratorAuth(auth)); const userRole = auth.user.role; const { election, electionData, electionHash } = electionDefinition; @@ -60,6 +68,8 @@ export function ExportElectionBallotPackageModalButton(): JSX.Element { workflow.init(electionDefinition, electionLocaleCodes) ); + const loaded = systemSettingsQuery.isSuccess; + const [isModalOpen, setIsModalOpen] = useState(false); /** @@ -189,6 +199,10 @@ export function ExportElectionBallotPackageModalButton(): JSX.Element { await state.archive.beginWithDirectSave(pathToFolder, defaultFileName); } await state.archive.file('election.json', electionData); + await state.archive.file( + 'systemSettings.json', + JSON.stringify(systemSettings || {}, null, 2) + ); await state.archive.file( 'manifest.json', JSON.stringify({ ballots: state.ballotConfigs }, undefined, 2) @@ -254,8 +268,12 @@ export function ExportElectionBallotPackageModalButton(): JSX.Element { case 'mounted': { actions = ( - diff --git a/apps/admin/frontend/src/components/print_all_ballots_button.test.tsx b/apps/admin/frontend/src/components/print_all_ballots_button.test.tsx index 0bb70cbca0..9c41c88a82 100644 --- a/apps/admin/frontend/src/components/print_all_ballots_button.test.tsx +++ b/apps/admin/frontend/src/components/print_all_ballots_button.test.tsx @@ -158,6 +158,7 @@ test('initial modal state toggles based on printer state', async () => { reason: 'machine_locked', }); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetMachineConfig(); hardware.setPrinterConnected(false); @@ -189,6 +190,7 @@ test('modal shows "Printer Disconnected" if printer disconnected while printing' reason: 'machine_locked', }); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetMachineConfig(); renderApp(); @@ -234,6 +236,7 @@ test('modal is different for system administrators', async () => { reason: 'machine_locked', }); apiMock.expectGetCurrentElectionMetadata({ electionDefinition }); + apiMock.expectGetSystemSettings(); apiMock.expectGetCastVoteRecords([]); apiMock.expectGetMachineConfig(); renderApp(); diff --git a/apps/admin/frontend/src/lib/read_file_async.ts b/apps/admin/frontend/src/lib/read_file_async.ts deleted file mode 100644 index 34f099f0fa..0000000000 --- a/apps/admin/frontend/src/lib/read_file_async.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function readFileAsync(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const { result } = reader; - resolve(typeof result === 'string' ? result : ''); - }; - reader.onerror = reject; - reader.readAsText(file); - }); -} diff --git a/apps/admin/frontend/src/screens/definition_contests_screen.tsx b/apps/admin/frontend/src/screens/definition_contests_screen.tsx index 61b37a8ec2..21b0e5f5a7 100644 --- a/apps/admin/frontend/src/screens/definition_contests_screen.tsx +++ b/apps/admin/frontend/src/screens/definition_contests_screen.tsx @@ -15,7 +15,7 @@ import { } from '@votingworks/types'; import { Button, SegmentedButton, Prose, Text } from '@votingworks/ui'; -import { readFileAsync } from '../lib/read_file_async'; +import { readFileAsyncAsString } from '@votingworks/utils'; import { InputEventFunction, TextareaEventFunction } from '../config/types'; import { NavigationScreen } from '../components/navigation_screen'; @@ -284,7 +284,7 @@ export function DefinitionContestsScreen({ if (file?.type === 'image/svg+xml') { const yesNoContest = contest as YesNoContest; try { - const fileContent = await readFileAsync(file); + const fileContent = await readFileAsyncAsString(file); const description = `${yesNoContest.description} ${fileContent}`; diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx new file mode 100644 index 0000000000..4db027a500 --- /dev/null +++ b/apps/admin/frontend/src/screens/unconfigured_screen.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { + electionMinimalExhaustiveSampleFixtures, + systemSettings, +} from '@votingworks/fixtures'; +import userEvent from '@testing-library/user-event'; +import { zipFile } from '@votingworks/test-utils'; +import { renderInAppContext } from '../../test/render_in_app_context'; +import { screen } from '../../test/react_testing_library'; + +import { ApiMock, createApiMock } from '../../test/helpers/api_mock'; +import { UnconfiguredScreen } from './unconfigured_screen'; + +let apiMock: ApiMock; + +beforeEach(() => { + apiMock = createApiMock(); +}); + +afterEach(() => { + apiMock.assertComplete(); +}); + +test('renders a button to load setup package', async () => { + renderInAppContext(, { + apiMock, + }); + + await screen.findByText('Select Existing Setup Package Zip File'); +}); + +test('handles an uploaded file', async () => { + const { electionDefinition } = electionMinimalExhaustiveSampleFixtures; + + apiMock.expectConfigure(electionDefinition.electionData); + apiMock.expectSetSystemSettings(systemSettings.asText()); + + renderInAppContext(, { + apiMock, + electionDefinition: 'NONE', + }); + + const pkg = await zipFile({ + 'election.json': electionDefinition.electionData, + 'systemSettings.json': systemSettings.asText(), + }); + const file = new File([pkg], 'filepath.zip'); + const zipInput = await screen.findByLabelText( + 'Select Existing Setup Package Zip File' + ); + userEvent.upload(zipInput, file); + + await screen.findByText('Loading'); + // election_manager (the parent component) handles advancing to the next screen so we + // just need to test that loading is false and we rerender without the loading screen + await screen.findByLabelText('Select Existing Setup Package Zip File'); +}); diff --git a/apps/admin/frontend/src/screens/unconfigured_screen.tsx b/apps/admin/frontend/src/screens/unconfigured_screen.tsx index 2eb769c634..ad9ac946c5 100644 --- a/apps/admin/frontend/src/screens/unconfigured_screen.tsx +++ b/apps/admin/frontend/src/screens/unconfigured_screen.tsx @@ -14,11 +14,12 @@ import { assert } from '@votingworks/basics'; // eslint-disable-next-line vx/gts-no-import-export-type import type { ConfigureResult } from '@votingworks/admin-backend'; +import { readFileAsyncAsString } from '@votingworks/utils'; +import { readInitialAdminSetupPackageFromFile } from '../utils/initial_setup_package'; import { getElectionDefinitionConverterClient, VxFile, } from '../lib/converters'; -import { readFileAsync } from '../lib/read_file_async'; import { InputEventFunction } from '../config/types'; @@ -29,7 +30,7 @@ import { FileInputButton } from '../components/file_input_button'; import { HorizontalRule } from '../components/horizontal_rule'; import { Loading } from '../components/loading'; import { NavigationScreen } from '../components/navigation_screen'; -import { configure } from '../api'; +import { configure, setSystemSettings } from '../api'; const Loaded = styled.p` line-height: 2.5rem; @@ -65,6 +66,7 @@ export function UnconfiguredScreen(): JSX.Element { const history = useHistory(); const isMounted = useMountedState(); const configureMutation = configure.useMutation(); + const setSystemSettingsMutation = setSystemSettings.useMutation(); const configureMutateAsync = useCallback( async (electionData) => { @@ -85,12 +87,15 @@ export function UnconfiguredScreen(): JSX.Element { const { converter } = useContext(AppContext); const [isUploading, setIsUploading] = useState(false); + const [isUploadingZip, setIsUploadingZip] = useState(false); const [inputConversionFiles, setInputConversionFiles] = useState( [] ); const [isLoading, setIsLoading] = useState(false); const [vxElectionFileIsInvalid, setVxElectionFileIsInvalid] = useState(false); + const [systemSettingsFileIsInvalid, setSystemSettingsFileIsInvalid] = + useState(false); const [isUsingConverter, setIsUsingConverter] = useState(false); const client = useMemo( @@ -103,26 +108,66 @@ export function UnconfiguredScreen(): JSX.Element { history.push(routerPaths.electionDefinition); } - const handleVxElectionFile: InputEventFunction = async (event) => { - setIsUploading(true); - const input = event.currentTarget; - const file = input.files && input.files[0]; - - if (file) { - setVxElectionFileIsInvalid(false); - // TODO: read file content from backend - const fileContent = await readFileAsync(file); + const saveElectionToBackend = useCallback( + async (fileContent: string) => { const configureResult = await configureMutateAsync(fileContent); if (configureResult.isErr()) { setVxElectionFileIsInvalid(true); // eslint-disable-next-line no-console console.error( - 'handleVxElectionFile failed', + 'configureMutateAsync failed in saveElectionToBackend', configureResult.err().message ); } - setIsUploading(false); + }, + [configureMutateAsync] + ); + + const saveSystemSettingsToBackend = useCallback( + (fileContent: string) => { + setSystemSettingsMutation.mutate( + { systemSettings: fileContent }, + { + onSuccess: (result) => { + if (result.isErr()) { + setSystemSettingsFileIsInvalid(true); + } + }, + } + ); + }, + [setSystemSettingsMutation] + ); + + const handleVxElectionFile: InputEventFunction = async (event) => { + setIsUploading(true); + const input = event.currentTarget; + const file = input.files && input.files[0]; + + if (file) { + setVxElectionFileIsInvalid(false); + // TODO: read file content from backend + const fileContent = await readFileAsyncAsString(file); + await saveElectionToBackend(fileContent); + } + setIsUploading(false); + }; + + const handleSetupPackageFile: InputEventFunction = async (event) => { + setIsUploadingZip(true); + const input = event.currentTarget; + const file = input.files && input.files[0]; + + if (file) { + const initialSetupPackage = await readInitialAdminSetupPackageFromFile( + file + ); + setVxElectionFileIsInvalid(false); + setSystemSettingsFileIsInvalid(false); + await saveElectionToBackend(initialSetupPackage.electionString); + saveSystemSettingsToBackend(initialSetupPackage.systemSettingsString); } + setIsUploadingZip(false); }; const resetServerFiles = useCallback(async () => { @@ -238,7 +283,7 @@ export function UnconfiguredScreen(): JSX.Element { void updateStatus(); }, [updateStatus]); - if (isUploading || isLoading) { + if (isUploading || isUploadingZip || isLoading) { return ( @@ -301,6 +346,9 @@ export function UnconfiguredScreen(): JSX.Element { {vxElectionFileIsInvalid && ( Invalid Vx Election Definition file. )} + {systemSettingsFileIsInvalid && ( + Invalid System Settings file. + )}

+

+ + Select Existing Setup Package Zip File + +

{client && inputConversionFiles.length > 0 && ( diff --git a/apps/admin/frontend/src/utils/cast_vote_record_files.ts b/apps/admin/frontend/src/utils/cast_vote_record_files.ts index ab204352b8..468cbae296 100644 --- a/apps/admin/frontend/src/utils/cast_vote_record_files.ts +++ b/apps/admin/frontend/src/utils/cast_vote_record_files.ts @@ -3,11 +3,11 @@ import { CastVoteRecord, Election } from '@votingworks/types'; import { parseCvrFileInfoFromFilename, parseCvrs } from '@votingworks/utils'; import arrayUnique from 'array-unique'; import { sha256 } from 'js-sha256'; +import { readFileAsyncAsString } from '@votingworks/utils'; import { CastVoteRecordFile, CastVoteRecordFilePreprocessedData, } from '../config/types'; -import { readFileAsync } from '../lib/read_file_async'; /** * Adds elements to a set by creating a new set with the contents of the @@ -219,7 +219,7 @@ export class CastVoteRecordFiles { */ async add(file: File, election: Election): Promise { try { - const fileContent = await readFileAsync(file); + const fileContent = await readFileAsyncAsString(file); const parsedFileInfo = parseCvrFileInfoFromFilename(file.name); const result = this.addFromFileContent( fileContent, diff --git a/apps/admin/frontend/src/utils/initial_setup_package.test.ts b/apps/admin/frontend/src/utils/initial_setup_package.test.ts new file mode 100644 index 0000000000..6f70be1d01 --- /dev/null +++ b/apps/admin/frontend/src/utils/initial_setup_package.test.ts @@ -0,0 +1,20 @@ +import { zipFile } from '@votingworks/test-utils'; +import { electionMinimalExhaustiveSampleFixtures } from '@votingworks/fixtures'; +import { readInitialAdminSetupPackageFromFile } from './initial_setup_package'; + +test('readInitialAdminSetupPackageFromFile happy path', async () => { + const { electionDefinition, systemSettings } = + electionMinimalExhaustiveSampleFixtures; + const pkg = await zipFile({ + 'election.json': electionDefinition.electionData, + 'systemSettings.json': systemSettings.asText(), + }); + const file = new File([pkg], 'filepath.zip'); + + const setupPackage = await readInitialAdminSetupPackageFromFile(file); + + const electionObj = JSON.parse(setupPackage.electionString); + expect(electionObj.title).toEqual('Example Primary Election'); + const systemSettingsObj = JSON.parse(setupPackage.systemSettingsString); + expect(systemSettingsObj.arePollWorkerCardPinsEnabled).toEqual(true); +}); diff --git a/apps/admin/frontend/src/utils/initial_setup_package.ts b/apps/admin/frontend/src/utils/initial_setup_package.ts new file mode 100644 index 0000000000..a6ba1e8326 --- /dev/null +++ b/apps/admin/frontend/src/utils/initial_setup_package.ts @@ -0,0 +1,43 @@ +import { Buffer } from 'buffer'; + +import { + readFile, + openZip, + getEntries, + readTextEntry, + getFileByName, +} from '@votingworks/utils'; + +// InitialAdminSetupPackage models the zip file read in by VxAdmin when a system admin configures the machine. +// It's the delivery method for the system settings file. +// VxAdmin is the only machine to read system settings this way; other machines read system settings +// in via the ballot package which is exported by VxAdmin. Therefore we need a different way for +// VxAdmin to ingest system settings. +export interface InitialAdminSetupPackage { + systemSettingsString: string; + electionString: string; +} + +async function readInitialAdminSetupPackageFromBuffer( + source: Buffer +): Promise { + const zipfile = await openZip(source); + const entries = getEntries(zipfile); + + const electionEntry = getFileByName(entries, 'election.json'); + const electionString = await readTextEntry(electionEntry); + + const systemSettingsEntry = getFileByName(entries, 'systemSettings.json'); + const systemSettingsString = await readTextEntry(systemSettingsEntry); + + return { + electionString, + systemSettingsString, + }; +} + +export async function readInitialAdminSetupPackageFromFile( + file: File +): Promise { + return readInitialAdminSetupPackageFromBuffer(await readFile(file)); +} diff --git a/apps/admin/frontend/test/helpers/api_mock.ts b/apps/admin/frontend/test/helpers/api_mock.ts index 925bd1ef32..3782a9005c 100644 --- a/apps/admin/frontend/test/helpers/api_mock.ts +++ b/apps/admin/frontend/test/helpers/api_mock.ts @@ -137,6 +137,18 @@ export function createApiMock( apiClient.unconfigure.expectCallWith().resolves(); }, + expectSetSystemSettings(systemSettings: string) { + apiClient.setSystemSettings + .expectCallWith({ systemSettings }) + .resolves(ok({})); + }, + + expectGetSystemSettings() { + apiClient.getSystemSettings.expectCallWith().resolves({ + arePollWorkerCardPinsEnabled: false, + }); + }, + expectGetCastVoteRecordFileMode(fileMode: Admin.CvrFileMode) { apiClient.getCastVoteRecordFileMode.expectCallWith().resolves(fileMode); }, diff --git a/libs/fixtures/data/AdminInitialSetupPackage.zip b/libs/fixtures/data/AdminInitialSetupPackage.zip new file mode 100644 index 0000000000000000000000000000000000000000..62d076fe086e0920c207eecb36ff9da0c6fa095a GIT binary patch literal 1424 zcmWIWW@Zs#-~hs>frViVP|zaGz`)C(z>u1gnp~2ZpQo2qoSzpO!pp!ezd$5K0f<3# zX$3a}Bg;!>1_m(kHsW;AWdo7B&$UhBjwiJ9Zb%IB$Y?V=slwRiF1~58%B)k9CO#3H z{&K(a>Av}!IQ3e2jdPEE+_~?$UEeynBVbWFxXai+qiANnP_m{?+VXecL-%UJxr|;|uOMr0{j}roDcSt#`Bi zZ}+4<>4~{rnP`39yzg_+|CQNaX9`tK5Kz-kIHMGKq0PXhruY38_NFB+d!9&gpWLY5 z%CtY@_m`D6cNRCkP1`taQSIc!16xf>*4wS$a$BINGjMhA@|UlL*cerg{*84xv8SnV zX_1t;*oDs)yF5Sk)MqFev)CPdl(5WeLfN8BTmSiioi5d1ZCGzj(0VIiy&|<@&6_n_ z&l&NR2G}i$USk^I;NCb-lg;275382I6~moB=PptFxF+t;ox+I@mYqRL?8jUk<29dE z3ZJ^MCH!dl)a37n^@Nsf%~XjrSvXI{`rBO}!?nzBS#DkpRTSL%AmZbpyxlY9z^zQVJA15;efjb2W3uk6 z^F1%i8A#REtzA_F2$)CL|lD1@3Kly zne`FvtE)EXMt_)BGUw;@w<+4nwK(4|F2&EMVmEwQJLlzug84{ zey9kYd?=>=an;(qnTO9>cD)I)6+3>1OHkr;?qP!`Z`g9Z?{4GxB5-l`yrWz_(@$T0 zbod98&awof+jIQG7KE(Z;-j!pJN3GWM0E2kJKa-tvMRQ!r#{~Pn6vL*!{(#U?zq_2 zUyk}^vr71jWYYg%`>p;@vbww?+V$p(BKyjzhw`rPU#Q0$SbjoXa_K6O{uOi5*Pc3Q zHNBO=hQ-E1^A?j&<0r;HOQydu+v)tkRLEg(KV$gJqQb1hQ=E6tOWN5oU+wk%*S&Ss zCA;S6XO+r7Vds|lIiqY&xD2n)bJ5d0r_XEmygg&vV`bCxN}ciL&C^zq&1*OAoZfoe zOKIu~ZNV5*m#KCELZOL4KW#5ee3Q5P(Xl}ju-EV^Cvma>!0$|E%MXx;yiIiyX>sD&nZvez)e9Knp(b3 zS3P9{+Rn}q+!U6s3$zbpXMi^&lL#}S{6cmqsQiL~C5<2!Nu?RG(IBrPTMP0k3@m9h aU|}G~;{o2RY#>9JfN&3x-Ul>>fdK%gY(&@q literal 0 HcmV?d00001 diff --git a/libs/fixtures/data/sampleAdminInitialSetupPackage/election.json b/libs/fixtures/data/sampleAdminInitialSetupPackage/election.json new file mode 100644 index 0000000000..1f1c8496d1 --- /dev/null +++ b/libs/fixtures/data/sampleAdminInitialSetupPackage/election.json @@ -0,0 +1,213 @@ +{ + "title": "Example Primary Election", + "state": "State of Sample", + "county": { + "id": "sample-county", + "name": "Sample County" + }, + "date": "2021-09-08T00:00:00-08:00", + "ballotLayout": { + "paperSize": "letter" + }, + "districts": [ + { + "id": "district-1", + "name": "District 1" + } + ], + "parties": [ + { + "id": "0", + "name": "Mammal", + "fullName": "Mammal Party", + "abbrev": "Ma" + }, + { + "id": "1", + "name": "Fish", + "fullName": "Fish Party", + "abbrev": "F" + } + ], + "contests": [ + { + "id": "best-animal-mammal", + "districtId": "district-1", + "type": "candidate", + "title": "Best Animal", + "seats": 1, + "partyId": "0", + "candidates": [ + { + "id": "horse", + "name": "Horse", + "partyIds": ["0"] + }, + { + "id": "otter", + "name": "Otter", + "partyIds": ["0"] + }, + { + "id": "fox", + "name": "Fox", + "partyIds": ["0"] + } + ], + "allowWriteIns": false + }, + { + "id": "best-animal-fish", + "districtId": "district-1", + "type": "candidate", + "title": "Best Animal", + "seats": 1, + "partyId": "1", + "candidates": [ + { + "id": "seahorse", + "name": "Seahorse", + "partyIds": ["1"] + }, + { + "id": "salmon", + "name": "Salmon", + "partyIds": ["1"] + } + ], + "allowWriteIns": false + }, + { + "id": "zoo-council-mammal", + "districtId": "district-1", + "type": "candidate", + "title": "Zoo Council", + "seats": 3, + "partyId": "0", + "candidates": [ + { + "id": "zebra", + "name": "Zebra", + "partyIds": ["0"] + }, + { + "id": "lion", + "name": "Lion", + "partyIds": ["0"] + }, + { + "id": "kangaroo", + "name": "Kangaroo", + "partyIds": ["0"] + }, + { + "id": "elephant", + "name": "Elephant", + "partyIds": ["0"] + } + ], + "allowWriteIns": true + }, + { + "id": "aquarium-council-fish", + "districtId": "district-1", + "type": "candidate", + "title": "Zoo Council", + "seats": 2, + "partyId": "1", + "candidates": [ + { + "id": "manta-ray", + "name": "Manta Ray", + "partyIds": ["1"] + }, + { + "id": "pufferfish", + "name": "Pufferfish", + "partyIds": ["1"] + }, + { + "id": "rockfish", + "name": "Rockfish", + "partyIds": ["1"] + }, + { + "id": "triggerfish", + "name": "Triggerfish", + "partyIds": ["1"] + } + ], + "allowWriteIns": true + }, + { + "id": "new-zoo-either", + "districtId": "district-1", + "type": "yesno", + "title": "Ballot Measure 1", + "description": "Initiative Measure No. 12, Should Sample City establish a new safari-style zoo costing 2,000,000?\n\n Alternative Measure 12 A, Should Sample City establish a new traditional zoo costing 1,000,000?", + "yesOption": { + "id": "new-zoo-either-approved", + "label": "FOR APPROVAL OF EITHER Initiative No. 12 OR Alternative Initiative No. 12 A" + }, + "noOption": { + "id": "new-zoo-neither-approved", + "label": "AGAINST BOTH Initiative No. 12 AND Alternative Measure 12 A" + } + }, + { + "id": "new-zoo-pick", + "districtId": "district-1", + "type": "yesno", + "title": "Ballot Measure 1", + "description": "Initiative Measure No. 12, Should Sample City establish a new safari-style zoo costing 2,000,000?\n\n Alternative Measure 12 A, Should Sample City establish a new traditional zoo costing 1,000,000?", + "yesOption": { + "id": "new-zoo-safari", + "label": "FOR Initiative No. 12" + }, + "noOption": { + "id": "new-zoo-traditional", + "label": "FOR Alternative Measure No. 12 A" + } + }, + { + "id": "fishing", + "districtId": "district-1", + "type": "yesno", + "title": "Ballot Measure 3", + "description": "Should fishing be banned in all city owned lakes and rivers?", + "yesOption": { + "id": "ban-fishing", + "label": "YES" + }, + "noOption": { + "id": "allow-fishing", + "label": "NO" + } + } + ], + "precincts": [ + { + "id": "precinct-1", + "name": "Precinct 1" + }, + { + "id": "precinct-2", + "name": "Precinct 2" + } + ], + "ballotStyles": [ + { + "id": "1M", + "precincts": ["precinct-1", "precinct-2"], + "districts": ["district-1"], + "partyId": "0" + }, + { + "id": "2F", + "precincts": ["precinct-1", "precinct-2"], + "districts": ["district-1"], + "partyId": "1" + } + ], + "sealUrl": "/seals/Sample-Seal.svg" +} diff --git a/libs/fixtures/data/sampleAdminInitialSetupPackage/systemSettings.json b/libs/fixtures/data/sampleAdminInitialSetupPackage/systemSettings.json new file mode 100644 index 0000000000..57267c2bd0 --- /dev/null +++ b/libs/fixtures/data/sampleAdminInitialSetupPackage/systemSettings.json @@ -0,0 +1,3 @@ +{ + "arePollWorkerCardPinsEnabled": true +} diff --git a/libs/fixtures/src/data/AdminInitialSetupPackage.zip.ts b/libs/fixtures/src/data/AdminInitialSetupPackage.zip.ts new file mode 100644 index 0000000000..700943f78f --- /dev/null +++ b/libs/fixtures/src/data/AdminInitialSetupPackage.zip.ts @@ -0,0 +1,50 @@ +/* Generated by res-to-ts. DO NOT EDIT */ +/* eslint-disable */ +/* istanbul ignore file */ + +import { Buffer } from 'buffer'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, sep } from 'path'; + +/** + * Data of data/AdminInitialSetupPackage.zip encoded as base64. + * + * SHA-256 hash of file data: 64f5c96a1c04b1451c9656a2ff0f678f3312c3d4822894b75a54353e05fb77e6 + */ +const resourceDataBase64 = 'UEsDBBQACAAIAI5UZlYAAAAAAAAAAIQTAAANACAAZWxlY3Rpb24uanNvblVUDQAHrDIGZKf5C2Sy+QtkdXgLAAEE6QMAAATpAwAA7VjLctMwFN3nKzRex2CHDbBhUkhohjbJJAGGRxeyoySaypKR5BaX6b8jy46fsgkuhQ0zbcbxub7nPo6uFf0YAGBJLAmyXgJr8h0GIUFgyXEAeQwmBPkSM2oNEzMhodRm6+QCsB1Ya/MU9VlEZazgH+qb+o63ianQFnYGDlOIwiD1k7K9TkGF3WtP24xm5Ixc23lhO883jvNS/6lr9ZkSepAQJi9gzCJZ0IYwRHyN77QHgqREvOQZC8mxL4UCv2j79Kk83KOB7WaxlqJ9k2HAtTR0rz6vtNsQcolRq1On6esSBgEkxf1dRMi8goGlchoXFtDzOLpJ8Yx/aCIzBD7F4mCmSpB2omk9T59RiUR79TwF2pAq7RA7qGV4rOystc4yDnVQPqRbrDVQQEeBnikGMNYMBSoQ1DG5+Z2kIfGsXvzccZFAOYlSIgfGRUFfKeZ5E8rYtFfFd5Vj98MuEqa1aSRZNKGeJDv23UwxrQPtBNnVVSERtfBuP3Is0Ywm9jtIBOrQZFkXu4oY/5Uq3D6qUB47hLE2opWquie3TUASZGO3SWTA2mke3Lw7xvT49vFjLerPjOl3gGJotu/ZH1vUd8jj0FzSz02o53ojuK1tFw2kJ8U1pHvIGTPTvDOiPakQQeEBUmmmmhjRB8wRyaMuJcJvEeQ4CnI5PsYs6RTj6I/NkkDVDdocxubSXiYwWNXhntMkjHY7xCvVqrAtW/CedJz51+1kKyPak0q1eL/vSm3TZvCAifkLmVJ0aydDE2F5KL3Ff0OfMRKUGbR5pne84BJBEXEESk9ukfA5DvVeXRnOKJYYSnyDcuM5ewLc0RCsDywiW3DcemMZA/X2hB5JtoIQqOCBgDu1zmwhY2WhMgE+ExLTvZK/4zjJ/6uv9CsFY6J2KbRK447A+CQSyeEWJ/GqrW6Zw805iuxUPRbH3AoRGMttwzDk7AZtS922CPQQ0ZuexQqMl8vV4sP4AiymYDLbnE9WoFSutEwgsStl1zQYW4OaPi3Kfh0kPSXK8dvxbL7egLPF5txEPX/TWvo8qhP0GWL/+r86/5I606hbRdnocj95lcJu17+hLg1Rd8gnGaWqFI+onGetysk6l4UAPAQ8SCnaAkyB8gH8pJfsNrlD4DUSQL2PAVeZcnFqw5RDu55iuYSfJuvf641+e3S6nC9qdR/kBwsc+Zh2nFccDUznFcsMy88rjO3MPYw6PIzqJwHp2cs6WYOtsbmXhcdKIpWoKxFcNUSVPlASVWFS+T3QkeFo+tfiaJwMqY0jec91l58m1+JpOnVs9WuRPBE3e2twP/gJUEsHCILZSZbVAwAAhBMAAFBLAwQUAAgACAAms2hWAAAAAAAAAAAoAAAAEwAgAHN5c3RlbVNldHRpbmdzLmpzb25VVA0AB8l7CWTmkQtk/HsJZHV4CwABBOkDAAAE6QMAAKvmUlBQKsjPyQnPL8pOLXJOLEoJyMwrds1LTMpJTVGyUigpKk3lquUCAFBLBwhkv2K9KgAAACgAAABQSwECFAMUAAgACACOVGZWgtlJltUDAACEEwAADQAgAAAAAAAAAAAApIEAAAAAZWxlY3Rpb24uanNvblVUDQAHrDIGZKf5C2Sy+QtkdXgLAAEE6QMAAATpAwAAUEsBAhQDFAAIAAgAJrNoVmS/Yr0qAAAAKAAAABMAIAAAAAAAAAAAAKSBMAQAAHN5c3RlbVNldHRpbmdzLmpzb25VVA0AB8l7CWTmkQtk/HsJZHV4CwABBOkDAAAE6QMAAFBLBQYAAAAAAgACALwAAAC7BAAAAAA='; + +/** + * MIME type of data/AdminInitialSetupPackage.zip. + */ +export const mimeType = 'application/zip'; + +/** + * Path to a file containing this file's contents. + * + * SHA-256 hash of file data: 64f5c96a1c04b1451c9656a2ff0f678f3312c3d4822894b75a54353e05fb77e6 + */ +export function asFilePath(): string { + const directoryPath = mkdtempSync(tmpdir() + sep); + const filePath = join(directoryPath, 'AdminInitialSetupPackage.zip'); + writeFileSync(filePath, asBuffer()); + return filePath; +} + +/** + * Convert to a `data:` URL of data/AdminInitialSetupPackage.zip, suitable for embedding in HTML. + * + * SHA-256 hash of file data: 64f5c96a1c04b1451c9656a2ff0f678f3312c3d4822894b75a54353e05fb77e6 + */ +export function asDataUrl(): string { + return `data:${mimeType};base64,${resourceDataBase64}`; +} + +/** + * Raw data of data/AdminInitialSetupPackage.zip. + * + * SHA-256 hash of file data: 64f5c96a1c04b1451c9656a2ff0f678f3312c3d4822894b75a54353e05fb77e6 + */ +export function asBuffer(): Buffer { + return Buffer.from(resourceDataBase64, 'base64'); +} \ No newline at end of file diff --git a/libs/fixtures/src/data/electionMinimalExhaustiveSample/index.ts b/libs/fixtures/src/data/electionMinimalExhaustiveSample/index.ts index f8dcd179ab..8fa05a65eb 100644 --- a/libs/fixtures/src/data/electionMinimalExhaustiveSample/index.ts +++ b/libs/fixtures/src/data/electionMinimalExhaustiveSample/index.ts @@ -19,3 +19,4 @@ export { election, electionDefinition, } from './electionMinimalExhaustiveSample.json'; +export * as systemSettings from '../sampleAdminInitialSetupPackage/systemSettings.json'; diff --git a/libs/fixtures/src/data/sampleAdminInitialSetupPackage/election.json.ts b/libs/fixtures/src/data/sampleAdminInitialSetupPackage/election.json.ts new file mode 100644 index 0000000000..588bcb22b7 --- /dev/null +++ b/libs/fixtures/src/data/sampleAdminInitialSetupPackage/election.json.ts @@ -0,0 +1,76 @@ +/* Generated by res-to-ts. DO NOT EDIT */ +/* eslint-disable */ +/* istanbul ignore file */ + +import { Buffer } from 'buffer'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, sep } from 'path'; +import { safeParseElectionDefinition } from '@votingworks/types'; + +/** + * Data of data/sampleAdminInitialSetupPackage/election.json encoded as base64. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +const resourceDataBase64 = 'ewogICJ0aXRsZSI6ICJFeGFtcGxlIFByaW1hcnkgRWxlY3Rpb24iLAogICJzdGF0ZSI6ICJTdGF0ZSBvZiBTYW1wbGUiLAogICJjb3VudHkiOiB7CiAgICAiaWQiOiAic2FtcGxlLWNvdW50eSIsCiAgICAibmFtZSI6ICJTYW1wbGUgQ291bnR5IgogIH0sCiAgImRhdGUiOiAiMjAyMS0wOS0wOFQwMDowMDowMC0wODowMCIsCiAgImJhbGxvdExheW91dCI6IHsKICAgICJwYXBlclNpemUiOiAibGV0dGVyIgogIH0sCiAgImRpc3RyaWN0cyI6IFsKICAgIHsKICAgICAgImlkIjogImRpc3RyaWN0LTEiLAogICAgICAibmFtZSI6ICJEaXN0cmljdCAxIgogICAgfQogIF0sCiAgInBhcnRpZXMiOiBbCiAgICB7CiAgICAgICJpZCI6ICIwIiwKICAgICAgIm5hbWUiOiAiTWFtbWFsIiwKICAgICAgImZ1bGxOYW1lIjogIk1hbW1hbCBQYXJ0eSIsCiAgICAgICJhYmJyZXYiOiAiTWEiCiAgICB9LAogICAgewogICAgICAiaWQiOiAiMSIsCiAgICAgICJuYW1lIjogIkZpc2giLAogICAgICAiZnVsbE5hbWUiOiAiRmlzaCBQYXJ0eSIsCiAgICAgICJhYmJyZXYiOiAiRiIKICAgIH0KICBdLAogICJjb250ZXN0cyI6IFsKICAgIHsKICAgICAgImlkIjogImJlc3QtYW5pbWFsLW1hbW1hbCIsCiAgICAgICJkaXN0cmljdElkIjogImRpc3RyaWN0LTEiLAogICAgICAidHlwZSI6ICJjYW5kaWRhdGUiLAogICAgICAidGl0bGUiOiAiQmVzdCBBbmltYWwiLAogICAgICAic2VhdHMiOiAxLAogICAgICAicGFydHlJZCI6ICIwIiwKICAgICAgImNhbmRpZGF0ZXMiOiBbCiAgICAgICAgewogICAgICAgICAgImlkIjogImhvcnNlIiwKICAgICAgICAgICJuYW1lIjogIkhvcnNlIiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMCJdCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAib3R0ZXIiLAogICAgICAgICAgIm5hbWUiOiAiT3R0ZXIiLAogICAgICAgICAgInBhcnR5SWRzIjogWyIwIl0KICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJmb3giLAogICAgICAgICAgIm5hbWUiOiAiRm94IiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMCJdCiAgICAgICAgfQogICAgICBdLAogICAgICAiYWxsb3dXcml0ZUlucyI6IGZhbHNlCiAgICB9LAogICAgewogICAgICAiaWQiOiAiYmVzdC1hbmltYWwtZmlzaCIsCiAgICAgICJkaXN0cmljdElkIjogImRpc3RyaWN0LTEiLAogICAgICAidHlwZSI6ICJjYW5kaWRhdGUiLAogICAgICAidGl0bGUiOiAiQmVzdCBBbmltYWwiLAogICAgICAic2VhdHMiOiAxLAogICAgICAicGFydHlJZCI6ICIxIiwKICAgICAgImNhbmRpZGF0ZXMiOiBbCiAgICAgICAgewogICAgICAgICAgImlkIjogInNlYWhvcnNlIiwKICAgICAgICAgICJuYW1lIjogIlNlYWhvcnNlIiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMSJdCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAic2FsbW9uIiwKICAgICAgICAgICJuYW1lIjogIlNhbG1vbiIsCiAgICAgICAgICAicGFydHlJZHMiOiBbIjEiXQogICAgICAgIH0KICAgICAgXSwKICAgICAgImFsbG93V3JpdGVJbnMiOiBmYWxzZQogICAgfSwKICAgIHsKICAgICAgImlkIjogInpvby1jb3VuY2lsLW1hbW1hbCIsCiAgICAgICJkaXN0cmljdElkIjogImRpc3RyaWN0LTEiLAogICAgICAidHlwZSI6ICJjYW5kaWRhdGUiLAogICAgICAidGl0bGUiOiAiWm9vIENvdW5jaWwiLAogICAgICAic2VhdHMiOiAzLAogICAgICAicGFydHlJZCI6ICIwIiwKICAgICAgImNhbmRpZGF0ZXMiOiBbCiAgICAgICAgewogICAgICAgICAgImlkIjogInplYnJhIiwKICAgICAgICAgICJuYW1lIjogIlplYnJhIiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMCJdCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAibGlvbiIsCiAgICAgICAgICAibmFtZSI6ICJMaW9uIiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMCJdCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAia2FuZ2Fyb28iLAogICAgICAgICAgIm5hbWUiOiAiS2FuZ2Fyb28iLAogICAgICAgICAgInBhcnR5SWRzIjogWyIwIl0KICAgICAgICB9LAogICAgICAgIHsKICAgICAgICAgICJpZCI6ICJlbGVwaGFudCIsCiAgICAgICAgICAibmFtZSI6ICJFbGVwaGFudCIsCiAgICAgICAgICAicGFydHlJZHMiOiBbIjAiXQogICAgICAgIH0KICAgICAgXSwKICAgICAgImFsbG93V3JpdGVJbnMiOiB0cnVlCiAgICB9LAogICAgewogICAgICAiaWQiOiAiYXF1YXJpdW0tY291bmNpbC1maXNoIiwKICAgICAgImRpc3RyaWN0SWQiOiAiZGlzdHJpY3QtMSIsCiAgICAgICJ0eXBlIjogImNhbmRpZGF0ZSIsCiAgICAgICJ0aXRsZSI6ICJab28gQ291bmNpbCIsCiAgICAgICJzZWF0cyI6IDIsCiAgICAgICJwYXJ0eUlkIjogIjEiLAogICAgICAiY2FuZGlkYXRlcyI6IFsKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAibWFudGEtcmF5IiwKICAgICAgICAgICJuYW1lIjogIk1hbnRhIFJheSIsCiAgICAgICAgICAicGFydHlJZHMiOiBbIjEiXQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgImlkIjogInB1ZmZlcmZpc2giLAogICAgICAgICAgIm5hbWUiOiAiUHVmZmVyZmlzaCIsCiAgICAgICAgICAicGFydHlJZHMiOiBbIjEiXQogICAgICAgIH0sCiAgICAgICAgewogICAgICAgICAgImlkIjogInJvY2tmaXNoIiwKICAgICAgICAgICJuYW1lIjogIlJvY2tmaXNoIiwKICAgICAgICAgICJwYXJ0eUlkcyI6IFsiMSJdCiAgICAgICAgfSwKICAgICAgICB7CiAgICAgICAgICAiaWQiOiAidHJpZ2dlcmZpc2giLAogICAgICAgICAgIm5hbWUiOiAiVHJpZ2dlcmZpc2giLAogICAgICAgICAgInBhcnR5SWRzIjogWyIxIl0KICAgICAgICB9CiAgICAgIF0sCiAgICAgICJhbGxvd1dyaXRlSW5zIjogdHJ1ZQogICAgfSwKICAgIHsKICAgICAgImlkIjogIm5ldy16b28tZWl0aGVyIiwKICAgICAgImRpc3RyaWN0SWQiOiAiZGlzdHJpY3QtMSIsCiAgICAgICJ0eXBlIjogInllc25vIiwKICAgICAgInRpdGxlIjogIkJhbGxvdCBNZWFzdXJlIDEiLAogICAgICAiZGVzY3JpcHRpb24iOiAiSW5pdGlhdGl2ZSBNZWFzdXJlIE5vLiAxMiwgU2hvdWxkIFNhbXBsZSBDaXR5IGVzdGFibGlzaCBhIG5ldyBzYWZhcmktc3R5bGUgem9vIGNvc3RpbmcgMiwwMDAsMDAwP1xuXG4gQWx0ZXJuYXRpdmUgTWVhc3VyZSAxMiBBLCBTaG91bGQgU2FtcGxlIENpdHkgZXN0YWJsaXNoIGEgbmV3IHRyYWRpdGlvbmFsIHpvbyBjb3N0aW5nIDEsMDAwLDAwMD8iLAogICAgICAieWVzT3B0aW9uIjogewogICAgICAgICJpZCI6ICJuZXctem9vLWVpdGhlci1hcHByb3ZlZCIsCiAgICAgICAgImxhYmVsIjogIkZPUiBBUFBST1ZBTCBPRiBFSVRIRVIgSW5pdGlhdGl2ZSBOby4gMTIgT1IgQWx0ZXJuYXRpdmUgSW5pdGlhdGl2ZSBOby4gMTIgQSIKICAgICAgfSwKICAgICAgIm5vT3B0aW9uIjogewogICAgICAgICJpZCI6ICJuZXctem9vLW5laXRoZXItYXBwcm92ZWQiLAogICAgICAgICJsYWJlbCI6ICJBR0FJTlNUIEJPVEggSW5pdGlhdGl2ZSBOby4gMTIgQU5EIEFsdGVybmF0aXZlIE1lYXN1cmUgMTIgQSIKICAgICAgfQogICAgfSwKICAgIHsKICAgICAgImlkIjogIm5ldy16b28tcGljayIsCiAgICAgICJkaXN0cmljdElkIjogImRpc3RyaWN0LTEiLAogICAgICAidHlwZSI6ICJ5ZXNubyIsCiAgICAgICJ0aXRsZSI6ICJCYWxsb3QgTWVhc3VyZSAxIiwKICAgICAgImRlc2NyaXB0aW9uIjogIkluaXRpYXRpdmUgTWVhc3VyZSBOby4gMTIsIFNob3VsZCBTYW1wbGUgQ2l0eSBlc3RhYmxpc2ggYSBuZXcgc2FmYXJpLXN0eWxlIHpvbyBjb3N0aW5nIDIsMDAwLDAwMD9cblxuIEFsdGVybmF0aXZlIE1lYXN1cmUgMTIgQSwgU2hvdWxkIFNhbXBsZSBDaXR5IGVzdGFibGlzaCBhIG5ldyB0cmFkaXRpb25hbCB6b28gY29zdGluZyAxLDAwMCwwMDA/IiwKICAgICAgInllc09wdGlvbiI6IHsKICAgICAgICAiaWQiOiAibmV3LXpvby1zYWZhcmkiLAogICAgICAgICJsYWJlbCI6ICJGT1IgSW5pdGlhdGl2ZSBOby4gMTIiCiAgICAgIH0sCiAgICAgICJub09wdGlvbiI6IHsKICAgICAgICAiaWQiOiAibmV3LXpvby10cmFkaXRpb25hbCIsCiAgICAgICAgImxhYmVsIjogIkZPUiBBbHRlcm5hdGl2ZSBNZWFzdXJlIE5vLiAxMiBBIgogICAgICB9CiAgICB9LAogICAgewogICAgICAiaWQiOiAiZmlzaGluZyIsCiAgICAgICJkaXN0cmljdElkIjogImRpc3RyaWN0LTEiLAogICAgICAidHlwZSI6ICJ5ZXNubyIsCiAgICAgICJ0aXRsZSI6ICJCYWxsb3QgTWVhc3VyZSAzIiwKICAgICAgImRlc2NyaXB0aW9uIjogIlNob3VsZCBmaXNoaW5nIGJlIGJhbm5lZCBpbiBhbGwgY2l0eSBvd25lZCBsYWtlcyBhbmQgcml2ZXJzPyIsCiAgICAgICJ5ZXNPcHRpb24iOiB7CiAgICAgICAgImlkIjogImJhbi1maXNoaW5nIiwKICAgICAgICAibGFiZWwiOiAiWUVTIgogICAgICB9LAogICAgICAibm9PcHRpb24iOiB7CiAgICAgICAgImlkIjogImFsbG93LWZpc2hpbmciLAogICAgICAgICJsYWJlbCI6ICJOTyIKICAgICAgfQogICAgfQogIF0sCiAgInByZWNpbmN0cyI6IFsKICAgIHsKICAgICAgImlkIjogInByZWNpbmN0LTEiLAogICAgICAibmFtZSI6ICJQcmVjaW5jdCAxIgogICAgfSwKICAgIHsKICAgICAgImlkIjogInByZWNpbmN0LTIiLAogICAgICAibmFtZSI6ICJQcmVjaW5jdCAyIgogICAgfQogIF0sCiAgImJhbGxvdFN0eWxlcyI6IFsKICAgIHsKICAgICAgImlkIjogIjFNIiwKICAgICAgInByZWNpbmN0cyI6IFsicHJlY2luY3QtMSIsICJwcmVjaW5jdC0yIl0sCiAgICAgICJkaXN0cmljdHMiOiBbImRpc3RyaWN0LTEiXSwKICAgICAgInBhcnR5SWQiOiAiMCIKICAgIH0sCiAgICB7CiAgICAgICJpZCI6ICIyRiIsCiAgICAgICJwcmVjaW5jdHMiOiBbInByZWNpbmN0LTEiLCAicHJlY2luY3QtMiJdLAogICAgICAiZGlzdHJpY3RzIjogWyJkaXN0cmljdC0xIl0sCiAgICAgICJwYXJ0eUlkIjogIjEiCiAgICB9CiAgXSwKICAic2VhbFVybCI6ICIvc2VhbHMvU2FtcGxlLVNlYWwuc3ZnIgp9Cg=='; + +/** + * MIME type of data/sampleAdminInitialSetupPackage/election.json. + */ +export const mimeType = 'application/json'; + +/** + * Path to a file containing this file's contents. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export function asFilePath(): string { + const directoryPath = mkdtempSync(tmpdir() + sep); + const filePath = join(directoryPath, 'election.json'); + writeFileSync(filePath, asBuffer()); + return filePath; +} + +/** + * Convert to a `data:` URL of data/sampleAdminInitialSetupPackage/election.json, suitable for embedding in HTML. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export function asDataUrl(): string { + return `data:${mimeType};base64,${resourceDataBase64}`; +} + +/** + * Raw data of data/sampleAdminInitialSetupPackage/election.json. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export function asBuffer(): Buffer { + return Buffer.from(resourceDataBase64, 'base64'); +} + +/** + * Text content of data/sampleAdminInitialSetupPackage/election.json. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export function asText(): string { + return asBuffer().toString('utf-8'); +} + +/** + * Full election definition for data/sampleAdminInitialSetupPackage/election.json. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export const electionDefinition = safeParseElectionDefinition( + asText() +).unsafeUnwrap(); + +/** + * Election definition for data/sampleAdminInitialSetupPackage/election.json. + * + * SHA-256 hash of file data: e38b66442647f4bc87a08e3e4f0986db3c354a58b0790096b88c2e208e18cd08 + */ +export const election = electionDefinition.election; \ No newline at end of file diff --git a/libs/fixtures/src/data/sampleAdminInitialSetupPackage/systemSettings.json.ts b/libs/fixtures/src/data/sampleAdminInitialSetupPackage/systemSettings.json.ts new file mode 100644 index 0000000000..887a7a7df2 --- /dev/null +++ b/libs/fixtures/src/data/sampleAdminInitialSetupPackage/systemSettings.json.ts @@ -0,0 +1,59 @@ +/* Generated by res-to-ts. DO NOT EDIT */ +/* eslint-disable */ +/* istanbul ignore file */ + +import { Buffer } from 'buffer'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, sep } from 'path'; + +/** + * Data of data/sampleAdminInitialSetupPackage/systemSettings.json encoded as base64. + * + * SHA-256 hash of file data: ad5f55cd7aa2ba3208e4e74f1480a33341edc354a29924d5f71bd932bc4d9748 + */ +const resourceDataBase64 = 'ewogICJhcmVQb2xsV29ya2VyQ2FyZFBpbnNFbmFibGVkIjogdHJ1ZQp9Cg=='; + +/** + * MIME type of data/sampleAdminInitialSetupPackage/systemSettings.json. + */ +export const mimeType = 'application/json'; + +/** + * Path to a file containing this file's contents. + * + * SHA-256 hash of file data: ad5f55cd7aa2ba3208e4e74f1480a33341edc354a29924d5f71bd932bc4d9748 + */ +export function asFilePath(): string { + const directoryPath = mkdtempSync(tmpdir() + sep); + const filePath = join(directoryPath, 'systemSettings.json'); + writeFileSync(filePath, asBuffer()); + return filePath; +} + +/** + * Convert to a `data:` URL of data/sampleAdminInitialSetupPackage/systemSettings.json, suitable for embedding in HTML. + * + * SHA-256 hash of file data: ad5f55cd7aa2ba3208e4e74f1480a33341edc354a29924d5f71bd932bc4d9748 + */ +export function asDataUrl(): string { + return `data:${mimeType};base64,${resourceDataBase64}`; +} + +/** + * Raw data of data/sampleAdminInitialSetupPackage/systemSettings.json. + * + * SHA-256 hash of file data: ad5f55cd7aa2ba3208e4e74f1480a33341edc354a29924d5f71bd932bc4d9748 + */ +export function asBuffer(): Buffer { + return Buffer.from(resourceDataBase64, 'base64'); +} + +/** + * Text content of data/sampleAdminInitialSetupPackage/systemSettings.json. + * + * SHA-256 hash of file data: ad5f55cd7aa2ba3208e4e74f1480a33341edc354a29924d5f71bd932bc4d9748 + */ +export function asText(): string { + return asBuffer().toString('utf-8'); +} \ No newline at end of file diff --git a/libs/fixtures/src/index.test.ts b/libs/fixtures/src/index.test.ts index 15351b7afa..621070e368 100644 --- a/libs/fixtures/src/index.test.ts +++ b/libs/fixtures/src/index.test.ts @@ -43,6 +43,7 @@ test('has various election definitions', () => { "primaryElectionSampleDefinition", "primaryElectionSampleFixtures", "sampleBallotImages", + "systemSettings", ] `); }); diff --git a/libs/fixtures/src/index.ts b/libs/fixtures/src/index.ts index 71607f35ae..ad53f939bb 100644 --- a/libs/fixtures/src/index.ts +++ b/libs/fixtures/src/index.ts @@ -60,6 +60,7 @@ export { electionDefinition as electionSampleLongContentDefinition, election as electionSampleLongContent, } from './data/electionSampleLongContent.json'; +export * as systemSettings from './data/sampleAdminInitialSetupPackage/systemSettings.json'; export function asElectionDefinition(election: Election): ElectionDefinition { const electionData = JSON.stringify(election); diff --git a/libs/logging/VotingWorksLoggingDocumentation.md b/libs/logging/VotingWorksLoggingDocumentation.md index 34b7a25f20..7261e8b235 100644 --- a/libs/logging/VotingWorksLoggingDocumentation.md +++ b/libs/logging/VotingWorksLoggingDocumentation.md @@ -210,6 +210,22 @@ IDs are logged with each log to identify the log being written. **Type:** [user-action](#user-action) **Description:** User attempted to save the test deck tally report as PDF. Success or failure indicated by subsequent FileSaved log disposition. **Machines:** vx-admin-frontend +### initial-setup-zip-package-loaded +**Type:** [user-action](#user-action) +**Description:** User loaded VxAdmin initial setup package +**Machines:** vx-admin-frontend +### system-settings-save-initiated +**Type:** [application-status](#application-status) +**Description:** VxAdmin attempting to save System Settings to db +**Machines:** vx-admin-service +### system-settings-saved +**Type:** [application-status](#application-status) +**Description:** VxAdmin System Settings saved to db +**Machines:** vx-admin-service +### system-settings-retrieved +**Type:** [application-status](#application-status) +**Description:** VxAdmin System Settings read from db +**Machines:** vx-admin-service ### toggle-test-mode-init **Type:** [user-action](#user-action) **Description:** User has initiated toggling between test mode and live mode in the current application. diff --git a/libs/logging/src/log_documentation.test.ts b/libs/logging/src/log_documentation.test.ts index 1025b1910c..c70df33b51 100644 --- a/libs/logging/src/log_documentation.test.ts +++ b/libs/logging/src/log_documentation.test.ts @@ -27,7 +27,7 @@ describe('test cdf documentation generation', () => { expect(structuredData.DeviceModel).toEqual('VxAdmin 1.0'); expect(structuredData.GeneratedDate).toEqual('2020-07-24T00:00:00.000Z'); expect(structuredData.EventTypeDescription).toHaveLength(5); - expect(structuredData.EventIdDescription).toHaveLength(54); + expect(structuredData.EventIdDescription).toHaveLength(55); // Make sure VxAdminFrontend specific logs are included. expect(structuredData.EventIdDescription).toContainEqual( expect.objectContaining({ diff --git a/libs/logging/src/log_event_ids.ts b/libs/logging/src/log_event_ids.ts index 04ca31a55c..43dc495161 100644 --- a/libs/logging/src/log_event_ids.ts +++ b/libs/logging/src/log_event_ids.ts @@ -69,6 +69,10 @@ export enum LogEventId { TestDeckPrinted = 'test-deck-printed', TestDeckTallyReportPrinted = 'test-deck-tally-report-printed', TestDeckTallyReportSavedToPdf = 'test-deck-tally-report-saved-to-pdf', + InitialSetupPackageLoaded = 'initial-setup-zip-package-loaded', + SystemSettingsSaveInitiated = 'system-settings-save-initiated', + SystemSettingsSaved = 'system-settings-saved', + SystemSettingsRetrieved = 'system-settings-retrieved', // VxCentralScan specific user action logs TogglingTestMode = 'toggle-test-mode-init', ToggledTestMode = 'toggled-test-mode', @@ -866,6 +870,34 @@ const ScannerStateChanged: LogDetails = { restrictInDocumentationToApps: [LogSource.VxScanBackend], }; +const InitialSetupPackageLoaded: LogDetails = { + eventId: LogEventId.InitialSetupPackageLoaded, + eventType: LogEventType.UserAction, + documentationMessage: 'User loaded VxAdmin initial setup package', + restrictInDocumentationToApps: [LogSource.VxAdminFrontend], +}; + +const SystemSettingsSaveInitiated: LogDetails = { + eventId: LogEventId.SystemSettingsSaveInitiated, + eventType: LogEventType.ApplicationStatus, + documentationMessage: 'VxAdmin attempting to save System Settings to db', + restrictInDocumentationToApps: [LogSource.VxAdminService], +}; + +const SystemSettingsSaved: LogDetails = { + eventId: LogEventId.SystemSettingsSaved, + eventType: LogEventType.ApplicationStatus, + documentationMessage: 'VxAdmin System Settings saved to db', + restrictInDocumentationToApps: [LogSource.VxAdminService], +}; + +const SystemSettingsRetrieved: LogDetails = { + eventId: LogEventId.SystemSettingsRetrieved, + eventType: LogEventType.ApplicationStatus, + documentationMessage: 'VxAdmin System Settings read from db', + restrictInDocumentationToApps: [LogSource.VxAdminService], +}; + export function getDetailsForEventId(eventId: LogEventId): LogDetails { switch (eventId) { case LogEventId.ElectionConfigured: @@ -1058,6 +1090,15 @@ export function getDetailsForEventId(eventId: LogEventId): LogDetails { return ScannerEvent; case LogEventId.ScannerStateChanged: return ScannerStateChanged; + case LogEventId.InitialSetupPackageLoaded: + return InitialSetupPackageLoaded; + case LogEventId.SystemSettingsSaveInitiated: + return SystemSettingsSaveInitiated; + case LogEventId.SystemSettingsSaved: + return SystemSettingsSaved; + case LogEventId.SystemSettingsRetrieved: + return SystemSettingsRetrieved; + /* istanbul ignore next - compile time check for completeness */ default: throwIllegalValue(eventId); diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 86f1fa44d3..ce07dec95e 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -17,6 +17,7 @@ export * from './numeric'; export * from './polls'; export * from './precinct_selection'; export * from './printing'; +export * from './system_settings'; export * from './tallies'; export * from './ui_theme'; export * from './voting_method'; diff --git a/libs/types/src/system_settings.ts b/libs/types/src/system_settings.ts new file mode 100644 index 0000000000..d80cd164ce --- /dev/null +++ b/libs/types/src/system_settings.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { Id, Iso8601Timestamp } from './generic'; + +/** + * System settings as used by frontends. Several database fields are hidden from the app + * because they are omitted in this model; see schema.sql. + */ +export interface SystemSettings { + arePollWorkerCardPinsEnabled: boolean; +} + +export const SystemSettingsSchema: z.ZodType = z.object({ + arePollWorkerCardPinsEnabled: z.boolean(), +}); + +/** + * System settings as used by the db. + */ +export interface SystemSettingsDbRow { + id: Id; + created: Iso8601Timestamp; + // sqlite3 does not support booleans + arePollWorkerCardPinsEnabled: 0 | 1; +} diff --git a/libs/utils/src/ballot_package.ts b/libs/utils/src/ballot_package.ts index b0a2bc164e..f38fca0355 100644 --- a/libs/utils/src/ballot_package.ts +++ b/libs/utils/src/ballot_package.ts @@ -11,9 +11,17 @@ import { } from '@votingworks/types'; import { Buffer } from 'buffer'; import 'fast-text-encoding'; -import JsZip, { JSZipObject } from 'jszip'; import { z } from 'zod'; import { assert } from '@votingworks/basics'; +import { + getFileByName, + readFile, + openZip, + getEntries, + readEntry, + readTextEntry, + readJsonEntry, +} from './file_reading'; export interface BallotPackage { electionDefinition: ElectionDefinition; @@ -44,61 +52,14 @@ export interface BallotConfig extends BallotStyleData { isAbsentee: boolean; } -function readFile(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - /* istanbul ignore next */ - reader.onerror = () => { - reject(reader.error); - }; - - reader.onload = () => { - resolve(Buffer.from(reader.result as ArrayBuffer)); - }; - - reader.readAsArrayBuffer(file); - }); -} - -async function openZip(data: Uint8Array): Promise { - return await new JsZip().loadAsync(data); -} - -function getEntries(zipfile: JsZip): JSZipObject[] { - return Object.values(zipfile.files); -} - -async function readEntry(entry: JSZipObject): Promise { - return entry.async('nodebuffer'); -} - -async function readTextEntry(entry: JSZipObject): Promise { - const bytes = await readEntry(entry); - return new TextDecoder().decode(bytes); -} - -async function readJsonEntry(entry: JSZipObject): Promise { - return JSON.parse(await readTextEntry(entry)); -} - -function getFileByName(entries: JSZipObject[], name: string): JSZipObject { - const result = entries.find((entry) => entry.name === name); - - if (!result) { - throw new Error(`ballot package does not have a file called '${name}'`); - } - - return result; -} - export async function readBallotPackageFromBuffer( source: Buffer ): Promise { const zipfile = await openZip(source); + const zipName = 'ballot package'; const entries = getEntries(zipfile); - const electionEntry = getFileByName(entries, 'election.json'); - const manifestEntry = getFileByName(entries, 'manifest.json'); + const electionEntry = getFileByName(entries, 'election.json', zipName); + const manifestEntry = getFileByName(entries, 'manifest.json', zipName); const electionData = await readTextEntry(electionEntry); const manifest = (await readJsonEntry( @@ -106,8 +67,16 @@ export async function readBallotPackageFromBuffer( )) as BallotPackageManifest; const ballots = await Promise.all( manifest.ballots.map>(async (ballotConfig) => { - const ballotEntry = getFileByName(entries, ballotConfig.filename); - const layoutEntry = getFileByName(entries, ballotConfig.layoutFilename); + const ballotEntry = getFileByName( + entries, + ballotConfig.filename, + zipName + ); + const layoutEntry = getFileByName( + entries, + ballotConfig.layoutFilename, + zipName + ); const pdf = await readEntry(ballotEntry); const layout = safeParseJson( diff --git a/libs/utils/src/file_reading.ts b/libs/utils/src/file_reading.ts new file mode 100644 index 0000000000..b3da01c8f5 --- /dev/null +++ b/libs/utils/src/file_reading.ts @@ -0,0 +1,80 @@ +import JsZip, { JSZipObject } from 'jszip'; +import { Buffer } from 'buffer'; + +export function readFileAsyncAsString(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const { result } = reader; + resolve(typeof result === 'string' ? result : ''); + }; + reader.onerror = () => { + reject(reader.error); + }; + reader.readAsText(file); + }); +} + +export function readFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + /* istanbul ignore next */ + reader.onerror = () => { + reject(reader.error); + }; + + reader.onload = () => { + if (!reader.result) { + resolve(Buffer.from([])); + return; + } + + resolve(Buffer.from(reader.result as ArrayBuffer)); + }; + + reader.readAsArrayBuffer(file); + }); +} + +export async function openZip(data: Uint8Array): Promise { + return await new JsZip().loadAsync(data); +} + +export function getEntries(zipfile: JsZip): JSZipObject[] { + return Object.values(zipfile.files); +} + +export async function readEntry(entry: JSZipObject): Promise { + return entry.async('nodebuffer'); +} + +export async function readTextEntry(entry: JSZipObject): Promise { + const bytes = await readEntry(entry); + return new TextDecoder().decode(bytes); +} + +export async function readJsonEntry(entry: JSZipObject): Promise { + return JSON.parse(await readTextEntry(entry)); +} + +/** + * + * @param entries - represents the entries of a zip file + * @param name - the target file to find in the entries + * @param [zipName] - human-readable name zip file for use in error handling + * @returns contents of the target file as a JSZipObject + */ +export function getFileByName( + entries: JSZipObject[], + name: string, + zipName = 'Zip object' +): JSZipObject { + const result = entries.find((entry) => entry.name === name); + + if (!result) { + throw new Error(`${zipName} does not have a file called '${name}'`); + } + + return result; +} diff --git a/libs/utils/src/index.ts b/libs/utils/src/index.ts index 3b9fcefa1a..5b33f5df7b 100644 --- a/libs/utils/src/index.ts +++ b/libs/utils/src/index.ts @@ -11,6 +11,7 @@ export * as format from './format'; export * from './features'; export * from './fetch_json'; export * from './filenames'; +export * from './file_reading'; export * from './Hardware'; export * from './hmpb'; export * from './json_stream';