-
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
Update VxAdmin to support importing new CVR export format #3956
Conversation
Also rename apps/admin/backend/src/cvr_files.ts legacy_cast_vote_records.ts
unsafeParse expects an object, not a JSON string. Use safeParseJson instead, which expects a JSON string.
Where these helpers focus on reading in CVR exports but leave the actual persisting logic to consumers
expectedDirectoryName | ||
); | ||
} | ||
); |
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.
This test.each
is just moved down from above. The one below is new.
directoryNameComponents.unshift('TEST'); | ||
} | ||
return directoryNameComponents.join(SECTION_SEPARATOR); | ||
} |
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.
Similarly, this function is just moved down from above. The one below is new.
); | ||
const parseResult = safeParseJson( |
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.
As noted in the commit description, the mistake here was that unsafeParse
expects an object, not a JSON string. safeParseJson
, on the other hand, expects a JSON string, which is what we're providing.
Tests missed this because of how I'd incorrectly mocked unsafeParse
🙈
I recognize that I should just not mock, but here's why I mocked for context:
// Avoid having to prepare a complete CVR.CastVoteRecordReport object for
// CastVoteRecordExportMetadata
* are not read/parsed, but their existence is validated such that consumers can safely access | ||
* them. | ||
*/ | ||
export async function readCastVoteRecordExport( |
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.
My mental model for the separation of responsibilities between libs/backend
and apps/admin/backend
re import is that:
libs/backend
reads/parses data and performs validation internal to an export (i.e. it doesn't perform contextual validation like whether the fields in an export and the election definition correspond)apps/admin/backend
actually persists data and performs contextual validation
This separation will allow us to reuse libs/backend
import code in VxScan, when we build support for bootstrapping a VxScan with another VxScan's CVRs. The persistence logic and contextual validation for VxScan bootstrapping differ from the persistence logic and contextual validation for VxAdmin tabulation, so it's nice that those pieces are separated out.
scannerIds: [directoryNameComponents.machineId], | ||
}); | ||
continue; | ||
} |
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.
Whereas previously, we could extract everything that we needed for the "list of exports on the USB" UI from the directory name, now, we have to pull some info from the metadata file.
Note that I think it's okay for us to extract this information without authenticating the export (which can be time consuming since we have to re-hash), as we aren't actually importing anything yet. If someone tries to trick the system by changing a directory name or the metadata file, the worst that they'll be able to do is get the "list of exports on the USB" UI to display something misleading. Importing will still fail.
We've applied similar principles to Java Card auth (e.g. here).
@@ -62,6 +65,89 @@ const Content = styled.div` | |||
overflow: hidden; | |||
`; | |||
|
|||
/* c8 ignore start */ | |||
function userReadableMessageFromError( |
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.
Note that this function lives on the frontend. The old import code has an equivalent function live on the backend and sends a user-readable message down to the frontend for it to use. Ultimately, this message-preparation is a UI/frontend concern and not a backend concern, hence my moving it to the frontend.
This will become even more necessary once we start working on multi-language support (as noted here).
} | ||
} | ||
} | ||
/* c8 ignore stop */ |
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 know I've added a lot of code coverage ignores. I've opened issues to track all the tests that I'm deferring to later PRs.
return err({ type: 'invalid-mode', currentMode }); | ||
} | ||
|
||
const existingImportId = store.getCastVoteRecordFileByHash( |
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.
Throughout the code and UI, we talk about a singular CVR file per export, which is no longer the case. I'm going to update terminology in a follow-up PR
* An error encountered while reading an individual cast vote record | ||
*/ | ||
export type ReadCastVoteRecordError = { type: 'invalid-cast-vote-record' } & ( | ||
| { subType: 'batch-id-not-found' } |
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 appreciate the subType
approach.
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; | ||
} |
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.
Am I correct in understanding that, unlike before, where we passed in a list of filenames (like you pass in a list of batchIds
), now you just check whether the files exist in the file system? Makes sense.
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.
That's correct!
const precinct = getPrecinctById({ | ||
election, | ||
precinctId: castVoteRecord.BallotStyleUnitId, | ||
}); |
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.
If you wanted, you could use the cached versions of these functions from utils
to guarantee O(1).
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.
Nice! 84480ad
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.
Haters always gonna hate on the caching but we've got lots of data to process no time for haters.
// 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, | ||
}); |
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.
Now that we have a metadata file, I'm hoping we can eventually get the precinct list in the file up front to avoid this.
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.
👍 Opened an issue for myself so that I don't forget to revisit this: #3968
) | ||
) { | ||
const userRole = assertDefined(await getUserRole()); | ||
const importResult = await importCastVoteRecords(store, input.path); |
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.
How are you handling the manual file selection use case? As in line 446 below.
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.
Ah good catch, I'm not handling that case.
That said, it turns out that manual file selection is already broken because it only allows you to select an individual file, but for quite some time now, we've been exporting a directory and not a file.
Gonna revisit this holistically as its own issue, potentially just removing the manual option altogether, after checking in with others: #3967
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.
It's a bit of a hidden feature that I failed to properly publicize, but manual file selection is not broken. When I switched cast vote records to directories, I added the indicated logic so that you could select the cast-vote-record-report.json
at the root of the directory, which would import the directory.
I use this escape hatch all the time when doing manual testing in VxAdmin. It's far easier than having to always get a cast vote record report on a real or mocked USB drive, so I'd vote strongly to keep the option or something like it.
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.
Ah really great to know! In that case, will plan on maintaining
Easiest reviewed by commit
Overview
Issue link: #3926
This PR completes the end-to-end wiring of continuous export! It specifically updates VxAdmin to support importing the new CVR export format. The user experience is unchanged by this PR. Just a lot of under-the-hood tweaks.
All of the new logic is feature flag gated. Once I merge this PR, I'll work on pruning the feature flag and old CVR export/import logic.
Testing
I've mostly punted on automated tests, which I'll add in follow-up PRs, since this PR is large enough as is.
Checklist