-
Notifications
You must be signed in to change notification settings - Fork 7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Deprecate VxScan's existing backup functionality in favor of "extended" CVR exports #4011
Merged
Merged
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
f01ae9a
Prep for addition of rejected-<id>/ sub-directories in CVR exports
arsalansufi 0e8d42c
Clean up image reference logic in buildCastVoteRecord fn, specifically:
arsalansufi da36836
Update buildCastVoteRecord tests
arsalansufi 4bc4f60
Update exportCastVoteRecordsToUsbDrive to include rejected sheets
arsalansufi fb2ea1c
Update VxScan backend store, specifically:
arsalansufi c30f068
Update VxScan backend store tests
arsalansufi a74a6ef
Update VxScan state machine to export rejected sheets
arsalansufi 455d5fc
Update rest of VxScan backend to account for store changes
arsalansufi f4537fa
Update remaining VxScan backend tests
arsalansufi 7dcf49a
Update VxScan backend code coverage thresholds
arsalansufi 64f051f
Remove now unneeded "Save Backup" button from VxScan UI
arsalansufi fb4deb7
Update VxScan frontend tests
arsalansufi 3856417
Minimally update the VxCentralScan backend
arsalansufi 8f7d6e1
Don't check whether USB requires CVR sync after polls have been closed
arsalansufi 9615ad3
Block relevant actions when a CVR sync is detected as necessary
arsalansufi 4e2090f
Update/add tests
arsalansufi 6731e39
Add a few more tests
arsalansufi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,7 +20,6 @@ import { | |
} from '@votingworks/basics'; | ||
import { | ||
BallotIdSchema, | ||
BallotPageLayout, | ||
BatchInfo, | ||
CastVoteRecordExportFileName, | ||
CastVoteRecordExportMetadata, | ||
|
@@ -38,7 +37,7 @@ import { | |
BooleanEnvironmentVariableName, | ||
generateCastVoteRecordExportDirectoryName, | ||
generateElectionBasedSubfolderName, | ||
getExportedCastVoteRecordIds, | ||
getCastVoteRecordExportSubDirectoryNames, | ||
isFeatureFlagEnabled, | ||
SCANNER_RESULTS_FOLDER, | ||
} from '@votingworks/utils'; | ||
|
@@ -112,9 +111,10 @@ interface ExportContext { | |
} | ||
|
||
/** | ||
* A sheet to be included in a CVR export | ||
* A scanned sheet that was accepted and should be tabulated | ||
*/ | ||
export interface ResultSheet { | ||
export interface AcceptedSheet { | ||
readonly type: 'accepted'; | ||
readonly id: Id; | ||
readonly batchId: Id; | ||
readonly interpretation: SheetOf<PageInterpretation>; | ||
|
@@ -133,6 +133,21 @@ export interface ResultSheet { | |
readonly batchLabel?: string; | ||
} | ||
|
||
/** | ||
* A scanned sheet that was rejected | ||
*/ | ||
export interface RejectedSheet { | ||
readonly type: 'rejected'; | ||
readonly id: Id; | ||
readonly frontImagePath: string; | ||
readonly backImagePath: string; | ||
} | ||
|
||
/** | ||
* A scanned sheet, accepted or rejected | ||
*/ | ||
export type Sheet = AcceptedSheet | RejectedSheet; | ||
|
||
/** | ||
* An error encountered while exporting cast vote records to a USB drive | ||
*/ | ||
|
@@ -238,7 +253,7 @@ function buildCastVoteRecordReportMetadata( | |
|
||
function buildCastVoteRecord( | ||
exportContext: ExportContext, | ||
resultSheet: ResultSheet, | ||
sheet: AcceptedSheet, | ||
canonicalizedSheet: CanonicalizedSheet | ||
): CVR.CVR { | ||
const { scannerState } = exportContext; | ||
|
@@ -247,11 +262,16 @@ function buildCastVoteRecord( | |
const electionOptionPositionMap = buildElectionOptionPositionMap(election); | ||
const scannerId = VX_MACHINE_ID; | ||
|
||
const { id, batchId, indexInBatch } = resultSheet; | ||
const { id, batchId, indexInBatch } = sheet; | ||
const castVoteRecordId = | ||
(canonicalizedSheet.type === 'bmd' && | ||
canonicalizedSheet.interpretation.ballotId) || | ||
unsafeParse(BallotIdSchema, id); | ||
const [frontImageFilePath, backImageFilePath] = canonicalizedSheet.filenames; | ||
const imageFileUris: SheetOf<string> = [ | ||
`file:${path.basename(frontImageFilePath)}`, | ||
`file:${path.basename(backImageFilePath)}`, | ||
]; | ||
|
||
// BMD ballot | ||
if (canonicalizedSheet.type === 'bmd') { | ||
|
@@ -262,24 +282,15 @@ function buildCastVoteRecord( | |
election, | ||
electionId, | ||
electionOptionPositionMap, | ||
indexInBatch, | ||
imageFileUris, | ||
interpretation: canonicalizedSheet.interpretation, | ||
scannerId, | ||
}); | ||
} | ||
|
||
// HMPB ballot | ||
const [frontFileName, backFileName] = canonicalizedSheet.filenames; | ||
const [frontInterpretation, backInterpretation] = | ||
canonicalizedSheet.interpretation; | ||
const frontPage = { | ||
imageFileUri: `file:${path.basename(frontFileName)}`, | ||
interpretation: frontInterpretation, | ||
} as const; | ||
const backPage = { | ||
imageFileUri: `file:${path.basename(backFileName)}`, | ||
interpretation: backInterpretation, | ||
} as const; | ||
return baseBuildCastVoteRecord({ | ||
ballotMarkingMode: 'hand', | ||
batchId, | ||
|
@@ -291,8 +302,9 @@ function buildCastVoteRecord( | |
election, | ||
electionId, | ||
electionOptionPositionMap, | ||
imageFileUris, | ||
indexInBatch, | ||
pages: [frontPage, backPage], | ||
interpretations: [frontInterpretation, backInterpretation], | ||
scannerId, | ||
}); | ||
} | ||
|
@@ -309,7 +321,7 @@ function buildCastVoteRecord( | |
*/ | ||
async function exportCastVoteRecordFilesToUsbDrive( | ||
exportContext: ExportContext, | ||
resultSheet: ResultSheet, | ||
sheet: AcceptedSheet, | ||
exportDirectoryPathRelativeToUsbMountPoint: string | ||
): Promise< | ||
Result< | ||
|
@@ -319,10 +331,10 @@ async function exportCastVoteRecordFilesToUsbDrive( | |
> { | ||
const { exporter } = exportContext; | ||
|
||
const canonicalizeSheetResult = canonicalizeSheet( | ||
resultSheet.interpretation, | ||
[resultSheet.frontImagePath, resultSheet.backImagePath] | ||
); | ||
const canonicalizeSheetResult = canonicalizeSheet(sheet.interpretation, [ | ||
sheet.frontImagePath, | ||
sheet.backImagePath, | ||
]); | ||
if (canonicalizeSheetResult.isErr()) { | ||
return err({ | ||
type: 'invalid-sheet-found', | ||
|
@@ -339,7 +351,7 @@ async function exportCastVoteRecordFilesToUsbDrive( | |
); | ||
const castVoteRecord = buildCastVoteRecord( | ||
exportContext, | ||
resultSheet, | ||
sheet, | ||
canonicalizedSheet | ||
); | ||
const castVoteRecordId = castVoteRecord.UniqueId; | ||
|
@@ -348,37 +360,30 @@ async function exportCastVoteRecordFilesToUsbDrive( | |
CVR: [castVoteRecord], | ||
}; | ||
|
||
const [frontImageFilePath, backImageFilePath] = canonicalizedSheet.filenames; | ||
const frontLayout: BallotPageLayout | undefined = | ||
canonicalizedSheet.type === 'hmpb' | ||
? canonicalizedSheet.interpretation[0].layout | ||
: undefined; | ||
const backLayout: BallotPageLayout | undefined = | ||
canonicalizedSheet.type === 'hmpb' | ||
? canonicalizedSheet.interpretation[0].layout | ||
: undefined; | ||
|
||
const castVoteRecordFilesToExport: ReadableFile[] = [ | ||
readableFileFromData( | ||
CastVoteRecordExportFileName.CAST_VOTE_RECORD_REPORT, | ||
JSON.stringify(castVoteRecordReport) | ||
), | ||
readableFileFromDisk(frontImageFilePath), | ||
readableFileFromDisk(backImageFilePath), | ||
]; | ||
if (frontLayout) { | ||
|
||
const [frontImageFilePath, backImageFilePath] = canonicalizedSheet.filenames; | ||
castVoteRecordFilesToExport.push(readableFileFromDisk(frontImageFilePath)); | ||
castVoteRecordFilesToExport.push(readableFileFromDisk(backImageFilePath)); | ||
|
||
if (canonicalizedSheet.type === 'hmpb') { | ||
const [frontInterpretation, backInterpretation] = | ||
canonicalizedSheet.interpretation; | ||
castVoteRecordFilesToExport.push( | ||
readableFileFromData( | ||
`${path.parse(frontImageFilePath).name}.layout.json`, | ||
JSON.stringify(frontLayout) | ||
JSON.stringify(frontInterpretation.layout) | ||
) | ||
); | ||
} | ||
if (backLayout) { | ||
castVoteRecordFilesToExport.push( | ||
readableFileFromData( | ||
`${path.parse(backImageFilePath).name}.layout.json`, | ||
JSON.stringify(frontLayout) | ||
JSON.stringify(backInterpretation.layout) | ||
) | ||
); | ||
} | ||
|
@@ -402,6 +407,34 @@ async function exportCastVoteRecordFilesToUsbDrive( | |
return ok({ castVoteRecordId, castVoteRecordHash }); | ||
} | ||
|
||
async function exportRejectedSheetToUsbDrive( | ||
exportContext: ExportContext, | ||
sheet: RejectedSheet, | ||
exportDirectoryPathRelativeToUsbMountPoint: string | ||
): Promise< | ||
Result<{ subDirectoryName: string }, ExportCastVoteRecordsToUsbDriveError> | ||
> { | ||
const { exporter } = exportContext; | ||
|
||
const subDirectoryName = `${CastVoteRecordExportFileName.REJECTED_SHEET_SUB_DIRECTORY_NAME_PREFIX}${sheet.id}`; | ||
const filesToExport: ReadableFile[] = [ | ||
readableFileFromDisk(sheet.frontImagePath), | ||
readableFileFromDisk(sheet.backImagePath), | ||
]; | ||
for (const file of filesToExport) { | ||
const exportResult = await exporter.exportDataToUsbDrive( | ||
exportDirectoryPathRelativeToUsbMountPoint, | ||
path.join(subDirectoryName, file.fileName), | ||
file.open() | ||
); | ||
if (exportResult.isErr()) { | ||
return exportResult; | ||
} | ||
} | ||
|
||
return ok({ subDirectoryName }); | ||
} | ||
|
||
/** | ||
* Exports a top-level metadata file for a cast vote record export | ||
*/ | ||
|
@@ -526,47 +559,45 @@ export async function updateCreationTimestampOfDirectoryAndChildrenFiles( | |
|
||
/** | ||
* File creation timestamps could reveal the order in which ballots were cast. To maintain | ||
* voter privacy, every time a ballot is cast, we randomly select 1 or 2 previously added cast vote | ||
* records and update their creation timestamps to the present. This approach is equivalent to | ||
* moving random ballots to the top of a stack every time a new ballot is added to the stack, a | ||
* kind of shuffling as we go. | ||
* voter privacy, every time a ballot is cast, we randomly select 1 or 2 previous ballots and | ||
* update their creation timestamps to the present. This approach is equivalent to moving random | ||
* ballots to the top of a stack every time a new ballot is added to the stack, a kind of shuffling | ||
* as we go. | ||
* | ||
* This shuffling is irrelevant for full exports, where we already iterate over cast vote records | ||
* in an order independent of creation timestamp. | ||
* This shuffling is irrelevant for full exports, where we already iterate over ballots in an order | ||
* independent of creation timestamp. | ||
*/ | ||
async function randomlyUpdateCreationTimestamps( | ||
exportContext: ExportContext, | ||
exportDirectoryPathRelativeToUsbMountPoint: string, | ||
options: { castVoteRecordIdToIgnore?: string } = {} | ||
options: { subDirectoryNameToIgnore?: string } = {} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the variable names in this function because we're now shuffling not only cast vote record sub-directories but also sub-directories for rejected sheets, i.e. all export sub-directories |
||
): Promise<void> { | ||
const { usbMountPoint } = exportContext; | ||
|
||
const exportDirectoryPath = path.join( | ||
usbMountPoint, | ||
exportDirectoryPathRelativeToUsbMountPoint | ||
); | ||
const castVoteRecordIds = ( | ||
await getExportedCastVoteRecordIds(exportDirectoryPath) | ||
const subDirectoryNames = ( | ||
await getCastVoteRecordExportSubDirectoryNames(exportDirectoryPath) | ||
).filter( | ||
(castVoteRecordId) => castVoteRecordId !== options.castVoteRecordIdToIgnore | ||
(subDirectoryName) => subDirectoryName !== options.subDirectoryNameToIgnore | ||
); | ||
|
||
if (castVoteRecordIds.length === 0) { | ||
if (subDirectoryNames.length === 0) { | ||
return; | ||
} | ||
|
||
const oneOrTwo = crypto.randomInt(1, 3); | ||
for (let i = 0; i < oneOrTwo; i += 1) { | ||
const randomCastVoteRecordId = assertDefined( | ||
castVoteRecordIds[crypto.randomInt(0, castVoteRecordIds.length)] | ||
const randomSubDirectoryName = assertDefined( | ||
subDirectoryNames[crypto.randomInt(0, subDirectoryNames.length)] | ||
); | ||
const castVoteRecordDirectoryPath = path.join( | ||
const subDirectoryPath = path.join( | ||
exportDirectoryPath, | ||
randomCastVoteRecordId | ||
); | ||
await updateCreationTimestampOfDirectoryAndChildrenFiles( | ||
castVoteRecordDirectoryPath | ||
randomSubDirectoryName | ||
); | ||
await updateCreationTimestampOfDirectoryAndChildrenFiles(subDirectoryPath); | ||
} | ||
} | ||
|
||
|
@@ -604,7 +635,7 @@ async function markCastVoteRecordExportAsComplete( | |
export async function exportCastVoteRecordsToUsbDrive( | ||
scannerStore: ScannerStore, | ||
usbDrive: UsbDrive | LegacyUsb, | ||
resultSheets: ResultSheet[] | Generator<ResultSheet>, | ||
sheets: Iterable<Sheet>, | ||
exportOptions: ExportOptions | ||
): Promise<Result<void, ExportCastVoteRecordsToUsbDriveError>> { | ||
let usbMountPoint: string | undefined; | ||
|
@@ -657,7 +688,7 @@ export async function exportCastVoteRecordsToUsbDrive( | |
exportOptions.scannerType === 'precinct' && !exportOptions.isFullExport; | ||
|
||
const castVoteRecordHashes: { [castVoteRecordId: string]: string } = {}; | ||
for (const resultSheet of resultSheets) { | ||
for (const sheet of sheets) { | ||
// Randomly decide whether to shuffle creation timestamps before or after cast vote record | ||
// creation. If we always did one or the other, the last voter's cast vote record would be | ||
// identifiable, as either the first or the last cast vote record among the cluster of cast | ||
|
@@ -675,18 +706,31 @@ export async function exportCastVoteRecordsToUsbDrive( | |
); | ||
} | ||
|
||
const exportCastVoteRecordFilesResult = | ||
await exportCastVoteRecordFilesToUsbDrive( | ||
let mostRecentlyCreatedSubDirectoryName: string | undefined; | ||
if (sheet.type === 'rejected') { | ||
const exportResult = await exportRejectedSheetToUsbDrive( | ||
exportContext, | ||
resultSheet, | ||
sheet, | ||
exportDirectoryPathRelativeToUsbMountPoint | ||
); | ||
if (exportCastVoteRecordFilesResult.isErr()) { | ||
return exportCastVoteRecordFilesResult; | ||
if (exportResult.isErr()) { | ||
return exportResult; | ||
} | ||
const { subDirectoryName } = exportResult.ok(); | ||
mostRecentlyCreatedSubDirectoryName = subDirectoryName; | ||
} else { | ||
const exportResult = await exportCastVoteRecordFilesToUsbDrive( | ||
exportContext, | ||
sheet, | ||
exportDirectoryPathRelativeToUsbMountPoint | ||
); | ||
if (exportResult.isErr()) { | ||
return exportResult; | ||
} | ||
const { castVoteRecordId, castVoteRecordHash } = exportResult.ok(); | ||
castVoteRecordHashes[castVoteRecordId] = castVoteRecordHash; | ||
mostRecentlyCreatedSubDirectoryName = castVoteRecordId; | ||
} | ||
const { castVoteRecordId, castVoteRecordHash } = | ||
exportCastVoteRecordFilesResult.ok(); | ||
castVoteRecordHashes[castVoteRecordId] = castVoteRecordHash; | ||
|
||
if ( | ||
isCreationTimestampShufflingNecessary && | ||
|
@@ -695,8 +739,7 @@ export async function exportCastVoteRecordsToUsbDrive( | |
await randomlyUpdateCreationTimestamps( | ||
exportContext, | ||
exportDirectoryPathRelativeToUsbMountPoint, | ||
// Don't randomly select the cast vote record that was just created | ||
{ castVoteRecordIdToIgnore: castVoteRecordId } | ||
{ subDirectoryNameToIgnore: mostRecentlyCreatedSubDirectoryName } | ||
); | ||
} | ||
} | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sidebar: is this still needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not entirely sure - left a note to revisit this in my temporary catch-all issue: #3996