diff --git a/apps/admin/backend/src/app.ts b/apps/admin/backend/src/app.ts index deacd2937c..67495b038e 100644 --- a/apps/admin/backend/src/app.ts +++ b/apps/admin/backend/src/app.ts @@ -32,9 +32,11 @@ import { createReadStream, createWriteStream, promises as fs, Stats } from 'fs'; import { basename, dirname, join } from 'path'; import { BALLOT_PACKAGE_FOLDER, + BooleanEnvironmentVariableName, CAST_VOTE_RECORD_REPORT_FILENAME, generateFilenameForBallotExportPackage, groupMapToGroupList, + isFeatureFlagEnabled, isIntegrationTest, parseCastVoteRecordReportDirectoryName, } from '@votingworks/utils'; @@ -48,6 +50,7 @@ import { CvrFileMode, ElectionRecord, ExportDataResult, + ImportCastVoteRecordsError, ManualResultsIdentifier, ManualResultsMetadataRecord, ManualResultsRecord, @@ -68,7 +71,7 @@ import { addCastVoteRecordReport, getAddCastVoteRecordReportErrorMessage, listCastVoteRecordFilesOnUsb, -} from './cvr_files'; +} from './legacy_cast_vote_records'; import { getMachineConfig } from './machine_config'; import { getWriteInAdjudicationContext, @@ -86,6 +89,7 @@ import { getOverallElectionWriteInSummary } from './tabulation/write_ins'; import { rootDebug } from './util/debug'; import { tabulateTallyReportResults } from './tabulation/tally_reports'; import { buildExporter } from './util/exporter'; +import { importCastVoteRecords } from './cast_vote_records'; const debug = rootDebug.extend('app'); @@ -404,9 +408,39 @@ function buildApi({ }): Promise< Result< CvrFileImportInfo, - AddCastVoteRecordReportError & { message: string } + | (AddCastVoteRecordReportError & { message: string }) + | ImportCastVoteRecordsError > > { + /* c8 ignore start */ + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.ENABLE_CONTINUOUS_EXPORT + ) + ) { + const userRole = assertDefined(await getUserRole()); + const importResult = await importCastVoteRecords(store, input.path); + if (importResult.isErr()) { + await logger.log(LogEventId.CvrLoaded, userRole, { + disposition: 'failure', + errorDetails: JSON.stringify(importResult.err()), + errorType: importResult.err().type, + exportDirectoryPath: input.path, + result: 'Cast vote records not imported, error shown to user.', + }); + } else { + await logger.log(LogEventId.CvrLoaded, userRole, { + disposition: 'success', + exportDirectoryPath: input.path, + numberOfBallotsImported: importResult.ok().newlyAdded, + numberOfDuplicateBallotsIgnored: importResult.ok().alreadyPresent, + result: 'Cast vote records imported.', + }); + } + return importResult; + } + /* c8 ignore stop */ + const userRole = assertDefined(await getUserRole()); const { path: inputPath } = input; // the path passed to the backend may be for the report directory or the diff --git a/apps/admin/backend/src/cast_vote_records.ts b/apps/admin/backend/src/cast_vote_records.ts new file mode 100644 index 0000000000..acf47bd8f8 --- /dev/null +++ b/apps/admin/backend/src/cast_vote_records.ts @@ -0,0 +1,283 @@ +/* c8 ignore start */ +import * as fs from 'fs/promises'; +import { sha256 } from 'js-sha256'; +import path from 'path'; +import { v4 as uuid } from 'uuid'; +import { isTestReport, readCastVoteRecordExport } from '@votingworks/backend'; +import { assert, assertDefined, err, ok, Result } from '@votingworks/basics'; +import { + BallotId, + BallotPageLayoutSchema, + CVR, + ElectionDefinition, + getContests, + safeParseJson, +} from '@votingworks/types'; +import { + BooleanEnvironmentVariableName, + castVoteRecordHasValidContestReferences, + convertCastVoteRecordVotesToTabulationVotes, + getBallotStyleById, + getPrecinctById, + isFeatureFlagEnabled, +} from '@votingworks/utils'; + +import { Store } from './store'; +import { + CastVoteRecordElectionDefinitionValidationError, + CvrFileImportInfo, + CvrFileMode, + ImportCastVoteRecordsError, +} from './types'; + +/** + * Validates that the fields in a cast vote record and the election definition correspond + */ +function validateCastVoteRecordAgainstElectionDefinition( + castVoteRecord: CVR.CVR, + electionDefinition: ElectionDefinition +): Result { + function wrapError( + error: Omit + ): Result { + return err({ ...error, type: 'invalid-cast-vote-record' }); + } + + const { election, electionHash } = electionDefinition; + + if ( + castVoteRecord.ElectionId !== electionHash && + !isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_CVR_ELECTION_HASH_CHECK + ) + ) { + return wrapError({ subType: 'election-mismatch' }); + } + + const precinct = getPrecinctById( + electionDefinition, + castVoteRecord.BallotStyleUnitId + ); + if (!precinct) { + return wrapError({ subType: 'precinct-not-found' }); + } + + const ballotStyle = getBallotStyleById( + electionDefinition, + castVoteRecord.BallotStyleId + ); + if (!ballotStyle) { + return wrapError({ subType: 'ballot-style-not-found' }); + } + + const contestValidationResult = castVoteRecordHasValidContestReferences( + castVoteRecord, + getContests({ ballotStyle, election }) + ); + if (contestValidationResult.isErr()) { + return wrapError({ subType: contestValidationResult.err() }); + } + + return ok(); +} + +/** + * Imports cast vote records given a cast vote record export directory path + */ +export async function importCastVoteRecords( + store: Store, + exportDirectoryPath: string +): Promise> { + const electionId = assertDefined(store.getCurrentElectionId()); + const { electionDefinition } = assertDefined(store.getElection(electionId)); + + const readResult = await readCastVoteRecordExport(exportDirectoryPath); + if (readResult.isErr()) { + return readResult; + } + const { castVoteRecordExportMetadata, castVoteRecords } = readResult.ok(); + const { castVoteRecordReportMetadata } = castVoteRecordExportMetadata; + + const exportDirectoryName = path.basename(exportDirectoryPath); + // Hashing the export metadata, which includes a root hash of all the individual cast vote + // records, gives us a complete hash of the entire export + const exportHash = sha256(JSON.stringify(castVoteRecordExportMetadata)); + const exportedTimestamp = castVoteRecordReportMetadata.GeneratedDate; + + // Ensure that the records to be imported match the mode (test vs. official) of previously + // imported records + const mode: CvrFileMode = isTestReport(castVoteRecordReportMetadata) + ? 'test' + : 'official'; + const currentMode = store.getCurrentCvrFileModeForElection(electionId); + if (currentMode !== 'unlocked' && mode !== currentMode) { + return err({ type: 'invalid-mode', currentMode }); + } + + const existingImportId = store.getCastVoteRecordFileByHash( + electionId, + exportHash + ); + if (existingImportId) { + return ok({ + id: existingImportId, + alreadyPresent: store.getCastVoteRecordCountByFileId(existingImportId), + exportedTimestamp, + fileMode: mode, + fileName: exportDirectoryName, + newlyAdded: 0, + wasExistingFile: true, + }); + } + + return await store.withTransaction(async () => { + const scannerIds = new Set(); + for (const vxBatch of castVoteRecordReportMetadata.vxBatch) { + store.addScannerBatch({ + batchId: vxBatch['@id'], + electionId, + label: vxBatch.BatchLabel, + scannerId: vxBatch.CreatingDeviceId, + }); + scannerIds.add(vxBatch.CreatingDeviceId); + } + + // Create a top-level record for the import + const importId = uuid(); + store.addCastVoteRecordFileRecord({ + id: importId, + electionId, + exportedTimestamp, + filename: exportDirectoryName, + isTestMode: isTestReport(castVoteRecordReportMetadata), + scannerIds, + sha256Hash: exportHash, + }); + + let castVoteRecordIndex = 0; + let newlyAdded = 0; + let alreadyPresent = 0; + const precinctIds = new Set(); + for await (const castVoteRecordResult of castVoteRecords) { + if (castVoteRecordResult.isErr()) { + return err({ + ...castVoteRecordResult.err(), + index: castVoteRecordIndex, + }); + } + const { + castVoteRecord, + castVoteRecordBallotSheetId, + castVoteRecordCurrentSnapshot, + castVoteRecordWriteIns, + referencedFiles, + } = castVoteRecordResult.ok(); + + const validationResult = validateCastVoteRecordAgainstElectionDefinition( + castVoteRecord, + electionDefinition + ); + if (validationResult.isErr()) { + return err({ ...validationResult.err(), index: castVoteRecordIndex }); + } + + // Add an individual cast vote record to the import + const votes = convertCastVoteRecordVotesToTabulationVotes( + castVoteRecordCurrentSnapshot + ); + const addCastVoteRecordResult = store.addCastVoteRecordFileEntry({ + ballotId: castVoteRecord.UniqueId as BallotId, + cvr: { + ballotStyleId: castVoteRecord.BallotStyleId, + batchId: castVoteRecord.BatchId, + card: castVoteRecordBallotSheetId + ? { type: 'hmpb', sheetNumber: castVoteRecordBallotSheetId } + : { type: 'bmd' }, + precinctId: castVoteRecord.BallotStyleUnitId, + votes, + votingMethod: castVoteRecord.vxBallotType, + }, + cvrFileId: importId, + electionId, + }); + if (addCastVoteRecordResult.isErr()) { + return err({ + ...addCastVoteRecordResult.err(), + index: castVoteRecordIndex, + }); + } + const { cvrId: castVoteRecordId, isNew: isCastVoteRecordNew } = + addCastVoteRecordResult.ok(); + + if (isCastVoteRecordNew) { + const hmpbCastVoteRecordWriteIns = castVoteRecordWriteIns.filter( + (castVoteRecordWriteIn) => castVoteRecordWriteIn.side + ); + if (hmpbCastVoteRecordWriteIns.length > 0) { + // Guaranteed to exist given validation in readCastVoteRecordExport + assert(referencedFiles !== undefined); + + for (const i of [0, 1] as const) { + const imageData = await fs.readFile( + referencedFiles.imageFilePaths[i] + ); + const parseLayoutResult = safeParseJson( + await fs.readFile(referencedFiles.layoutFilePaths[i], 'utf8'), + BallotPageLayoutSchema + ); + if (parseLayoutResult.isErr()) { + return err({ + type: 'invalid-cast-vote-record', + subType: 'layout-parse-error', + index: castVoteRecordIndex, + }); + } + store.addBallotImage({ + cvrId: castVoteRecordId, + imageData, + pageLayout: parseLayoutResult.ok(), + side: (['front', 'back'] as const)[i], + }); + } + + for (const hmpbCastVoteRecordWriteIn of hmpbCastVoteRecordWriteIns) { + store.addWriteIn({ + castVoteRecordId, + contestId: hmpbCastVoteRecordWriteIn.contestId, + electionId, + optionId: hmpbCastVoteRecordWriteIn.optionId, + side: assertDefined(hmpbCastVoteRecordWriteIn.side), + }); + } + } + } + + if (isCastVoteRecordNew) { + newlyAdded += 1; + } else { + alreadyPresent += 1; + } + precinctIds.add(castVoteRecord.BallotStyleUnitId); + + castVoteRecordIndex += 1; + } + + // TODO: Calculate the precinct list before iterating through cast vote records, once there is + // only one geopolitical unit per batch + store.updateCastVoteRecordFileRecord({ + id: importId, + precinctIds, + }); + + return ok({ + id: importId, + alreadyPresent, + exportedTimestamp, + fileMode: mode, + fileName: exportDirectoryName, + newlyAdded, + wasExistingFile: false, + }); + }); +} +/* c8 ignore stop */ diff --git a/apps/admin/backend/src/cvr_files.test.ts b/apps/admin/backend/src/legacy_cast_vote_records.test.ts similarity index 98% rename from apps/admin/backend/src/cvr_files.test.ts rename to apps/admin/backend/src/legacy_cast_vote_records.test.ts index d73888d9e8..b3c1477c6d 100644 --- a/apps/admin/backend/src/cvr_files.test.ts +++ b/apps/admin/backend/src/legacy_cast_vote_records.test.ts @@ -6,7 +6,7 @@ import { createMockUsbDrive } from '@votingworks/usb-drive'; import { listCastVoteRecordFilesOnUsb, validateCastVoteRecord, -} from './cvr_files'; +} from './legacy_cast_vote_records'; const electionDefinition = electionTwoPartyPrimaryDefinition; const file = Buffer.from([]); @@ -361,7 +361,7 @@ describe('validateCastVoteRecord', () => { reportBatchIds: ['batch-1'], }); - expect(result.err()).toEqual('invalid-contest'); + expect(result.err()).toEqual('contest-not-found'); }); test('error on invalid contest option id', () => { @@ -392,7 +392,7 @@ describe('validateCastVoteRecord', () => { reportBatchIds: ['batch-1'], }); - expect(result.err()).toEqual('invalid-contest-option'); + expect(result.err()).toEqual('contest-option-not-found'); }); test('error on unknown ballot image', () => { diff --git a/apps/admin/backend/src/cvr_files.ts b/apps/admin/backend/src/legacy_cast_vote_records.ts similarity index 91% rename from apps/admin/backend/src/cvr_files.ts rename to apps/admin/backend/src/legacy_cast_vote_records.ts index 107066ba04..6ff6991583 100644 --- a/apps/admin/backend/src/cvr_files.ts +++ b/apps/admin/backend/src/legacy_cast_vote_records.ts @@ -7,22 +7,19 @@ import { CVR_BALLOT_IMAGES_SUBDIRECTORY, CVR_BALLOT_LAYOUTS_SUBDIRECTORY, isTestReport, + readCastVoteRecordExportMetadata, } from '@votingworks/backend'; import { assert, err, ok, - integers, Result, throwIllegalValue, } from '@votingworks/basics'; import { LogEventId, Logger } from '@votingworks/logging'; import { - AnyContest, BallotId, BallotPageLayoutSchema, - ContestOptionId, - Contests, CVR, ElectionDefinition, getBallotStyle, @@ -35,12 +32,16 @@ import { import { BooleanEnvironmentVariableName, CAST_VOTE_RECORD_REPORT_FILENAME, + castVoteRecordHasValidContestReferences, + ContestReferenceError, convertCastVoteRecordVotesToTabulationVotes, generateElectionBasedSubfolderName, getCurrentSnapshot, getWriteInsFromCastVoteRecord, + isCastVoteRecordWriteInValid, isFeatureFlagEnabled, parseCastVoteRecordReportDirectoryName, + parseCastVoteRecordReportExportDirectoryName, SCANNER_RESULTS_FOLDER, } from '@votingworks/utils'; import * as fs from 'fs/promises'; @@ -114,6 +115,40 @@ export async function listCastVoteRecordFilesOnUsb( for (const entry of fileSearchResult.ok()) { if (entry.type === FileSystemEntryType.Directory) { + /* c8 ignore start */ + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.ENABLE_CONTINUOUS_EXPORT + ) + ) { + const directoryNameComponents = + parseCastVoteRecordReportExportDirectoryName(entry.name); + if (!directoryNameComponents) { + continue; + } + const metadataResult = await readCastVoteRecordExportMetadata( + entry.path + ); + if (metadataResult.isErr()) { + continue; + } + const metadata = metadataResult.ok(); + castVoteRecordFileMetadataList.push({ + cvrCount: metadata.castVoteRecordReportMetadata.vxBatch + .map((batch) => batch.NumberSheets) + .reduce((sum, n) => sum + n, 0), + exportTimestamp: new Date( + metadata.castVoteRecordReportMetadata.GeneratedDate + ), + isTestModeResults: directoryNameComponents.inTestMode, + name: entry.name, + path: entry.path, + scannerIds: [directoryNameComponents.machineId], + }); + continue; + } + /* c8 ignore stop */ + const parsedFileInfo = parseCastVoteRecordReportDirectoryName(entry.name); if (parsedFileInfo) { castVoteRecordFileMetadataList.push({ @@ -138,64 +173,6 @@ export async function listCastVoteRecordFilesOnUsb( ); } -// CVR Validation - -function getValidContestOptions(contest: AnyContest): ContestOptionId[] { - switch (contest.type) { - case 'candidate': - return [ - ...contest.candidates.map((candidate) => candidate.id), - ...integers({ from: 0, through: contest.seats - 1 }) - .map((num) => `write-in-${num}`) - .toArray(), - ]; - case 'yesno': - return [contest.yesOption.id, contest.noOption.id]; - /* c8 ignore next 2 */ - default: - return throwIllegalValue(contest); - } -} - -type ContestReferenceError = 'invalid-contest' | 'invalid-contest-option'; - -/** - * Checks whether all the contest and contest options referenced in a cast vote - * record are indeed a part of the specified election. - */ -function snapshotHasValidContestReferences( - snapshot: CVR.CVRSnapshot, - electionContests: Contests -): Result { - for (const cvrContest of snapshot.CVRContest) { - const electionContest = electionContests.find( - (contest) => contest.id === cvrContest.ContestId - ); - if (!electionContest) return err('invalid-contest'); - - const validContestOptions = new Set( - getValidContestOptions(electionContest) - ); - for (const cvrContestSelection of cvrContest.CVRContestSelection) { - if (!validContestOptions.has(cvrContestSelection.ContestSelectionId)) { - return err('invalid-contest-option'); - } - } - } - - return ok(); -} - -/** - * Checks whether any of the write-ins in the cast vote record are invalid - * due to not referencing a top-level ballot image. - */ -function cvrHasValidWriteInImageReferences(cvr: CVR.CVR) { - return getWriteInsFromCastVoteRecord(cvr).every( - ({ side, text }) => side || text - ); -} - type CastVoteRecordValidationError = | 'invalid-election' | 'invalid-ballot-style' @@ -276,14 +253,12 @@ export function validateCastVoteRecord({ return err('no-current-snapshot'); } - for (const snapshot of cvr.CVRSnapshot) { - const contestValidation = snapshotHasValidContestReferences( - snapshot, - getContests({ ballotStyle, election }) - ); - if (contestValidation.isErr()) { - return contestValidation; - } + const contestValidation = castVoteRecordHasValidContestReferences( + cvr, + getContests({ ballotStyle, election }) + ); + if (contestValidation.isErr()) { + return contestValidation; } const ballotImageLocations = cvr.BallotImage?.map( @@ -298,7 +273,11 @@ export function validateCastVoteRecord({ } } - if (!cvrHasValidWriteInImageReferences(cvr)) { + const castVoteRecordWriteIns = getWriteInsFromCastVoteRecord(cvr); + if ( + castVoteRecordWriteIns.length > 0 && + !castVoteRecordWriteIns.every(isCastVoteRecordWriteInValid) + ) { return err('invalid-write-in-image-location'); } @@ -391,9 +370,9 @@ export function getAddCastVoteRecordReportErrorMessage( return 'The record references a ballot image which is not included in the report.'; case 'no-current-snapshot': return `The record does not contain a current snapshot of the interpreted results.`; - case 'invalid-contest': + case 'contest-not-found': return `The record references a contest which does not exist for its ballot style.`; - case 'invalid-contest-option': + case 'contest-option-not-found': return `The record references a contest option which does not exist for the contest.`; default: throwIllegalValue(subErrorType); diff --git a/apps/admin/backend/src/tabulation/full_results.test.ts b/apps/admin/backend/src/tabulation/full_results.test.ts index 88098b2908..73f28dac00 100644 --- a/apps/admin/backend/src/tabulation/full_results.test.ts +++ b/apps/admin/backend/src/tabulation/full_results.test.ts @@ -16,7 +16,7 @@ import { tabulateElectionResults, } from './full_results'; import { Store } from '../store'; -import { addCastVoteRecordReport } from '../cvr_files'; +import { addCastVoteRecordReport } from '../legacy_cast_vote_records'; import { MockCastVoteRecordFile, addMockCvrFileToStore, diff --git a/apps/admin/backend/src/types.ts b/apps/admin/backend/src/types.ts index e17ce4f033..9b26496fae 100644 --- a/apps/admin/backend/src/types.ts +++ b/apps/admin/backend/src/types.ts @@ -1,3 +1,7 @@ +import { + ReadCastVoteRecordError, + ReadCastVoteRecordExportError, +} from '@votingworks/backend'; import { ContestId, ContestOptionId, @@ -497,3 +501,33 @@ export type PartySplitTallyReportResults = TallyReportResultsBase & { export type TallyReportResults = | SingleTallyReportResults | PartySplitTallyReportResults; + +/** + * An error involving the correspondence between the fields in a cast vote record and the election + * definition + */ +export type CastVoteRecordElectionDefinitionValidationError = { + type: 'invalid-cast-vote-record'; +} & ( + | { subType: 'ballot-style-not-found' } + | { subType: 'contest-not-found' } + | { subType: 'contest-option-not-found' } + | { subType: 'election-mismatch' } + | { subType: 'precinct-not-found' } +); + +type WithIndex = T & { index: number }; + +/** + * An error encountered while importing cast vote records + */ +export type ImportCastVoteRecordsError = + | ReadCastVoteRecordExportError + | WithIndex + | WithIndex + | { type: 'invalid-mode'; currentMode: 'official' | 'test' } + | WithIndex<{ type: 'ballot-id-already-exists-with-different-data' }> + | WithIndex<{ + type: 'invalid-cast-vote-record'; + subType: 'layout-parse-error'; + }>; diff --git a/apps/admin/frontend/src/components/import_cvrfiles_modal.tsx b/apps/admin/frontend/src/components/import_cvrfiles_modal.tsx index 63aeff78af..73430a859a 100644 --- a/apps/admin/frontend/src/components/import_cvrfiles_modal.tsx +++ b/apps/admin/frontend/src/components/import_cvrfiles_modal.tsx @@ -22,7 +22,10 @@ import { } from '@votingworks/utils'; import { assert, throwIllegalValue } from '@votingworks/basics'; -import type { CvrFileImportInfo } from '@votingworks/admin-backend'; +import type { + CvrFileImportInfo, + ImportCastVoteRecordsError, +} from '@votingworks/admin-backend'; import { AppContext } from '../contexts/app_context'; import { Loading } from './loading'; import { @@ -62,6 +65,89 @@ const Content = styled.div` overflow: hidden; `; +/* c8 ignore start */ +function userReadableMessageFromError( + error: ImportCastVoteRecordsError +): string { + switch (error.type) { + case 'authentication-error': { + return 'Unable to authenticate cast vote records. Try exporting them from the scanner again.'; + } + case 'ballot-id-already-exists-with-different-data': { + return `Found a cast vote record at index ${error.index} that has the same ballot ID as a previously imported cast vote record, but with different data.`; + } + case 'invalid-mode': { + return { + official: + 'You are currently tabulating official results but the selected cast vote record export contains test results.', + test: 'You are currently tabulating test results but the selected cast vote record export contains official results.', + }[error.currentMode]; + } + case 'invalid-cast-vote-record': { + const messageBase = `Found an invalid cast vote record at index ${error.index}. `; + const messageDetail = (() => { + switch (error.subType) { + case 'ballot-style-not-found': { + return 'The record references a ballot style that does not exist.'; + } + case 'batch-id-not-found': { + return 'The record references a batch ID that does not exist.'; + } + case 'contest-not-found': { + return 'The record references a contest that does not exist.'; + } + case 'contest-option-not-found': { + return 'The record references a contest option that does not exist.'; + } + case 'election-mismatch': { + return 'The record references the wrong election.'; + } + case 'image-file-not-found': { + return 'The record references an image file that does not exist.'; + } + // These two go hand-in-hand + case 'invalid-ballot-image-field': + case 'invalid-write-in-field': { + return 'The record contains an incorrectly formatted ballot image and/or write-in field.'; + } + case 'invalid-ballot-sheet-id': { + return 'The record contains an incorrectly formatted ballot sheet ID.'; + } + case 'layout-file-not-found': { + return 'The record references a layout file that does not exist.'; + } + case 'layout-parse-error': { + return 'The layout file could not be parsed.'; + } + case 'no-current-snapshot': { + return 'The record does not contain a current snapshot of the interpreted results.'; + } + case 'parse-error': { + return 'The record could not be parsed.'; + } + case 'precinct-not-found': { + return 'The record references a precinct that does not exist.'; + } + default: { + throwIllegalValue(error, 'subType'); + } + } + })(); + return [messageBase, messageDetail].join(' '); + } + case 'metadata-file-not-found': { + return 'Unable to find metadata file.'; + } + case 'metadata-file-parse-error': { + return 'Unable to parse metadata file.'; + } + default: { + throwIllegalValue(error, 'type'); + } + } +} +/* c8 ignore stop */ + type ModalState = | { state: 'error'; errorMessage?: string; filename: string } | { state: 'loading' } @@ -95,9 +181,13 @@ export function ImportCvrFilesModal({ onClose }: Props): JSX.Element | null { { onSuccess: (addCastVoteRecordFileResult) => { if (addCastVoteRecordFileResult.isErr()) { + const error = addCastVoteRecordFileResult.err(); setCurrentState({ state: 'error', - errorMessage: addCastVoteRecordFileResult.err().message, + errorMessage: + 'message' in error + ? error.message + : userReadableMessageFromError(error), filename, }); } else if (addCastVoteRecordFileResult.ok().wasExistingFile) { diff --git a/libs/auth/scripts/mock_card.ts b/libs/auth/scripts/mock_card.ts index 923a8ad174..7ec0b73b0e 100644 --- a/libs/auth/scripts/mock_card.ts +++ b/libs/auth/scripts/mock_card.ts @@ -91,9 +91,7 @@ async function parseCommandLineArgs(): Promise { `Must specify election definition for election manager and poll worker cards\n\n${helpMessage}` ); } - const electionData = fs - .readFileSync(args.electionDefinition) - .toString('utf-8'); + const electionData = fs.readFileSync(args.electionDefinition, 'utf-8'); if (!safeParseElection(electionData).isOk()) { throw new Error( `${args.electionDefinition} isn't a valid election definition` diff --git a/libs/auth/src/artifact_authentication.test.ts b/libs/auth/src/artifact_authentication.test.ts index 8511dfe120..a370bc94b2 100644 --- a/libs/auth/src/artifact_authentication.test.ts +++ b/libs/auth/src/artifact_authentication.test.ts @@ -6,7 +6,7 @@ import { mockOf } from '@votingworks/test-utils'; import { CastVoteRecordExportMetadata, CVR, - unsafeParse, + safeParseJson, } from '@votingworks/types'; import { getTestFilePath } from '../test/utils'; @@ -20,7 +20,7 @@ import { ArtifactAuthenticationConfig } from './config'; jest.mock('@votingworks/types', (): typeof import('@votingworks/types') => ({ ...jest.requireActual('@votingworks/types'), - unsafeParse: jest.fn(), + safeParseJson: jest.fn(), })); /** @@ -46,9 +46,7 @@ let electionPackage: { beforeEach(() => { // Avoid having to prepare a complete CVR.CastVoteRecordReport object for // CastVoteRecordExportMetadata - mockOf(unsafeParse).mockImplementation((_, value) => - JSON.parse(value as string) - ); + mockOf(safeParseJson).mockImplementation((value) => ok(JSON.parse(value))); tempDirectoryPath = dirSync().name; @@ -209,13 +207,9 @@ test.each<{ importingMachineConfig: vxAdminTestConfig, tamperFn: () => { assert(castVoteRecords.artifactToImport.type === 'cast_vote_records'); - const metadataFilePath = path.join( - castVoteRecords.artifactToImport.directoryPath, - 'metadata.json' - ); - const metadataFileContents = fs - .readFileSync(metadataFilePath) - .toString('utf-8'); + const { directoryPath } = castVoteRecords.artifactToImport; + const metadataFilePath = path.join(directoryPath, 'metadata.json'); + const metadataFileContents = fs.readFileSync(metadataFilePath, 'utf-8'); const metadataFileContentsAltered = JSON.stringify({ ...JSON.parse(metadataFileContents), castVoteRecordRootHash: expectedCastVoteRecordRootHash.replace( @@ -226,6 +220,17 @@ test.each<{ fs.writeFileSync(metadataFilePath, metadataFileContentsAltered); }, }, + { + description: 'cast vote records, removed metadata file', + artifactGenerator: () => castVoteRecords, + exportingMachineConfig: vxScanTestConfig, + importingMachineConfig: vxAdminTestConfig, + tamperFn: () => { + assert(castVoteRecords.artifactToImport.type === 'cast_vote_records'); + const { directoryPath } = castVoteRecords.artifactToImport; + fs.rmSync(path.join(directoryPath, 'metadata.json')); + }, + }, { description: 'cast vote records, altered cast vote record file', artifactGenerator: () => castVoteRecords, @@ -308,11 +313,11 @@ test.each<{ // cp and cpSync are experimental so not recommended for use in production but fine for use // in tests fs.cpSync( - path.join(directoryPath, cvrId1), + path.join(directoryPath, cvrId2), path.join(directoryPath, cvrId3), { recursive: true } ); - fs.rmSync(path.join(directoryPath, cvrId1), { recursive: true }); + fs.rmSync(path.join(directoryPath, cvrId2), { recursive: true }); }, }, { @@ -352,3 +357,22 @@ test.each<{ ).toEqual(err(expect.any(Error))); } ); + +test('Error parsing cast vote record export metadata file', async () => { + mockOf(safeParseJson).mockImplementation(() => err(new Error('Whoa!'))); + + const signatureFile = await prepareSignatureFile( + castVoteRecords.artifactToExport, + vxScanTestConfig + ); + fs.writeFileSync( + path.join(tempDirectoryPath, signatureFile.fileName), + signatureFile.fileContents + ); + expect( + await authenticateArtifactUsingSignatureFile( + castVoteRecords.artifactToImport, + vxAdminTestConfig + ) + ).toEqual(err(expect.any(Error))); +}); diff --git a/libs/auth/src/artifact_authentication.ts b/libs/auth/src/artifact_authentication.ts index 12aad70292..707ce3110f 100644 --- a/libs/auth/src/artifact_authentication.ts +++ b/libs/auth/src/artifact_authentication.ts @@ -13,9 +13,8 @@ import { throwIllegalValue, } from '@votingworks/basics'; import { - CastVoteRecordExportMetadata, CastVoteRecordExportMetadataSchema, - unsafeParse, + safeParseJson, } from '@votingworks/types'; import { computeCastVoteRecordRootHashFromScratch } from './cast_vote_record_hashes'; @@ -296,18 +295,26 @@ async function performArtifactSpecificAuthenticationChecks( ): Promise { switch (artifact.type) { case 'cast_vote_records': { - const metadataFileContents = ( - await fs.readFile(path.join(artifact.directoryPath, 'metadata.json')) - ).toString('utf-8'); - const metadata: CastVoteRecordExportMetadata = unsafeParse( - CastVoteRecordExportMetadataSchema, - metadataFileContents + const metadataFileContents = await fs.readFile( + path.join(artifact.directoryPath, 'metadata.json'), + 'utf-8' ); + const parseResult = safeParseJson( + metadataFileContents, + CastVoteRecordExportMetadataSchema + ); + if (parseResult.isErr()) { + throw new Error( + `Error parsing metadata file: ${parseResult.err().message}` + ); + } + const metadata = parseResult.ok(); const castVoteRecordRootHash = await computeCastVoteRecordRootHashFromScratch(artifact.directoryPath); assert( metadata.castVoteRecordRootHash === castVoteRecordRootHash, - "Cast vote record root hash in metadata file doesn't match recomputed hash" + `Cast vote record root hash in metadata file doesn't match recomputed hash: ` + + `${metadata.castVoteRecordRootHash} != ${castVoteRecordRootHash}` ); break; } diff --git a/libs/auth/src/live_check.ts b/libs/auth/src/live_check.ts index b3fcd6c18a..0024841058 100644 --- a/libs/auth/src/live_check.ts +++ b/libs/auth/src/live_check.ts @@ -59,13 +59,12 @@ export class LiveCheck { signingPrivateKey: this.machinePrivateKey, }); - const machineCert = await fs.readFile(this.machineCertPath); + const machineCert = await fs.readFile(this.machineCertPath, 'utf-8'); const qrCodeValueParts: string[] = [ message, messageSignature.toString('base64'), machineCert - .toString('utf-8') // Remove the standard PEM header and footer to make the QR code as small as possible .replace('-----BEGIN CERTIFICATE-----', '') .replace('-----END CERTIFICATE-----', ''), diff --git a/libs/backend/src/cast_vote_records/import.ts b/libs/backend/src/cast_vote_records/import.ts new file mode 100644 index 0000000000..7eaf0fd00a --- /dev/null +++ b/libs/backend/src/cast_vote_records/import.ts @@ -0,0 +1,268 @@ +/* istanbul ignore file */ +import { existsSync } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; +import { authenticateArtifactUsingSignatureFile } from '@votingworks/auth'; +import { + assertDefined, + AsyncIteratorPlus, + err, + iter, + ok, + Result, +} from '@votingworks/basics'; +import { + CastVoteRecordExportMetadata, + CastVoteRecordExportMetadataSchema, + CVR, + safeParseJson, + safeParseNumber, +} from '@votingworks/types'; +import { + BooleanEnvironmentVariableName, + CastVoteRecordWriteIn, + getCurrentSnapshot, + getWriteInsFromCastVoteRecord, + isCastVoteRecordWriteInValid, + isFeatureFlagEnabled, +} from '@votingworks/utils'; + +type ReadCastVoteRecordExportMetadataError = + | { type: 'metadata-file-not-found' } + | { type: 'metadata-file-parse-error' }; + +/** + * An error encountered while reading an individual cast vote record + */ +export type ReadCastVoteRecordError = { type: 'invalid-cast-vote-record' } & ( + | { subType: 'batch-id-not-found' } + | { subType: 'image-file-not-found' } + | { subType: 'invalid-ballot-image-field' } + | { subType: 'invalid-ballot-sheet-id' } + | { subType: 'invalid-write-in-field' } + | { subType: 'layout-file-not-found' } + | { subType: 'no-current-snapshot' } + | { subType: 'parse-error' } +); + +/** + * A top-level error encountered while reading a cast vote record export. Does not include errors + * encountered while reading individual cast vote records. + */ +export type ReadCastVoteRecordExportError = + | ReadCastVoteRecordExportMetadataError + | { type: 'authentication-error' }; + +interface ReferencedFiles { + imageFilePaths: [string, string]; // [front, back] + layoutFilePaths: [string, string]; // [front, back] +} + +interface CastVoteRecordAndReferencedFiles { + castVoteRecord: CVR.CVR; + castVoteRecordBallotSheetId?: number; + castVoteRecordCurrentSnapshot: CVR.CVRSnapshot; + castVoteRecordWriteIns: CastVoteRecordWriteIn[]; + referencedFiles?: ReferencedFiles; +} + +interface CastVoteRecordExportContents { + castVoteRecordExportMetadata: CastVoteRecordExportMetadata; + castVoteRecords: AsyncIteratorPlus< + Result + >; +} + +/** + * Reads and parses a cast vote record export's metadata file. Does *not* authenticate the export + * so should only be used if you're 1) handling authentication elsewhere or 2) using the metadata + * for a non-critical purpose like listing exports in a UI before import. + */ +export async function readCastVoteRecordExportMetadata( + exportDirectoryPath: string +): Promise< + Result +> { + const metadataFilePath = path.join(exportDirectoryPath, 'metadata.json'); + if (!existsSync(metadataFilePath)) { + return err({ type: 'metadata-file-not-found' }); + } + const metadataFileContents = await fs.readFile(metadataFilePath, 'utf-8'); + const parseResult = safeParseJson( + metadataFileContents, + CastVoteRecordExportMetadataSchema + ); + if (parseResult.isErr()) { + return err({ type: 'metadata-file-parse-error' }); + } + return parseResult; +} + +async function* castVoteRecordGenerator( + exportDirectoryPath: string, + batchIds: Set +): AsyncGenerator< + Result +> { + function wrapError( + error: Omit + ): Result { + return err({ ...error, type: 'invalid-cast-vote-record' }); + } + + const castVoteRecordIds = ( + await fs.readdir(exportDirectoryPath, { withFileTypes: true }) + ) + .filter((entry) => entry.isDirectory()) + .map((directory) => directory.name); + + for (const castVoteRecordId of castVoteRecordIds) { + const castVoteRecordDirectoryPath = path.join( + exportDirectoryPath, + castVoteRecordId + ); + const castVoteRecordReport = await fs.readFile( + path.join(castVoteRecordDirectoryPath, 'cast-vote-record-report.json'), + 'utf-8' + ); + const parseResult = safeParseJson( + castVoteRecordReport, + CVR.CastVoteRecordReportSchema + ); + if (parseResult.isErr()) { + yield wrapError({ subType: 'parse-error' }); + return; + } + if (parseResult.ok().CVR?.length !== 1) { + yield wrapError({ subType: 'parse-error' }); + return; + } + const castVoteRecord = assertDefined(parseResult.ok().CVR?.[0]); + + if (!batchIds.has(castVoteRecord.BatchId)) { + yield wrapError({ subType: 'batch-id-not-found' }); + return; + } + + // Only relevant for HMPBs + let castVoteRecordBallotSheetId: number | undefined; + if (castVoteRecord.BallotSheetId) { + const parseBallotSheetIdResult = safeParseNumber( + castVoteRecord.BallotSheetId + ); + if (parseResult.isErr()) { + yield wrapError({ subType: 'invalid-ballot-sheet-id' }); + return; + } + castVoteRecordBallotSheetId = parseBallotSheetIdResult.ok(); + } + + const castVoteRecordCurrentSnapshot = getCurrentSnapshot(castVoteRecord); + if (!castVoteRecordCurrentSnapshot) { + yield wrapError({ subType: 'no-current-snapshot' }); + return; + } + + const castVoteRecordWriteIns = + getWriteInsFromCastVoteRecord(castVoteRecord); + if (!castVoteRecordWriteIns.every(isCastVoteRecordWriteInValid)) { + yield wrapError({ subType: 'invalid-write-in-field' }); + return; + } + + let referencedFiles: ReferencedFiles | undefined; + if (castVoteRecord.BallotImage) { + if ( + castVoteRecord.BallotImage.length !== 2 || + !castVoteRecord.BallotImage[0]?.Location?.startsWith('file:') || + !castVoteRecord.BallotImage[1]?.Location?.startsWith('file:') + ) { + yield wrapError({ subType: 'invalid-ballot-image-field' }); + return; + } + const ballotImageLocations: [string, string] = [ + castVoteRecord.BallotImage[0].Location.replace('file:', ''), + castVoteRecord.BallotImage[1].Location.replace('file:', ''), + ]; + + const imageFilePaths = ballotImageLocations.map((location) => + path.join(castVoteRecordDirectoryPath, location) + ) as [string, string]; + const layoutFilePaths = ballotImageLocations.map((location) => + path.join( + castVoteRecordDirectoryPath, + `${path.parse(location).name}.layout.json` + ) + ) as [string, string]; + + if (!imageFilePaths.every((filePath) => existsSync(filePath))) { + yield wrapError({ subType: 'image-file-not-found' }); + return; + } + if (!layoutFilePaths.every((filePath) => existsSync(filePath))) { + yield wrapError({ subType: 'layout-file-not-found' }); + return; + } + + referencedFiles = { imageFilePaths, layoutFilePaths }; + } + + yield ok({ + castVoteRecord, + castVoteRecordBallotSheetId, + castVoteRecordCurrentSnapshot, + castVoteRecordWriteIns, + referencedFiles, + }); + } +} + +/** + * Reads and parses a cast vote record export, authenticating the export in the process. The + * export's metadata file is parsed upfront whereas the cast vote records are parsed lazily. + * + * Basic validation is performed on the cast vote records. Referenced image files and layout files + * are not read/parsed, but their existence is validated such that consumers can safely access + * them. + */ +export async function readCastVoteRecordExport( + exportDirectoryPath: string +): Promise< + Result +> { + const authenticationResult = await authenticateArtifactUsingSignatureFile({ + type: 'cast_vote_records', + context: 'import', + directoryPath: exportDirectoryPath, + }); + if ( + authenticationResult.isErr() && + !isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_CAST_VOTE_RECORDS_AUTHENTICATION + ) + ) { + return err({ type: 'authentication-error' }); + } + + const metadataResult = await readCastVoteRecordExportMetadata( + exportDirectoryPath + ); + if (metadataResult.isErr()) { + return metadataResult; + } + const castVoteRecordExportMetadata = metadataResult.ok(); + + const batchIds = new Set( + castVoteRecordExportMetadata.castVoteRecordReportMetadata.vxBatch.map( + (batch) => batch['@id'] + ) + ); + const castVoteRecords = iter( + castVoteRecordGenerator(exportDirectoryPath, batchIds) + ); + + return ok({ + castVoteRecordExportMetadata, + castVoteRecords, + }); +} diff --git a/libs/backend/src/cast_vote_records/index.ts b/libs/backend/src/cast_vote_records/index.ts index d7d490e4bc..c1a14fa0e7 100644 --- a/libs/backend/src/cast_vote_records/index.ts +++ b/libs/backend/src/cast_vote_records/index.ts @@ -2,5 +2,6 @@ export { buildCVRContestsFromVotes } from './build_cast_vote_record'; export { buildCastVoteRecordReportMetadata } from './build_report_metadata'; export * from './export'; +export * from './import'; export * from './legacy_export'; export * from './legacy_import'; diff --git a/libs/utils/src/cast_vote_records.ts b/libs/utils/src/cast_vote_records.ts index 4f88aaf34d..811b362094 100644 --- a/libs/utils/src/cast_vote_records.ts +++ b/libs/utils/src/cast_vote_records.ts @@ -1,7 +1,17 @@ -import { assert, Optional } from '@votingworks/basics'; import { + assert, + err, + integers, + ok, + Optional, + Result, + throwIllegalValue, +} from '@votingworks/basics'; +import { + AnyContest, ContestId, ContestOptionId, + Contests, CVR, Side, Tabulation, @@ -121,3 +131,65 @@ export function getWriteInsFromCastVoteRecord( return castVoteRecordWriteIns; } + +/** + * Checks whether a cast vote record write-in is valid. See {@link CastVoteRecordWriteIn} for more + * context. + */ +export function isCastVoteRecordWriteInValid( + cvrWriteIn: CastVoteRecordWriteIn +): boolean { + return Boolean(cvrWriteIn.side || cvrWriteIn.text); +} + +function getValidContestOptions(contest: AnyContest): ContestOptionId[] { + switch (contest.type) { + case 'candidate': + return [ + ...contest.candidates.map((candidate) => candidate.id), + ...integers({ from: 0, through: contest.seats - 1 }) + .map((num) => `write-in-${num}`) + .toArray(), + ]; + case 'yesno': + return [contest.yesOption.id, contest.noOption.id]; + /* c8 ignore next 2 */ + default: + return throwIllegalValue(contest); + } +} + +export type ContestReferenceError = + | 'contest-not-found' + | 'contest-option-not-found'; + +/** + * Checks whether all the contest and contest options referenced in a cast vote record are indeed a + * part of the specified election + */ +export function castVoteRecordHasValidContestReferences( + cvr: CVR.CVR, + electionContests: Contests +): Result { + for (const cvrSnapshot of cvr.CVRSnapshot) { + for (const cvrContest of cvrSnapshot.CVRContest) { + const electionContest = electionContests.find( + (contest) => contest.id === cvrContest.ContestId + ); + if (!electionContest) { + return err('contest-not-found'); + } + + const validContestOptions = new Set( + getValidContestOptions(electionContest) + ); + for (const cvrContestSelection of cvrContest.CVRContestSelection) { + if (!validContestOptions.has(cvrContestSelection.ContestSelectionId)) { + return err('contest-option-not-found'); + } + } + } + } + + return ok(); +} diff --git a/libs/utils/src/filenames.test.ts b/libs/utils/src/filenames.test.ts index 79e1c83fad..de62caec92 100644 --- a/libs/utils/src/filenames.test.ts +++ b/libs/utils/src/filenames.test.ts @@ -20,6 +20,8 @@ import { generateCastVoteRecordReportDirectoryName, parseCastVoteRecordReportDirectoryName, generateCastVoteRecordExportDirectoryName, + CastVoteRecordExportDirectoryNameComponents, + parseCastVoteRecordReportExportDirectoryName, } from './filenames'; describe('parseBallotExportPackageInfoFromFilename', () => { @@ -187,39 +189,6 @@ describe('generateCastVoteRecordReportDirectoryName', () => { }); }); -test.each<{ - inTestMode: boolean; - machineId: string; - time: Date; - expectedDirectoryName: string; -}>([ - { - inTestMode: true, - machineId: 'SCAN-0001', - time: new Date(2023, 7, 16, 17, 2, 24), - expectedDirectoryName: 'TEST__machine_SCAN-0001__2023-08-16_17-02-24', - }, - { - inTestMode: false, - machineId: 'SCAN-0001', - time: new Date(2023, 7, 16, 17, 2, 24), - expectedDirectoryName: 'machine_SCAN-0001__2023-08-16_17-02-24', - }, - { - inTestMode: true, - machineId: '<3-u!n#icorn<3', - time: new Date(2023, 7, 16, 17, 2, 24), - expectedDirectoryName: 'TEST__machine_3unicorn3__2023-08-16_17-02-24', - }, -])( - 'generateCastVoteRecordExportDirectoryName', - ({ expectedDirectoryName, ...input }) => { - expect(generateCastVoteRecordExportDirectoryName(input)).toEqual( - expectedDirectoryName - ); - } -); - test('generates ballot export package names as expected with simple inputs', () => { const mockElection: ElectionDefinition = { election: { @@ -600,3 +569,110 @@ test('generateLogFilename', () => { ) ); }); + +test.each<{ + input: CastVoteRecordExportDirectoryNameComponents; + expectedDirectoryName: string; +}>([ + { + input: { + inTestMode: true, + machineId: 'SCAN-0001', + time: new Date(2023, 7, 16, 17, 2, 24), + }, + expectedDirectoryName: 'TEST__machine_SCAN-0001__2023-08-16_17-02-24', + }, + { + input: { + inTestMode: false, + machineId: 'SCAN-0001', + time: new Date(2023, 7, 16, 17, 2, 24), + }, + expectedDirectoryName: 'machine_SCAN-0001__2023-08-16_17-02-24', + }, + { + input: { + inTestMode: true, + machineId: '<3-u!n#icorn<3', + time: new Date(2023, 7, 16, 17, 2, 24), + }, + expectedDirectoryName: 'TEST__machine_3unicorn3__2023-08-16_17-02-24', + }, +])( + 'generateCastVoteRecordExportDirectoryName', + ({ input, expectedDirectoryName }) => { + expect(generateCastVoteRecordExportDirectoryName(input)).toEqual( + expectedDirectoryName + ); + } +); + +test.each<{ + directoryName: string; + expectedDirectoryNameComponents?: CastVoteRecordExportDirectoryNameComponents; +}>([ + { + directoryName: 'TEST__machine_SCAN-0001__2023-08-16_17-02-24', + expectedDirectoryNameComponents: { + inTestMode: true, + machineId: 'SCAN-0001', + time: new Date(2023, 7, 16, 17, 2, 24), + }, + }, + { + directoryName: 'machine_SCAN-0001__2023-08-16_17-02-24', + expectedDirectoryNameComponents: { + inTestMode: false, + machineId: 'SCAN-0001', + time: new Date(2023, 7, 16, 17, 2, 24), + }, + }, + { + directoryName: + 'TEST__machine_SCAN-0001__2023-08-16_17-02-24__extra-section', + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'machine_SCAN-0001__2023-08-16_17-02-24__extra-section', + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'TEST__machine_SCAN-0001', // Missing time section + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'machine_SCAN-0001', // Missing time section + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'TEST__SCAN-0001__2023-08-16_17-02-24', // Missing machine_ prefix + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'SCAN-0001__2023-08-16_17-02-24', // Missing machine_ prefix + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'TEST__wrong-prefix_SCAN-0001__2023-08-16_17-02-24', + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'wrong-prefix_SCAN-0001__2023-08-16_17-02-24', + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'TEST__machine_SCAN-0001__invalid-time', + expectedDirectoryNameComponents: undefined, + }, + { + directoryName: 'machine_SCAN-0001__invalid-time', + expectedDirectoryNameComponents: undefined, + }, +])( + 'parseCastVoteRecordReportExportDirectoryName', + ({ directoryName, expectedDirectoryNameComponents }) => { + expect(parseCastVoteRecordReportExportDirectoryName(directoryName)).toEqual( + expectedDirectoryNameComponents + ); + } +); diff --git a/libs/utils/src/filenames.ts b/libs/utils/src/filenames.ts index cbf99b4a82..5f9551259b 100644 --- a/libs/utils/src/filenames.ts +++ b/libs/utils/src/filenames.ts @@ -34,6 +34,12 @@ export interface CastVoteRecordReportListing { timestamp: Date; } +export interface CastVoteRecordExportDirectoryNameComponents { + inTestMode: boolean; + machineId: string; + time: Date; +} + export function sanitizeStringForFilename( input: string, { replaceInvalidCharsWith = '', defaultValue = 'placeholder' } = {} @@ -120,30 +126,6 @@ export function generateCastVoteRecordReportDirectoryName( return isTestMode ? `TEST${SECTION_SEPARATOR}${filename}` : filename; } -/** - * Generates a name for a cast vote record export directory - */ -export function generateCastVoteRecordExportDirectoryName({ - inTestMode, - machineId, - time = new Date(), -}: { - inTestMode: boolean; - machineId: string; - time?: Date; -}): string { - const machineString = [ - 'machine', - maybeParse(MachineId, machineId) ?? sanitizeStringForFilename(machineId), - ].join(SUBSECTION_SEPARATOR); - const timeString = moment(time).format(TIME_FORMAT_STRING); - const directoryNameComponents = [machineString, timeString]; - if (inTestMode) { - directoryNameComponents.unshift('TEST'); - } - return directoryNameComponents.join(SECTION_SEPARATOR); -} - /** * Extract information about a CVR file from the filename. Expected filename * format with human-readable separators is: @@ -151,6 +133,8 @@ export function generateCastVoteRecordExportDirectoryName({ * This format is current as of 2023-02-22 and may be out of date if separator constants have changed * * If the the parsing is unsuccessful, returns `undefined`. + * + * @deprecated */ export function parseCastVoteRecordReportDirectoryName( filename: string @@ -326,3 +310,62 @@ export function generateLogFilename( throwIllegalValue(fileType); } } + +/** + * Generates a name for a cast vote record export directory + */ +export function generateCastVoteRecordExportDirectoryName({ + inTestMode, + machineId, + time = new Date(), +}: Omit & { + time?: Date; +}): string { + const machineString = [ + 'machine', + maybeParse(MachineId, machineId) ?? sanitizeStringForFilename(machineId), + ].join(SUBSECTION_SEPARATOR); + const timeString = moment(time).format(TIME_FORMAT_STRING); + const directoryNameComponents = [machineString, timeString]; + if (inTestMode) { + directoryNameComponents.unshift('TEST'); + } + return directoryNameComponents.join(SECTION_SEPARATOR); +} + +/** + * Extracts information about a cast vote record export from the export directory name + */ +export function parseCastVoteRecordReportExportDirectoryName( + exportDirectoryName: string +): Optional { + const sections = exportDirectoryName.split(SECTION_SEPARATOR); + const inTestMode = sections.length === 3 && sections[0] === 'TEST'; + const postTestPrefixSections = inTestMode ? sections.slice(1) : sections; + if (postTestPrefixSections.length !== 2) { + return; + } + assert(postTestPrefixSections[0] !== undefined); + assert(postTestPrefixSections[1] !== undefined); + + const machineString = postTestPrefixSections[0]; + const machineSubsections = machineString.split(SUBSECTION_SEPARATOR); + if (machineSubsections.length !== 2 || machineSubsections[0] !== 'machine') { + return; + } + assert(machineSubsections[1] !== undefined); + const machineId = machineSubsections[1]; + + const timeString = postTestPrefixSections[1]; + const timeMoment = moment(timeString, TIME_FORMAT_STRING); + if (!timeMoment.isValid()) { + return; + } + const time = timeMoment.toDate(); + + return { + inTestMode, + machineId, + time, + }; +}