From dceb6ed8f69afad02faa5c368262891d4d6c0e86 Mon Sep 17 00:00:00 2001 From: "adghayes@gmail.com" Date: Tue, 31 Oct 2023 00:20:25 -0700 Subject: [PATCH] WIP --- apps/admin/backend/src/app.ts | 11 +- .../backend/src/end_to_end_hmpb.test.ts | 30 +- .../e2e/configuration.spec.ts | 14 +- apps/mark-scan/backend/src/app.test.ts | 28 +- apps/mark-scan/backend/test/app_helpers.ts | 14 +- apps/mark/backend/src/app.test.ts | 51 +-- apps/mark/backend/test/app_helpers.ts | 14 +- .../e2e/scroll_buttons.spec.ts | 12 +- apps/scan/backend/src/app_config.test.ts | 71 +-- .../backend/test/helpers/shared_helpers.ts | 9 +- apps/scan/frontend/src/config/globals.ts | 2 - .../ballot_package/ballot_package_io.test.ts | 249 ++++++---- .../src/ballot_package/ballot_package_io.ts | 72 ++- libs/backend/src/ballot_package/test_utils.ts | 23 + .../src/cast_vote_records/test_utils.ts | 5 +- ...rings_machine_configuration_test_runner.ts | 11 +- libs/utils/src/filenames.test.ts | 431 +----------------- libs/utils/src/filenames.ts | 201 +------- 18 files changed, 339 insertions(+), 909 deletions(-) diff --git a/apps/admin/backend/src/app.ts b/apps/admin/backend/src/app.ts index 8ccbcb0a88..a601b22115 100644 --- a/apps/admin/backend/src/app.ts +++ b/apps/admin/backend/src/app.ts @@ -34,6 +34,7 @@ import { createReadStream, createWriteStream, promises as fs } from 'fs'; import path, { join } from 'path'; import { BALLOT_PACKAGE_FOLDER, + generateElectionBasedSubfolderName, generateFilenameForBallotExportPackage, groupMapToGroupList, isIntegrationTest, @@ -221,12 +222,12 @@ function buildApi({ const electionRecord = getCurrentElectionRecord(workspace); assert(electionRecord); const { electionDefinition, id: electionId } = electionRecord; + const { election, electionHash } = electionDefinition; const systemSettings = store.getSystemSettings(electionId); const tempDirectory = dirSync().name; try { const ballotPackageFileName = generateFilenameForBallotExportPackage( - electionDefinition, new Date() ); const tempDirectoryBallotPackageFilePath = join( @@ -255,8 +256,12 @@ function buildApi({ ballotPackageZipStream.finish(); await ballotPackageZipPromise.promise; + const usbDriveBallotPackageDirectoryRelativePath = join( + generateElectionBasedSubfolderName(election, electionHash), + BALLOT_PACKAGE_FOLDER + ); const exportBallotPackageResult = await exporter.exportDataToUsbDrive( - BALLOT_PACKAGE_FOLDER, + usbDriveBallotPackageDirectoryRelativePath, ballotPackageFileName, createReadStream(tempDirectoryBallotPackageFilePath) ); @@ -272,7 +277,7 @@ function buildApi({ filePath: tempDirectoryBallotPackageFilePath, }); const exportSignatureFileResult = await exporter.exportDataToUsbDrive( - BALLOT_PACKAGE_FOLDER, + usbDriveBallotPackageDirectoryRelativePath, signatureFile.fileName, signatureFile.fileContents ); diff --git a/apps/central-scan/backend/src/end_to_end_hmpb.test.ts b/apps/central-scan/backend/src/end_to_end_hmpb.test.ts index 3622aa036f..7a7203a9ef 100644 --- a/apps/central-scan/backend/src/end_to_end_hmpb.test.ts +++ b/apps/central-scan/backend/src/end_to_end_hmpb.test.ts @@ -1,8 +1,8 @@ import getPort from 'get-port'; import { - createBallotPackageZipArchive, getCastVoteRecordExportDirectoryPaths, isTestReport, + mockBallotPackageFileTree, readCastVoteRecordExport, } from '@votingworks/backend'; import { @@ -143,21 +143,19 @@ test('going through the whole process works', async () => { BooleanEnvironmentVariableName.SKIP_BALLOT_PACKAGE_AUTHENTICATION ); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'ballot-package.zip': await createBallotPackageZipArchive( - electionGridLayoutNewHampshireAmherstFixtures.electionJson.toBallotPackage( - { - ...DEFAULT_SYSTEM_SETTINGS, - markThresholds: { - definite: 0.08, - marginal: 0.05, - }, - } - ) - ), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree( + electionGridLayoutNewHampshireAmherstFixtures.electionJson.toBallotPackage( + { + ...DEFAULT_SYSTEM_SETTINGS, + markThresholds: { + definite: 0.08, + marginal: 0.05, + }, + } + ) + ) + ); const configureResult = await apiClient.configureFromBallotPackageOnUsbDrive(); expect(configureResult.err()).toBeUndefined(); diff --git a/apps/central-scan/integration-testing/e2e/configuration.spec.ts b/apps/central-scan/integration-testing/e2e/configuration.spec.ts index 1827f39c62..fdf66b8799 100644 --- a/apps/central-scan/integration-testing/e2e/configuration.spec.ts +++ b/apps/central-scan/integration-testing/e2e/configuration.spec.ts @@ -1,5 +1,5 @@ import test from '@playwright/test'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { getMockFileUsbDriveHandler } from '@votingworks/usb-drive'; import { electionGridLayoutNewHampshireAmherstFixtures } from '@votingworks/fixtures'; import { logInAsElectionManager, forceReset } from './helpers'; @@ -15,13 +15,11 @@ test('configure + scan', async ({ page }) => { await logInAsElectionManager(page, electionDefinition.electionHash); - usbHandler.insert({ - 'ballot-packages': { - 'ballot-package.zip': await createBallotPackageZipArchive( - electionGridLayoutNewHampshireAmherstFixtures.electionJson.toBallotPackage() - ), - }, - }); + usbHandler.insert( + await mockBallotPackageFileTree( + electionGridLayoutNewHampshireAmherstFixtures.electionJson.toBallotPackage() + ) + ); await page.getByText('No ballots have been scanned').waitFor(); usbHandler.remove(); diff --git a/apps/mark-scan/backend/src/app.test.ts b/apps/mark-scan/backend/src/app.test.ts index 00ce9e271d..b56b18dff3 100644 --- a/apps/mark-scan/backend/src/app.test.ts +++ b/apps/mark-scan/backend/src/app.test.ts @@ -12,12 +12,13 @@ import { } from '@votingworks/test-utils'; import { InsertedSmartCardAuthApi } from '@votingworks/auth'; import { + BALLOT_PACKAGE_FOLDER, BooleanEnvironmentVariableName, getFeatureFlagMock, singlePrecinctSelectionFor, } from '@votingworks/utils'; import { Buffer } from 'buffer'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { Server } from 'http'; import * as grout from '@votingworks/grout'; import { @@ -68,17 +69,14 @@ afterEach(() => { async function setUpUsbAndConfigureElection( electionDefinition: ElectionDefinition ) { - const zipBuffer = await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': zipBuffer, - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + systemSettings: safeParseSystemSettings( + systemSettings.asText() + ).unsafeUnwrap(), + }) + ); const writeResult = await apiClient.configureBallotPackageFromUsb(); assert(writeResult.isOk()); @@ -227,8 +225,10 @@ test('configureBallotPackageFromUsb returns an error if ballot package parsing f ); mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': Buffer.from("doesn't matter"), + 'some-election': { + [BALLOT_PACKAGE_FOLDER]: { + 'test-ballot-package.zip': Buffer.from("doesn't matter"), + }, }, }); diff --git a/apps/mark-scan/backend/test/app_helpers.ts b/apps/mark-scan/backend/test/app_helpers.ts index d5d0893d00..b7949039af 100644 --- a/apps/mark-scan/backend/test/app_helpers.ts +++ b/apps/mark-scan/backend/test/app_helpers.ts @@ -7,7 +7,7 @@ import { Application } from 'express'; import { AddressInfo } from 'net'; import { fakeLogger, Logger } from '@votingworks/logging'; import tmp from 'tmp'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { Server } from 'http'; import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import { @@ -128,13 +128,11 @@ export async function configureApp( sessionExpiresAt: fakeSessionExpiresAt(), }) ); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive( - electionJson.toBallotPackage(systemSettings) - ), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree( + electionJson.toBallotPackage(systemSettings) + ) + ); const result = await apiClient.configureBallotPackageFromUsb(); expect(result.isOk()).toEqual(true); mockOf(mockAuth.getAuthStatus).mockImplementation(() => diff --git a/apps/mark/backend/src/app.test.ts b/apps/mark/backend/src/app.test.ts index 41691d8f67..2277c4d99e 100644 --- a/apps/mark/backend/src/app.test.ts +++ b/apps/mark/backend/src/app.test.ts @@ -18,12 +18,13 @@ import { ElectionDefinition, } from '@votingworks/types'; import { + BALLOT_PACKAGE_FOLDER, BooleanEnvironmentVariableName, getFeatureFlagMock, } from '@votingworks/utils'; import { Buffer } from 'buffer'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { Server } from 'http'; import * as grout from '@votingworks/grout'; import { MockUsbDrive } from '@votingworks/usb-drive'; @@ -97,18 +98,15 @@ test('configureBallotPackageFromUsb reads to and writes from store', async () => mockElectionManagerAuth(electionDefinition); - const zipBuffer = await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseJson( - systemSettings.asText(), - SystemSettingsSchema - ).unsafeUnwrap(), - }); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': zipBuffer, - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + systemSettings: safeParseJson( + systemSettings.asText(), + SystemSettingsSchema + ).unsafeUnwrap(), + }) + ); const writeResult = await apiClient.configureBallotPackageFromUsb(); assert(writeResult.isOk()); @@ -126,18 +124,15 @@ test('unconfigureMachine deletes system settings and election definition', async mockElectionManagerAuth(electionDefinition); - const zipBuffer = await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseJson( - systemSettings.asText(), - SystemSettingsSchema - ).unsafeUnwrap(), - }); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': zipBuffer, - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + systemSettings: safeParseJson( + systemSettings.asText(), + SystemSettingsSchema + ).unsafeUnwrap(), + }) + ); const writeResult = await apiClient.configureBallotPackageFromUsb(); assert(writeResult.isOk()); @@ -173,8 +168,10 @@ test('configureBallotPackageFromUsb returns an error if ballot package parsing f ); mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': Buffer.from("doesn't matter"), + 'some-election': { + [BALLOT_PACKAGE_FOLDER]: { + 'test-ballot-package.zip': Buffer.from("doesn't matter"), + }, }, }); diff --git a/apps/mark/backend/test/app_helpers.ts b/apps/mark/backend/test/app_helpers.ts index c3a8aed406..2a3d8865b2 100644 --- a/apps/mark/backend/test/app_helpers.ts +++ b/apps/mark/backend/test/app_helpers.ts @@ -7,7 +7,7 @@ import { Application } from 'express'; import { AddressInfo } from 'net'; import { fakeLogger } from '@votingworks/logging'; import tmp from 'tmp'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import { Server } from 'http'; import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import { @@ -71,13 +71,11 @@ export async function configureApp( sessionExpiresAt: fakeSessionExpiresAt(), }) ); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive( - electionJson.toBallotPackage(systemSettings) - ), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree( + electionJson.toBallotPackage(systemSettings) + ) + ); const result = await apiClient.configureBallotPackageFromUsb(); expect(result.isOk()).toEqual(true); mockOf(mockAuth.getAuthStatus).mockImplementation(() => diff --git a/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts b/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts index 7888236045..e2eada16e7 100644 --- a/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts +++ b/apps/mark/integration-testing/e2e/scroll_buttons.spec.ts @@ -4,7 +4,7 @@ import { electionGeneralJson, } from '@votingworks/fixtures'; import { getMockFileUsbDriveHandler } from '@votingworks/usb-drive'; -import { createBallotPackageZipArchive } from '@votingworks/backend'; +import { mockBallotPackageFileTree } from '@votingworks/backend'; import assert from 'assert'; import { mockCardRemoval, @@ -37,13 +37,9 @@ test('configure, open polls, and test contest scroll buttons', async ({ await enterPin(page); await page.getByText('VxMark is Not Configured').waitFor(); - usbHandler.insert({ - 'ballot-packages': { - 'ballot-package.zip': await createBallotPackageZipArchive( - electionGeneralJson.toBallotPackage() - ), - }, - }); + usbHandler.insert( + await mockBallotPackageFileTree(electionGeneralJson.toBallotPackage()) + ); // Election Manager: set precinct await page.getByText('Precinct', { exact: true }).waitFor(); diff --git a/apps/scan/backend/src/app_config.test.ts b/apps/scan/backend/src/app_config.test.ts index 212e637109..91d90d6d93 100644 --- a/apps/scan/backend/src/app_config.test.ts +++ b/apps/scan/backend/src/app_config.test.ts @@ -13,7 +13,6 @@ import { singlePrecinctSelectionFor, } from '@votingworks/utils'; import { - assert, assertDefined, err, find, @@ -21,16 +20,14 @@ import { ok, unique, } from '@votingworks/basics'; -import fs from 'fs'; -import { join } from 'path'; import { fakeElectionManagerUser, fakeSessionExpiresAt, mockOf, } from '@votingworks/test-utils'; import { - createBallotPackageZipArchive, getCastVoteRecordExportDirectoryPaths, + mockBallotPackageFileTree, readCastVoteRecordExport, } from '@votingworks/backend'; import { InsertedSmartCardAuthApi } from '@votingworks/auth'; @@ -110,7 +107,7 @@ test("fails to configure if there's no ballot package on the usb drive", async ( err('no_ballot_package_on_usb_drive') ); - mockUsbDrive.insertUsbDrive({ 'ballot-packages': {} }); + mockUsbDrive.insertUsbDrive({}); expect(await apiClient.configureFromBallotPackageOnUsbDrive()).toEqual( err('no_ballot_package_on_usb_drive') ); @@ -132,13 +129,11 @@ test('fails to configure ballot package if election definition on card does not mockAuth, electionFamousNames2021Fixtures.electionDefinition ); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition: electionGeneralDefinition, - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition: electionGeneralDefinition, + }) + ); expect(await apiClient.configureFromBallotPackageOnUsbDrive()).toEqual( err('election_hash_mismatch') ); @@ -150,13 +145,11 @@ test("if there's only one precinct in the election, it's selected automatically electionTwoPartyPrimaryFixtures.singlePrecinctElectionDefinition; await withApp({}, async ({ apiClient, mockUsbDrive, mockAuth }) => { mockElectionManager(mockAuth, electionDefinition); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + }) + ); expect(await apiClient.configureFromBallotPackageOnUsbDrive()).toEqual( ok() ); @@ -168,46 +161,6 @@ test("if there's only one precinct in the election, it's selected automatically }); }); -test('configures using the most recently created ballot package on the usb drive', async () => { - await withApp({}, async ({ apiClient, mockUsbDrive, mockAuth }) => { - mockElectionManager(mockAuth, electionGeneralDefinition); - - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'older-ballot-package.zip': await createBallotPackageZipArchive( - electionFamousNames2021Fixtures.electionJson.toBallotPackage() - ), - 'newer-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition: electionGeneralDefinition, - }), - }, - }); - // Ensure our mock actually created the files in the order we expect (the - // order of the keys in the object above) - const usbDrive = await mockUsbDrive.usbDrive.status(); - assert(usbDrive.status === 'mounted'); - const dirPath = join(usbDrive.mountPoint, 'ballot-packages'); - const files = fs.readdirSync(dirPath); - const filesWithStats = files.map((file) => ({ - file, - ...fs.statSync(join(dirPath, file)), - })); - expect(filesWithStats[0].file).toContain('newer-ballot-package.zip'); - expect(filesWithStats[1].file).toContain('older-ballot-package.zip'); - expect(filesWithStats[0].ctime.getTime()).toBeGreaterThan( - filesWithStats[1].ctime.getTime() - ); - - expect(await apiClient.configureFromBallotPackageOnUsbDrive()).toEqual( - ok() - ); - const config = await apiClient.getConfig(); - expect(config.electionDefinition?.election.title).toEqual( - electionGeneralDefinition.election.title - ); - }); -}); - test('continuous CVR export', async () => { await withApp( {}, diff --git a/apps/scan/backend/test/helpers/shared_helpers.ts b/apps/scan/backend/test/helpers/shared_helpers.ts index 4c9cd8a2a9..2ec3ceec1b 100644 --- a/apps/scan/backend/test/helpers/shared_helpers.ts +++ b/apps/scan/backend/test/helpers/shared_helpers.ts @@ -2,7 +2,7 @@ import { InsertedSmartCardAuthApi } from '@votingworks/auth'; import { ok } from '@votingworks/basics'; import { areOrWereCastVoteRecordsBeingExportedToUsbDrive, - createBallotPackageZipArchive, + mockBallotPackageFileTree, } from '@votingworks/backend'; import { electionFamousNames2021Fixtures } from '@votingworks/fixtures'; import * as grout from '@votingworks/grout'; @@ -80,12 +80,7 @@ export async function configureApp( }) ); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': - await createBallotPackageZipArchive(ballotPackage), - }, - }); + mockUsbDrive.insertUsbDrive(await mockBallotPackageFileTree(ballotPackage)); expect(await apiClient.configureFromBallotPackageOnUsbDrive()).toEqual(ok()); diff --git a/apps/scan/frontend/src/config/globals.ts b/apps/scan/frontend/src/config/globals.ts index 6cd7134970..e5916de39e 100644 --- a/apps/scan/frontend/src/config/globals.ts +++ b/apps/scan/frontend/src/config/globals.ts @@ -1,5 +1,3 @@ -export const PRECINCT_SCANNER_FOLDER = 'ballot-packages'; - export const FONT_SIZES = [18, 24, 28, 32]; export const DEFAULT_FONT_SIZE = 1; export const LARGE_DISPLAY_FONT_SIZE = 3; diff --git a/libs/backend/src/ballot_package/ballot_package_io.test.ts b/libs/backend/src/ballot_package/ballot_package_io.test.ts index afd9f0bb29..d8dd7d2803 100644 --- a/libs/backend/src/ballot_package/ballot_package_io.test.ts +++ b/libs/backend/src/ballot_package/ballot_package_io.test.ts @@ -2,6 +2,7 @@ import { fakeLogger } from '@votingworks/logging'; import { DEFAULT_SYSTEM_SETTINGS, InsertedSmartCardAuth, + SystemSettings, safeParseSystemSettings, } from '@votingworks/types'; import { @@ -17,7 +18,9 @@ import { } from '@votingworks/fixtures'; import { assert, err, ok } from '@votingworks/basics'; import { + BALLOT_PACKAGE_FOLDER, BooleanEnvironmentVariableName, + generateElectionBasedSubfolderName, getFeatureFlagMock, } from '@votingworks/utils'; import { authenticateArtifactUsingSignatureFile } from '@votingworks/auth'; @@ -25,7 +28,10 @@ import { join } from 'path'; import * as fs from 'fs'; import { Buffer } from 'buffer'; import { UsbDrive, createMockUsbDrive } from '@votingworks/usb-drive'; -import { createBallotPackageZipArchive } from './test_utils'; +import { + createBallotPackageZipArchive, + mockBallotPackageFileTree, +} from './test_utils'; import { readBallotPackageFromUsb } from './ballot_package_io'; const mockFeatureFlagger = getFeatureFlagMock(); @@ -47,25 +53,20 @@ beforeEach(() => { async function assertFilesCreatedInOrder( usbDrive: UsbDrive, - olderFileName: string, - newerFileName: string + relativeFilePaths: string[] ) { const usbDriveStatus = await usbDrive.status(); assert(usbDriveStatus.status === 'mounted'); // Ensure our mock actually created the files in the order we expect (the // order of the keys in the object above) - const dirPath = join(usbDriveStatus.mountPoint, 'ballot-packages'); - const files = fs.readdirSync(dirPath); - const filesWithStats = files.map((file) => ({ - file, - ...fs.statSync(join(dirPath, file)), - })); - assert(filesWithStats[0] !== undefined && filesWithStats[1] !== undefined); - expect(filesWithStats[0].file).toContain(newerFileName); - expect(filesWithStats[1].file).toContain(olderFileName); - expect(filesWithStats[0].ctime.getTime()).toBeGreaterThan( - filesWithStats[1].ctime.getTime() + const filesWithStats = relativeFilePaths.map((relativeFilePath) => + fs.statSync(join(usbDriveStatus.mountPoint, relativeFilePath)) ); + for (let i = 0; i < filesWithStats.length - 1; i += 1) { + expect(filesWithStats[i]!.ctime.getTime()).toBeLessThan( + filesWithStats[i + 1]!.ctime.getTime() + ); + } } test('readBallotPackageFromUsb can read a ballot package from usb', async () => { @@ -79,16 +80,14 @@ test('readBallotPackageFromUsb can read a ballot package from usb', async () => }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + systemSettings: safeParseSystemSettings( + systemSettings.asText() + ).unsafeUnwrap(), + }) + ); const ballotPackageResult = await readBallotPackageFromUsb( authStatus, @@ -121,13 +120,11 @@ test("readBallotPackageFromUsb uses default system settings when system settings }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition, + }) + ); const ballotPackageResult = await readBallotPackageFromUsb( authStatus, @@ -148,16 +145,9 @@ test('errors if logged-out auth is passed', async () => { }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ electionDefinition }) + ); const logger = fakeLogger(); @@ -185,16 +175,11 @@ test('errors if election hash on provided auth is different than ballot package }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition: otherElectionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ + electionDefinition: otherElectionDefinition, + }) + ); const ballotPackageResult = await readBallotPackageFromUsb( authStatus, @@ -238,15 +223,18 @@ test('errors if a user is authenticated but is not an election manager', async ( }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({}); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree({ electionDefinition }) + ); await expect( readBallotPackageFromUsb(authStatus, mockUsbDrive.usbDrive, fakeLogger()) ).rejects.toThrow('Only election managers may configure a ballot package.'); }); -test('configures using the most recently created ballot package on the usb drive', async () => { +test('configures using the most recently created ballot package for an election', async () => { const { electionDefinition } = electionTwoPartyPrimaryFixtures; + const { election, electionHash } = electionDefinition; const authStatus: InsertedSmartCardAuth.AuthStatus = { status: 'logged_in', user: fakeElectionManagerUser({ @@ -256,23 +244,37 @@ test('configures using the most recently created ballot package on the usb drive }; const mockUsbDrive = createMockUsbDrive(); + const electionDirectory = generateElectionBasedSubfolderName( + election, + electionHash + ); + const specificSystemSettings: SystemSettings = { + ...DEFAULT_SYSTEM_SETTINGS, + auth: { + ...DEFAULT_SYSTEM_SETTINGS.auth, + inactiveSessionTimeLimitMinutes: 25, + overallSessionTimeLimitHours: 11, + numIncorrectPinAttemptsAllowedBeforeCardLockout: 7, + }, + }; mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'older-ballot-package.zip': await createBallotPackageZipArchive( - electionFamousNames2021Fixtures.electionJson.toBallotPackage() - ), - 'newer-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }), + [electionDirectory]: { + [BALLOT_PACKAGE_FOLDER]: { + 'older-ballot-package.zip': await createBallotPackageZipArchive( + electionFamousNames2021Fixtures.electionJson.toBallotPackage() + ), + 'newer-ballot-package.zip': await createBallotPackageZipArchive({ + electionDefinition, + systemSettings: specificSystemSettings, + }), + }, }, }); await assertFilesCreatedInOrder( mockUsbDrive.usbDrive, - 'older-ballot-package.zip', - 'newer-ballot-package.zip' + ['older-ballot-package.zip', 'newer-ballot-package.zip'].map((filename) => + join(electionDirectory, BALLOT_PACKAGE_FOLDER, filename) + ) ); const ballotPackageResult = await readBallotPackageFromUsb( @@ -282,14 +284,74 @@ test('configures using the most recently created ballot package on the usb drive ); assert(ballotPackageResult.isOk()); const ballotPackage = ballotPackageResult.ok(); - expect(ballotPackage.electionDefinition).toEqual(electionDefinition); - expect(ballotPackage.systemSettings).toEqual( - safeParseSystemSettings(systemSettings.asText()).unsafeUnwrap() + // use correct system settings as a proxy for the correct ballot package + expect(ballotPackage.systemSettings).toEqual(specificSystemSettings); +}); + +test('configures using the most recently created ballot package across elections', async () => { + const { electionDefinition } = electionTwoPartyPrimaryFixtures; + const { election, electionHash } = electionDefinition; + + const { electionDefinition: otherElectionDefinition } = + electionFamousNames2021Fixtures; + const { election: otherElection, electionHash: otherElectionHash } = + otherElectionDefinition; + + const authStatus: InsertedSmartCardAuth.AuthStatus = { + status: 'logged_in', + user: fakeElectionManagerUser({ + electionHash: electionDefinition.electionHash, + }), + sessionExpiresAt: fakeSessionExpiresAt(), + }; + + const mockUsbDrive = createMockUsbDrive(); + const electionDirectory = generateElectionBasedSubfolderName( + election, + electionHash + ); + const otherElectionDirectory = generateElectionBasedSubfolderName( + otherElection, + otherElectionHash + ); + mockUsbDrive.insertUsbDrive({ + [otherElectionDirectory]: { + [BALLOT_PACKAGE_FOLDER]: { + 'older-ballot-package.zip': await createBallotPackageZipArchive({ + electionDefinition: otherElectionDefinition, + }), + }, + }, + [electionDirectory]: { + [BALLOT_PACKAGE_FOLDER]: { + 'newer-ballot-package.zip': await createBallotPackageZipArchive({ + electionDefinition, + }), + }, + }, + }); + await assertFilesCreatedInOrder(mockUsbDrive.usbDrive, [ + join( + otherElectionDirectory, + BALLOT_PACKAGE_FOLDER, + 'older-ballot-package.zip' + ), + join(electionDirectory, BALLOT_PACKAGE_FOLDER, 'newer-ballot-package.zip'), + ]); + + const ballotPackageResult = await readBallotPackageFromUsb( + authStatus, + mockUsbDrive.usbDrive, + fakeLogger() ); + assert(ballotPackageResult.isOk()); + const ballotPackage = ballotPackageResult.ok(); + expect(ballotPackage.electionDefinition).toEqual(electionDefinition); }); test('ignores hidden `.`-prefixed files, even if they are newer', async () => { const { electionDefinition } = electionTwoPartyPrimaryFixtures; + const { election, electionHash } = electionDefinition; const authStatus: InsertedSmartCardAuth.AuthStatus = { status: 'logged_in', user: fakeElectionManagerUser({ @@ -299,21 +361,28 @@ test('ignores hidden `.`-prefixed files, even if they are newer', async () => { }; const mockUsbDrive = createMockUsbDrive(); + const electionDirectory = generateElectionBasedSubfolderName( + election, + electionHash + ); mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'older-ballot-package.zip': await createBallotPackageZipArchive({ - electionDefinition, - systemSettings: safeParseSystemSettings( - systemSettings.asText() - ).unsafeUnwrap(), - }), - '._newer-hidden-file-ballot-package.zip': Buffer.from('not a zip file'), + [electionDirectory]: { + [BALLOT_PACKAGE_FOLDER]: { + 'older-ballot-package.zip': await createBallotPackageZipArchive({ + electionDefinition, + systemSettings: safeParseSystemSettings( + systemSettings.asText() + ).unsafeUnwrap(), + }), + '._newer-hidden-file-ballot-package.zip': Buffer.from('not a zip file'), + }, }, }); await assertFilesCreatedInOrder( mockUsbDrive.usbDrive, - 'older-ballot-package.zip', - '._newer-hidden-file-ballot-package.zip' + ['older-ballot-package.zip', '._newer-hidden-file-ballot-package.zip'].map( + (filename) => join(electionDirectory, BALLOT_PACKAGE_FOLDER, filename) + ) ); const ballotPackageResult = await readBallotPackageFromUsb( @@ -342,13 +411,11 @@ test('readBallotPackageFromUsb returns error result if ballot package authentica }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'ballot-package.zip': await createBallotPackageZipArchive( - electionFamousNames2021Fixtures.electionJson.toBallotPackage() - ), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree( + electionFamousNames2021Fixtures.electionJson.toBallotPackage() + ) + ); const ballotPackageResult = await readBallotPackageFromUsb( authStatus, @@ -376,13 +443,11 @@ test('readBallotPackageFromUsb ignores ballot package authentication errors if S }; const mockUsbDrive = createMockUsbDrive(); - mockUsbDrive.insertUsbDrive({ - 'ballot-packages': { - 'ballot-package.zip': await createBallotPackageZipArchive( - electionFamousNames2021Fixtures.electionJson.toBallotPackage() - ), - }, - }); + mockUsbDrive.insertUsbDrive( + await mockBallotPackageFileTree( + electionFamousNames2021Fixtures.electionJson.toBallotPackage() + ) + ); const ballotPackageResult = await readBallotPackageFromUsb( authStatus, diff --git a/libs/backend/src/ballot_package/ballot_package_io.ts b/libs/backend/src/ballot_package/ballot_package_io.ts index 74974d790b..cc81f01cfc 100644 --- a/libs/backend/src/ballot_package/ballot_package_io.ts +++ b/libs/backend/src/ballot_package/ballot_package_io.ts @@ -1,4 +1,4 @@ -import path from 'path'; +import { join } from 'path'; import { Result, assert, err, ok } from '@votingworks/basics'; import { BALLOT_PACKAGE_FOLDER, @@ -7,7 +7,6 @@ import { readBallotPackageFromBuffer, } from '@votingworks/utils'; import * as fs from 'fs/promises'; -import * as fsSync from 'fs'; import { LogEventId, Logger } from '@votingworks/logging'; import { BallotPackage, @@ -24,31 +23,66 @@ async function getMostRecentBallotPackageFilepath( const usbDriveStatus = await usbDrive.status(); assert(usbDriveStatus.status === 'mounted', 'No USB drive mounted'); - const directoryPath = path.join( - usbDriveStatus.mountPoint, - BALLOT_PACKAGE_FOLDER - ); - if (!fsSync.existsSync(directoryPath)) { - return err('no_ballot_package_on_usb_drive'); + // Although not all USB drive root directories are election directories, we + // just check them all. It's not necessary to enforce the naming convention. + const possibleElectionDirectories = ( + await fs.readdir(usbDriveStatus.mountPoint, { + withFileTypes: true, + }) + ).filter((entry) => entry.isDirectory()); + + const electionBallotPackageDirectories: string[] = []; + for (const possibleElectionDirectory of possibleElectionDirectories) { + const hasBallotPackageDirectory = ( + await fs.readdir( + join(usbDriveStatus.mountPoint, possibleElectionDirectory.name), + { + withFileTypes: true, + } + ) + ).some( + (entry) => entry.isDirectory() && entry.name === BALLOT_PACKAGE_FOLDER + ); + + if (hasBallotPackageDirectory) { + electionBallotPackageDirectories.push( + join( + usbDriveStatus.mountPoint, + possibleElectionDirectory.name, + BALLOT_PACKAGE_FOLDER + ) + ); + } } - const files = await fs.readdir(directoryPath, { withFileTypes: true }); - const ballotPackageFiles = files.filter( - (file) => - // Ignore hidden files that start with `.` - file.isFile() && !file.name.startsWith('.') && file.name.endsWith('.zip') - ); - if (ballotPackageFiles.length === 0) { + const ballotPackageFilePaths: string[] = []; + for (const electionBallotPackageDirectory of electionBallotPackageDirectories) { + ballotPackageFilePaths.push( + ...( + await fs.readdir(electionBallotPackageDirectory, { + withFileTypes: true, + }) + ) + .filter( + (file) => + file.isFile() && + file.name.endsWith('.zip') && + // Ignore hidden files that start with `.` + !file.name.startsWith('.') + ) + .map((file) => join(electionBallotPackageDirectory, file.name)) + ); + } + + if (ballotPackageFilePaths.length === 0) { return err('no_ballot_package_on_usb_drive'); } const ballotPackageFilesWithStats = await Promise.all( - ballotPackageFiles.map(async (file) => { - const filePath = path.join(directoryPath, file.name); + ballotPackageFilePaths.map(async (filePath) => { return { - ...file, filePath, - // Include file stats so we can sort by creation time + // Get file stats so we can sort by creation time ...(await fs.lstat(filePath)), }; }) diff --git a/libs/backend/src/ballot_package/test_utils.ts b/libs/backend/src/ballot_package/test_utils.ts index b9eb8b4131..ed6655e5cd 100644 --- a/libs/backend/src/ballot_package/test_utils.ts +++ b/libs/backend/src/ballot_package/test_utils.ts @@ -1,6 +1,11 @@ import JsZip from 'jszip'; import { BallotPackage, BallotPackageFileName } from '@votingworks/types'; import { Buffer } from 'buffer'; +import { + BALLOT_PACKAGE_FOLDER, + generateElectionBasedSubfolderName, +} from '@votingworks/utils'; +import { MockFileTree } from '@votingworks/usb-drive'; /** * Builds a ballot package zip archive from a BallotPackage object. @@ -27,3 +32,21 @@ export function createBallotPackageZipArchive( } return jsZip.generateAsync({ type: 'nodebuffer' }); } + +/** + * Helper for mocking the file contents of on a USB drive with a ballot package + * saved to it. + */ +export async function mockBallotPackageFileTree( + ballotPackage: BallotPackage +): Promise { + const { election, electionHash } = ballotPackage.electionDefinition; + return { + [generateElectionBasedSubfolderName(election, electionHash)]: { + [BALLOT_PACKAGE_FOLDER]: { + 'test-ballot-package.zip': + await createBallotPackageZipArchive(ballotPackage), + }, + }, + }; +} diff --git a/libs/backend/src/cast_vote_records/test_utils.ts b/libs/backend/src/cast_vote_records/test_utils.ts index adec70b5a6..a7691c7789 100644 --- a/libs/backend/src/cast_vote_records/test_utils.ts +++ b/libs/backend/src/cast_vote_records/test_utils.ts @@ -14,7 +14,6 @@ import { } from '@votingworks/types'; import { UsbDrive } from '@votingworks/usb-drive'; import { - BALLOT_PACKAGE_FOLDER, getExportedCastVoteRecordIds, SCANNER_RESULTS_FOLDER, } from '@votingworks/utils'; @@ -144,9 +143,7 @@ export async function getCastVoteRecordExportDirectoryPaths( usbDriveStatus.status === 'mounted' ? usbDriveStatus.mountPoint : undefined; assert(usbMountPoint !== undefined); - const electionDirectoryNames = fs - .readdirSync(usbMountPoint) - .filter((name) => name !== BALLOT_PACKAGE_FOLDER); + const electionDirectoryNames = fs.readdirSync(usbMountPoint); assert(electionDirectoryNames.length === 1); const electionResultsDirectoryPath = path.join( diff --git a/libs/backend/src/ui_strings/ui_strings_machine_configuration_test_runner.ts b/libs/backend/src/ui_strings/ui_strings_machine_configuration_test_runner.ts index 9833ca3133..14c92435e5 100644 --- a/libs/backend/src/ui_strings/ui_strings_machine_configuration_test_runner.ts +++ b/libs/backend/src/ui_strings/ui_strings_machine_configuration_test_runner.ts @@ -10,7 +10,7 @@ import { MockUsbDrive } from '@votingworks/usb-drive'; import { extractCdfUiStrings } from '@votingworks/utils'; import { Result, assertDefined } from '@votingworks/basics'; import { UiStringsStore } from './ui_strings_store'; -import { createBallotPackageZipArchive } from '../ballot_package/test_utils'; +import { mockBallotPackageFileTree } from '../ballot_package/test_utils'; type MockUsbDriveLike = Pick; @@ -37,12 +37,9 @@ export function runUiStringMachineConfigurationTests( ); async function doTestConfigure(usbBallotPackage: BallotPackage) { - getMockUsbDrive().insertUsbDrive({ - 'ballot-packages': { - 'test-ballot-package.zip': - await createBallotPackageZipArchive(usbBallotPackage), - }, - }); + getMockUsbDrive().insertUsbDrive( + await mockBallotPackageFileTree(usbBallotPackage) + ); const result = await runConfigureMachine(); expect(result.err()).toBeUndefined(); diff --git a/libs/utils/src/filenames.test.ts b/libs/utils/src/filenames.test.ts index de62caec92..2c8cfb560d 100644 --- a/libs/utils/src/filenames.test.ts +++ b/libs/utils/src/filenames.test.ts @@ -3,112 +3,18 @@ import { electionGeneralDefinition, electionWithMsEitherNeitherDefinition, } from '@votingworks/fixtures'; -import { Election, ElectionDefinition } from '@votingworks/types'; -import { typedAs } from '@votingworks/basics'; +import { Election } from '@votingworks/types'; import { - parseBallotExportPackageInfoFromFilename, generateElectionBasedSubfolderName, generateFilenameForBallotExportPackage, - generateFinalExportDefaultFilename, - ElectionData, - generateFilenameForBallotExportPackageFromElectionData, - generateBatchResultsDefaultFilename, generateLogFilename, LogFileType, generateSemsFinalExportDefaultFilename, - CastVoteRecordReportListing, - generateCastVoteRecordReportDirectoryName, - parseCastVoteRecordReportDirectoryName, generateCastVoteRecordExportDirectoryName, CastVoteRecordExportDirectoryNameComponents, parseCastVoteRecordReportExportDirectoryName, } from './filenames'; -describe('parseBallotExportPackageInfoFromFilename', () => { - test('parses a basic name properly', () => { - const name = - 'choctaw-county_2020-general-election_a5753d5776__2020-12-02_09-42-50.zip'; - - const parsedInfo = parseBallotExportPackageInfoFromFilename(name); - expect(parsedInfo).toBeTruthy(); - const { electionCounty, electionName, electionHash, timestamp } = - parsedInfo!; - expect(electionCounty).toEqual('choctaw county'); - expect(electionName).toEqual('2020 general election'); - expect(electionHash).toEqual('a5753d5776'); - expect(timestamp).toStrictEqual(new Date(2020, 11, 2, 9, 42, 50)); - }); - - test('fails to parse a name with the section separator twice', () => { - const name = - 'choctaw-county_2020-general-election__a5753d5776__2020-12-02_09-42-50.zip'; - - expect(parseBallotExportPackageInfoFromFilename(name)).toBeUndefined(); - }); - - test('fails to parse a name with a bad election string', () => { - const name = - 'choctaw-county_2020-general_election_a5753d5776__2020-12-02_09-42-50.zip'; - - expect(parseBallotExportPackageInfoFromFilename(name)).toBeUndefined(); - }); - - test('fails to parse a name with no section separator', () => { - expect(parseBallotExportPackageInfoFromFilename('string')).toBeUndefined(); - }); - - test('fails to parse a name without all election segments', () => { - const name = 'string__2020-12-02_09-42-50.zip'; - expect(parseBallotExportPackageInfoFromFilename(name)).toBeUndefined(); - }); - - test('uses placeholders for parts that get sanitized away', () => { - const timestamp = new Date(2020, 3, 14); - expect( - generateFilenameForBallotExportPackageFromElectionData({ - electionCounty: '!!!', // sanitized as empty string - electionHash: 'abcdefg', - electionName: '???', // sanitized as empty string - timestamp, - }) - ).toEqual('county_election_abcdefg__2020-04-14_00-00-00.zip'); - }); - - test('works end to end when generating ballot package name', () => { - const time = new Date(2020, 3, 14); - expect( - parseBallotExportPackageInfoFromFilename( - generateFilenameForBallotExportPackage(electionGeneralDefinition, time) - ) - ).toEqual({ - electionCounty: - electionGeneralDefinition.election.county.name.toLocaleLowerCase(), - electionName: - electionGeneralDefinition.election.title.toLocaleLowerCase(), - electionHash: electionGeneralDefinition.electionHash.slice(0, 10), - timestamp: time, - }); - expect( - parseBallotExportPackageInfoFromFilename( - generateFilenameForBallotExportPackage( - electionWithMsEitherNeitherDefinition, - time - ) - ) - ).toEqual({ - electionCounty: - electionWithMsEitherNeitherDefinition.election.county.name.toLocaleLowerCase(), - electionName: - electionWithMsEitherNeitherDefinition.election.title.toLocaleLowerCase(), - electionHash: electionWithMsEitherNeitherDefinition.electionHash.slice( - 0, - 10 - ), - timestamp: time, - }); - }); -}); - describe('generateElectionBasedSubfolderName', () => { test('generates basic election subfolder name as expected', () => { const mockElection: Election = { @@ -155,104 +61,10 @@ describe('generateElectionBasedSubfolderName', () => { }); }); -describe('generateCastVoteRecordReportDirectoryName', () => { - test('generates basic scanning results filename in test mode', () => { - const time = new Date(2019, 2, 14, 15, 9, 26); - expect( - generateCastVoteRecordReportDirectoryName('1', 0, true, time) - ).toEqual('TEST__machine_1__0_ballots__2019-03-14_15-09-26'); - - expect( - generateCastVoteRecordReportDirectoryName('po!n@y:__', 35, true, time) - ).toEqual('TEST__machine_pony__35_ballots__2019-03-14_15-09-26'); - }); - - test('generates basic scanning results filename not in test mode', () => { - const time = new Date(2019, 2, 14, 15, 9, 26); - expect( - generateCastVoteRecordReportDirectoryName('1', 0, false, time) - ).toEqual('machine_1__0_ballots__2019-03-14_15-09-26'); - expect( - generateCastVoteRecordReportDirectoryName( - '<3-u!n#icorn<3', - 1, - false, - time - ) - ).toEqual('machine_3unicorn3__1_ballot__2019-03-14_15-09-26'); - }); - - test('generates basic scanning results filename with default time', () => { - expect(generateCastVoteRecordReportDirectoryName('1', 0, false)).toEqual( - expect.stringMatching('machine_1__0_ballots__') - ); - }); -}); - -test('generates ballot export package names as expected with simple inputs', () => { - const mockElection: ElectionDefinition = { - election: { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County', id: '' }, - title: 'General Election', - }, - electionHash: 'testHash12', - electionData: '', - }; - const time = new Date(2019, 2, 14, 15, 9, 26); - expect(generateFilenameForBallotExportPackage(mockElection, time)).toEqual( - 'king-county_general-election_testHash12__2019-03-14_15-09-26.zip' - ); -}); - -test('generates ballot export package names as expected when election information has weird characters', () => { - const mockElection: ElectionDefinition = { - election: { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County!!', id: '' }, - title: '-_General__Election$$', - }, - electionHash: 'testHash12', - electionData: '', - }; - const time = new Date(2019, 2, 14, 15, 9, 26); - expect(generateFilenameForBallotExportPackage(mockElection, time)).toEqual( - 'king-county_general-election_testHash12__2019-03-14_15-09-26.zip' - ); - expect(generateFilenameForBallotExportPackage(mockElection)).toEqual( - expect.stringMatching('king-county_general-election_testHash12__') - ); -}); - -test('generates ballot export package name with truncated election hash', () => { - const mockElection: ElectionDefinition = { - election: { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County', id: '' }, - title: 'General Election', - }, - electionHash: 'testHash123456789', - electionData: '', - }; - const time = new Date(2019, 2, 14, 15, 9, 26); - expect(generateFilenameForBallotExportPackage(mockElection, time)).toEqual( - 'king-county_general-election_testHash12__2019-03-14_15-09-26.zip' - ); -}); - test('generates ballot export package name with zero padded time pieces', () => { - const mockElection: ElectionDefinition = { - election: { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County', id: '' }, - title: 'General Election', - }, - electionHash: 'testHash12', - electionData: '', - }; const time = new Date(2019, 2, 1, 1, 9, 2); - expect(generateFilenameForBallotExportPackage(mockElection, time)).toEqual( - 'king-county_general-election_testHash12__2019-03-01_01-09-02.zip' + expect(generateFilenameForBallotExportPackage(time)).toEqual( + 'ballot-package__2019-03-01_01-09-02.zip' ); }); @@ -291,167 +103,6 @@ describe('generateSemsFinalExportDefaultFilename', () => { }); }); -describe('generateFinalExportDefaultFilename', () => { - test('generates the correct filename for test mode', () => { - const mockElection: Election = { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County', id: '' }, - title: 'General Election', - }; - const time = new Date(2019, 2, 1, 1, 9, 2); - expect( - generateFinalExportDefaultFilename(true, mockElection, time) - ).toEqual( - 'votingworks-test-results_king-county_general-election_2019-03-01_01-09-02.csv' - ); - }); - - test('generates the correct filename for live mode', () => { - const time = new Date(2019, 2, 1, 1, 9, 2); - const mockElection: Election = { - ...electionWithMsEitherNeitherDefinition.election, - county: { name: 'King County', id: '' }, - title: 'General Election', - }; - expect( - generateFinalExportDefaultFilename(false, mockElection, time) - ).toEqual( - 'votingworks-live-results_king-county_general-election_2019-03-01_01-09-02.csv' - ); - expect(generateFinalExportDefaultFilename(false, mockElection)).toEqual( - expect.stringMatching( - 'votingworks-live-results_king-county_general-election' - ) - ); - }); -}); - -describe('parseCastVoteRecordReportDirectoryName', () => { - test('parses a basic name not in test mode properly', () => { - const name = 'machine_5__1_ballots__2020-12-08_10-42-02'; - const results = parseCastVoteRecordReportDirectoryName(name); - expect(results).toEqual({ - isTestModeResults: false, - machineId: '5', - numberOfBallots: 1, - timestamp: new Date(2020, 11, 8, 10, 42, 2), - }); - }); - - test('parses a basic name in test mode properly', () => { - const name = 'TEST__machine_0002__54_ballots__2020-12-08_10-42-02'; - const results = parseCastVoteRecordReportDirectoryName(name); - expect(results).toEqual({ - isTestModeResults: true, - machineId: '0002', - numberOfBallots: 54, - timestamp: new Date(2020, 11, 8, 10, 42, 2), - }); - }); - - test('undefined when the format of the filename is unexpected', () => { - expect( - parseCastVoteRecordReportDirectoryName( - 'INVALID__machine_0002__54_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - '__machine_0002__54_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__machine_0002__54_ballots__bad_timestamp' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__machine_0002__blah_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'machine_0002__54_ballots__2020-12-08__10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__something__machine_0002__54_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__unicorn_0002__54_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__machine_0002__54_puppies__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__machine_0002__54__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - expect( - parseCastVoteRecordReportDirectoryName( - 'TEST__0002__54_ballots__2020-12-08_10-42-02' - ) - ).toBeUndefined(); - }); - - test('works end to end with generating the report directory name', () => { - const time = new Date(2020, 3, 14); - const generatedName = generateCastVoteRecordReportDirectoryName( - 'machine', - 1234, - true, - time - ); - expect(parseCastVoteRecordReportDirectoryName(generatedName)).toEqual({ - machineId: 'machine', - numberOfBallots: 1234, - isTestModeResults: true, - timestamp: time, - }); - - const generatedName2 = generateCastVoteRecordReportDirectoryName( - '0004', - 0, - false, - time - ); - expect(parseCastVoteRecordReportDirectoryName(generatedName2)).toEqual({ - machineId: '0004', - numberOfBallots: 0, - isTestModeResults: false, - timestamp: time, - }); - - const generatedName3 = generateCastVoteRecordReportDirectoryName( - '0004', - 1, - false, - time - ); - expect(parseCastVoteRecordReportDirectoryName(generatedName3)).toEqual({ - machineId: '0004', - numberOfBallots: 1, - isTestModeResults: false, - timestamp: time, - }); - }); -}); - -function arbitraryMachineId(): fc.Arbitrary { - return fc.stringOf( - fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-'), - { minLength: 1 } - ); -} - function arbitraryTimestampDate(): fc.Arbitrary { return ( fc @@ -465,35 +116,6 @@ function arbitraryTimestampDate(): fc.Arbitrary { ); } -function arbitraryCastVoteRecordReportListing(): fc.Arbitrary { - return fc.record({ - machineId: arbitraryMachineId(), - numberOfBallots: fc.nat(), - isTestModeResults: fc.boolean(), - timestamp: arbitraryTimestampDate(), - }); -} - -test('generate/parse CVR file fuzzing', () => { - fc.assert( - fc.property( - arbitraryCastVoteRecordReportListing(), - (castVoteRecordReportListing) => { - expect( - parseCastVoteRecordReportDirectoryName( - generateCastVoteRecordReportDirectoryName( - castVoteRecordReportListing.machineId, - castVoteRecordReportListing.numberOfBallots, - castVoteRecordReportListing.isTestModeResults, - castVoteRecordReportListing.timestamp - ) - ) - ).toEqual(castVoteRecordReportListing); - } - ) - ); -}); - function arbitrarySafeName(): fc.Arbitrary { return fc .stringOf( @@ -506,53 +128,6 @@ function arbitrarySafeName(): fc.Arbitrary { .filter((s) => s.length >= 1); } -function arbitraryElectionData(): fc.Arbitrary { - return fc.record({ - electionCounty: arbitrarySafeName(), - electionName: arbitrarySafeName(), - electionHash: fc.hexaString({ minLength: 20, maxLength: 20 }), - timestamp: arbitraryTimestampDate(), - }); -} - -test('generate/parse ballot package filename fuzzing', () => { - fc.assert( - fc.property(arbitraryElectionData(), (electionData) => { - expect( - parseBallotExportPackageInfoFromFilename( - generateFilenameForBallotExportPackageFromElectionData(electionData) - ) - ).toEqual( - typedAs({ - electionCounty: electionData.electionCounty.toLocaleLowerCase(), - electionHash: electionData.electionHash - .toLocaleLowerCase() - .slice(0, 10), - electionName: electionData.electionName.toLocaleLowerCase(), - timestamp: electionData.timestamp, - }) - ); - }) - ); -}); - -test('generateBatchResultsDefaultFilename', () => { - fc.assert( - fc.property( - fc.boolean(), - fc.constant(electionGeneralDefinition.election), - fc.oneof(fc.constant(undefined), arbitraryTimestampDate()), - (isTestModeResults, election, time) => { - expect( - generateBatchResultsDefaultFilename(isTestModeResults, election, time) - ).toMatch( - /^votingworks-(test|live)-batch-results_franklin-county_general-election_\d\d\d\d-\d\d-\d\d_\d\d-\d\d-\d\d.csv$/ - ); - } - ) - ); -}); - test('generateLogFilename', () => { fc.assert( fc.property( diff --git a/libs/utils/src/filenames.ts b/libs/utils/src/filenames.ts index 4ec397f0a2..ea31e02d0b 100644 --- a/libs/utils/src/filenames.ts +++ b/libs/utils/src/filenames.ts @@ -1,13 +1,6 @@ import moment from 'moment'; -import { - Election, - ElectionDefinition, - MachineId, - maybeParse, - safeParseNumber, -} from '@votingworks/types'; +import { Election, MachineId, maybeParse } from '@votingworks/types'; import { assert, Optional, throwIllegalValue } from '@votingworks/basics'; -import { basename } from 'path'; const SECTION_SEPARATOR = '__'; const SUBSECTION_SEPARATOR = '_'; @@ -24,13 +17,6 @@ export const TEST_FILE_PREFIX = 'TEST'; */ export const CAST_VOTE_RECORD_REPORT_FILENAME = 'cast-vote-record-report.json'; -export interface ElectionData { - electionCounty: string; - electionName: string; - electionHash: string; - timestamp: Date; -} - export interface CastVoteRecordReportListing { machineId: string; numberOfBallots: number; @@ -55,41 +41,6 @@ export function sanitizeStringForFilename( return sanitized.trim().length === 0 ? defaultValue : sanitized; } -/** - * Convert an auto-generated name of the ballot configuration package zip archive - * to the pieces of data contained in the name. - */ -export function parseBallotExportPackageInfoFromFilename( - filename: string -): ElectionData | undefined { - // There should be two underscores separating the timestamp from the election information - const segments = filename.split(SECTION_SEPARATOR); - if (segments.length !== 2) { - return; - } - - const [electionString, timeString] = segments; - assert(typeof electionString !== 'undefined'); - - let electionSegments = electionString.split(SUBSECTION_SEPARATOR); - if (electionSegments.length !== 3) { - return; - } - electionSegments = electionSegments.map((s) => s.replace(/-/g, ' ')); - const [electionCounty, electionName, electionHash] = electionSegments; - assert(typeof electionCounty !== 'undefined'); - assert(typeof electionName !== 'undefined'); - assert(typeof electionHash !== 'undefined'); - - const parsedTime = moment(timeString, TIME_FORMAT_STRING); - return { - electionCounty, - electionName, - electionHash, - timestamp: parsedTime.toDate(), - }; -} - export function generateElectionBasedSubfolderName( election: Election, electionHash: string @@ -108,88 +59,6 @@ export function generateElectionBasedSubfolderName( )}`; } -/** - * Generate the directory name for the cast vote record report. - * - * @deprecated - */ -export function generateCastVoteRecordReportDirectoryName( - machineId: string, - numBallotsScanned: number, - isTestMode: boolean, - time: Date = new Date() -): string { - const machineString = `machine${SUBSECTION_SEPARATOR}${ - maybeParse(MachineId, machineId) ?? sanitizeStringForFilename(machineId) - }`; - const ballotString = `${numBallotsScanned}${SUBSECTION_SEPARATOR}${ - numBallotsScanned === 1 ? 'ballot' : 'ballots' - }`; - const timeInformation = moment(time).format(TIME_FORMAT_STRING); - const filename = `${machineString}${SECTION_SEPARATOR}${ballotString}${SECTION_SEPARATOR}${timeInformation}`; - return isTestMode - ? `${TEST_FILE_PREFIX}${SECTION_SEPARATOR}${filename}` - : filename; -} - -/** - * Extract information about a CVR file from the filename. Expected filename - * format with human-readable separators is: - * [TEST__]machine_{machineId}__{numberOfBallots}_ballots__YYYY-MM-DD_HH-mm-ss.jsonl - * 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 -): Optional { - // TODO: no need to ignore extension once we don't accept .jsonl - const fileBasename = basename(filename); - const segments = fileBasename.split(SECTION_SEPARATOR); - const isTestModeResults = - segments.length === 4 && segments[0] === TEST_FILE_PREFIX; - - const postTestPrefixSegments = isTestModeResults - ? segments.slice(1) - : segments; - if (postTestPrefixSegments.length !== 3) { - return; - } - // extract machine id - assert(typeof postTestPrefixSegments[0] !== 'undefined'); - const machineSegments = postTestPrefixSegments[0].split(SUBSECTION_SEPARATOR); - if (machineSegments.length !== 2 || machineSegments[0] !== 'machine') { - return; - } - const machineId = machineSegments[1]; - assert(typeof machineId !== 'undefined'); - - // extract number of ballots - assert(typeof postTestPrefixSegments[1] !== 'undefined'); - const ballotSegments = postTestPrefixSegments[1].split(SUBSECTION_SEPARATOR); - if ( - ballotSegments.length !== 2 || - (ballotSegments[1] !== 'ballots' && ballotSegments[1] !== 'ballot') - ) { - return; - } - const numberOfBallotsParseResult = safeParseNumber(ballotSegments[0]); - if (numberOfBallotsParseResult.isErr()) return; - - // extract timestamp - const timestampMoment = moment(postTestPrefixSegments[2], TIME_FORMAT_STRING); - if (!timestampMoment.isValid()) return; - - return { - machineId, - numberOfBallots: numberOfBallotsParseResult.ok(), - isTestModeResults, - timestamp: timestampMoment.toDate(), - }; -} - /* Get the name of an election to use in a filename from the Election object */ function generateElectionName(election: Election): string { const electionCountyName = sanitizeStringForFilename(election.county.name, { @@ -203,60 +72,12 @@ function generateElectionName(election: Election): string { return `${electionCountyName}${SUBSECTION_SEPARATOR}${electionTitle}`; } -export function getElectionDataFromElectionDefinition( - electionDefinition: ElectionDefinition, - timestamp: Date -): ElectionData { - return { - electionCounty: electionDefinition.election.county.name, - electionHash: electionDefinition.electionHash, - electionName: electionDefinition.election.title, - timestamp, - }; -} - -export function generateFilenameForBallotExportPackageFromElectionData({ - electionName, - electionCounty, - electionHash, - timestamp, -}: ElectionData): string { - const electionCountyName = sanitizeStringForFilename(electionCounty, { - replaceInvalidCharsWith: WORD_SEPARATOR, - defaultValue: 'county', - }); - const electionTitle = sanitizeStringForFilename(electionName, { - replaceInvalidCharsWith: WORD_SEPARATOR, - defaultValue: 'election', - }); - const electionInformation = `${electionCountyName}${SUBSECTION_SEPARATOR}${electionTitle}${SUBSECTION_SEPARATOR}${electionHash.slice( - 0, - 10 - )}`; - const timeInformation = moment(timestamp).format(TIME_FORMAT_STRING); - return `${electionInformation}${SECTION_SEPARATOR}${timeInformation}.zip`; -} - /* Generate the name for a ballot export package */ export function generateFilenameForBallotExportPackage( - electionDefinition: ElectionDefinition, - time: Date = new Date() -): string { - return generateFilenameForBallotExportPackageFromElectionData( - getElectionDataFromElectionDefinition(electionDefinition, time) - ); -} - -/* Generate the filename for final results export from election manager */ -export function generateFinalExportDefaultFilename( - isTestModeResults: boolean, - election: Election, time: Date = new Date() ): string { - const filemode = isTestModeResults ? 'test' : 'live'; const timeInformation = moment(time).format(TIME_FORMAT_STRING); - const electionName = generateElectionName(election); - return `votingworks${WORD_SEPARATOR}${filemode}${WORD_SEPARATOR}results${SUBSECTION_SEPARATOR}${electionName}${SUBSECTION_SEPARATOR}${timeInformation}.csv`; + return `ballot-package${SECTION_SEPARATOR}${timeInformation}.zip`; } /* Generate the filename for final sems results export from election manager */ @@ -271,24 +92,6 @@ export function generateSemsFinalExportDefaultFilename( return `votingworks${WORD_SEPARATOR}sems${WORD_SEPARATOR}${filemode}${WORD_SEPARATOR}results${SUBSECTION_SEPARATOR}${electionName}${SUBSECTION_SEPARATOR}${timeInformation}.txt`; } -/** - * Generates a filename for the tally results CSV broken down by batch. - * @param isTestModeResults Boolean representing if the results are testmode or livemode - * @param election Election object we are generating the filename for - * @param time Optional for the time we are generating the filename, defaults to the current time. - * @returns string filename i.e. "votingworks-live-batch-results_election-name_timestamp.csv" - */ -export function generateBatchResultsDefaultFilename( - isTestModeResults: boolean, - election: Election, - time: Date = new Date() -): string { - const filemode = isTestModeResults ? 'test' : 'live'; - const timeInformation = moment(time).format(TIME_FORMAT_STRING); - const electionName = generateElectionName(election); - return `votingworks${WORD_SEPARATOR}${filemode}${WORD_SEPARATOR}batch-results${SUBSECTION_SEPARATOR}${electionName}${SUBSECTION_SEPARATOR}${timeInformation}.csv`; -} - /* Describes different formats of the log file. */ export enum LogFileType { Raw = 'raw',