Skip to content

Commit

Permalink
Kevin/vxadmin system settings zip import (#3147)
Browse files Browse the repository at this point in the history
* [vxadmin] load and export system settings

* PR feedback

* return null instead of undefined when system settings not found

* update setup package zip with new 'arePollWorkerCardPinsEnabled' naming
  • Loading branch information
kshen0 committed Mar 17, 2023
1 parent 2e60aa5 commit 142a108
Show file tree
Hide file tree
Showing 38 changed files with 1,179 additions and 122 deletions.
8 changes: 7 additions & 1 deletion apps/admin/backend/schema.sql
Expand Up @@ -80,11 +80,17 @@ create table printed_ballots (
on delete cascade
);

create table system_settings (
-- enforce singleton table
id integer primary key check (id = 1),
are_poll_worker_card_pins_enabled boolean not null default false
);

create table settings (
-- enforce singleton table
id integer primary key check (id = 1),
current_election_id varchar(36),
foreign key (current_election_id) references elections(id)
);

insert into settings default values;
insert into settings default values;
121 changes: 121 additions & 0 deletions apps/admin/backend/src/app.test.ts
Expand Up @@ -521,3 +521,124 @@ test('auth before election definition has been configured', async () => {
electionHash: undefined,
});
});

test('setSystemSettings happy path', async () => {
const { apiClient, auth, logger } = buildTestEnvironment();

const { electionDefinition, systemSettings } =
electionMinimalExhaustiveSampleFixtures;
await configureMachine(apiClient, auth, electionDefinition);

mockSystemAdministratorAuth(auth);

const result = await apiClient.setSystemSettings({
systemSettings: systemSettings.asText(),
});
assert(result.isOk());

// Logger call 1 is made by configureMachine when loading the election definition
expect(logger.log).toHaveBeenNthCalledWith(
2,
LogEventId.SystemSettingsSaveInitiated,
'system_administrator',
{ disposition: 'na' }
);
expect(logger.log).toHaveBeenNthCalledWith(
3,
LogEventId.SystemSettingsSaved,
'system_administrator',
{ disposition: 'success' }
);
});

test('setSystemSettings throws error when store.saveSystemSettings fails', async () => {
const { apiClient, auth, workspace, logger } = buildTestEnvironment();
const { electionDefinition, systemSettings } =
electionMinimalExhaustiveSampleFixtures;
await configureMachine(apiClient, auth, electionDefinition);
const errorString = 'db error at saveSystemSettings';
workspace.store.saveSystemSettings = jest.fn(() => {
throw new Error(errorString);
});

mockSystemAdministratorAuth(auth);
// https://jestjs.io/docs/expect#rejects
await expect(
apiClient.setSystemSettings({ systemSettings: systemSettings.asText() })
).rejects.toThrow(errorString);

// Logger call 1 is made by configureMachine when loading the election definition
expect(logger.log).toHaveBeenNthCalledWith(
2,
LogEventId.SystemSettingsSaveInitiated,
'system_administrator',
{ disposition: 'na' }
);
expect(logger.log).toHaveBeenNthCalledWith(
3,
LogEventId.SystemSettingsSaved,
'system_administrator',
{ disposition: 'failure', error: errorString }
);
});

test('setSystemSettings returns an error for malformed input', async () => {
const { apiClient, auth } = buildTestEnvironment();

const { electionDefinition } = electionMinimalExhaustiveSampleFixtures;
await configureMachine(apiClient, auth, electionDefinition);

mockSystemAdministratorAuth(auth);

const malformedInput = {
invalidField: 'hello',
} as const;

const result = await apiClient.setSystemSettings({
systemSettings: JSON.stringify(malformedInput),
});
assert(result.isErr());
const err = result.err();
expect(err.type).toEqual('parsing');
expect(JSON.parse(err.message)).toMatchObject([
{
code: 'invalid_type',
expected: 'boolean',
received: 'undefined',
path: ['arePollWorkerCardPinsEnabled'],
message: 'Required',
},
]);
});

test('getSystemSettings happy path', async () => {
const { apiClient, auth } = buildTestEnvironment();

const { electionDefinition, systemSettings } =
electionMinimalExhaustiveSampleFixtures;
await configureMachine(apiClient, auth, electionDefinition);

mockSystemAdministratorAuth(auth);

// configure with well-formed system settings
const setResult = await apiClient.setSystemSettings({
systemSettings: systemSettings.asText(),
});
assert(setResult.isOk());

const systemSettingsResult = await apiClient.getSystemSettings();
assert(systemSettingsResult);
expect(systemSettingsResult.arePollWorkerCardPinsEnabled).toEqual(true);
});

test('getSystemSettings returns null when no `system settings` are found', async () => {
const { apiClient, auth } = buildTestEnvironment();

const { electionDefinition } = electionMinimalExhaustiveSampleFixtures;
await configureMachine(apiClient, auth, electionDefinition);

mockSystemAdministratorAuth(auth);

const systemSettingsResult = await apiClient.getSystemSettings();
expect(systemSettingsResult).toBeNull();
});
70 changes: 69 additions & 1 deletion apps/admin/backend/src/app.ts
Expand Up @@ -15,6 +15,8 @@ import {
safeParseElectionDefinition,
safeParseJson,
safeParseNumber,
SystemSettings,
SystemSettingsSchema,
} from '@votingworks/types';
import { assert, assertDefined, err, ok, iter } from '@votingworks/basics';
import express, { Application } from 'express';
Expand All @@ -26,7 +28,11 @@ import * as grout from '@votingworks/grout';
import { promises as fs, Stats } from 'fs';
import { basename } from 'path';
import { parseCvrFileInfoFromFilename } from '@votingworks/utils';
import { AddCastVoteRecordFileResult, ConfigureResult } from './types';
import {
AddCastVoteRecordFileResult,
ConfigureResult,
SetSystemSettingsResult,
} from './types';
import { Workspace } from './util/workspace';
import { listCastVoteRecordFilesOnUsb } from './cvr_files';
import { Usb } from './util/usb';
Expand Down Expand Up @@ -123,6 +129,68 @@ function buildApi({
);
},

async setSystemSettings(input: {
systemSettings: string;
}): Promise<SetSystemSettingsResult> {
await logger.log(
LogEventId.SystemSettingsSaveInitiated,
assertDefined(await getUserRole()),
{ disposition: 'na' }
);

const { systemSettings } = input;
const validatedSystemSettings = safeParseJson(
systemSettings,
SystemSettingsSchema
);
if (validatedSystemSettings.isErr()) {
return err({
type: 'parsing',
message: validatedSystemSettings.err()?.message,
});
}

try {
store.saveSystemSettings(validatedSystemSettings.ok());
} catch (error) {
const typedError = error as Error;
await logger.log(
LogEventId.SystemSettingsSaved,
assertDefined(await getUserRole()),
{ disposition: 'failure', error: typedError.message }
);
throw error;
}

await logger.log(
LogEventId.SystemSettingsSaved,
assertDefined(await getUserRole()),
{ disposition: 'success' }
);

return ok({});
},

async getSystemSettings(): Promise<SystemSettings | null> {
try {
const settings = store.getSystemSettings();
await logger.log(
LogEventId.SystemSettingsRetrieved,
assertDefined(await getUserRole()),
{ disposition: 'success' }
);
return settings || null;
} catch (error) {
await logger.log(
LogEventId.SystemSettingsRetrieved,
assertDefined(await getUserRole()),
{ disposition: 'failure' }
);
throw error;
}
},

// `configure` and `unconfigure` handle changes to the election definition
async configure(input: { electionData: string }): Promise<ConfigureResult> {
const parseResult = safeParseElectionDefinition(input.electionData);
if (parseResult.isErr()) {
Expand Down
37 changes: 36 additions & 1 deletion apps/admin/backend/src/store.test.ts
Expand Up @@ -9,7 +9,12 @@ import {
arbitraryBallotStyleId,
arbitraryPrecinctId,
} from '@votingworks/test-utils';
import { BallotId, CastVoteRecord } from '@votingworks/types';
import {
BallotId,
CastVoteRecord,
SystemSettingsSchema,
safeParseJson,
} from '@votingworks/types';
import { typedAs } from '@votingworks/basics';
import fc from 'fast-check';
import { promises as fs } from 'fs';
Expand Down Expand Up @@ -1118,6 +1123,7 @@ test('write-in adjudication lifecycle', async () => {
"elections" => 1,
"printed_ballots" => 0,
"settings" => 1,
"system_settings" => 0,
"write_in_adjudications" => 1,
"write_ins" => 1,
}
Expand All @@ -1133,6 +1139,7 @@ test('write-in adjudication lifecycle', async () => {
"elections" => 1,
"printed_ballots" => 0,
"settings" => 1,
"system_settings" => 0,
"write_in_adjudications" => 0,
"write_ins" => 0,
}
Expand Down Expand Up @@ -1247,3 +1254,31 @@ test('current election id', () => {
store.setCurrentElectionId(undefined);
expect(store.getCurrentElectionId()).toBeUndefined();
});

/**
* System settings tests
*/
function makeSystemSettings() {
const systemSettingsString =
electionMinimalExhaustiveSampleFixtures.systemSettings.asText();
return safeParseJson(
systemSettingsString,
SystemSettingsSchema
).unsafeUnwrap();
}

test('saveSystemSettings and getSystemSettings write and read system settings', () => {
const store = Store.memoryStore();
const systemSettings = makeSystemSettings();
store.saveSystemSettings(systemSettings);
const retrievedSystemSettings = store.getSystemSettings();
expect(retrievedSystemSettings?.arePollWorkerCardPinsEnabled).toEqual(
systemSettings.arePollWorkerCardPinsEnabled
);
});

test('getSystemSettings returns undefined when no system settings exist', () => {
const store = Store.memoryStore();
const retrievedSystemSettings = store.getSystemSettings();
expect(retrievedSystemSettings).toBeUndefined();
});
35 changes: 35 additions & 0 deletions apps/admin/backend/src/store.ts
Expand Up @@ -18,6 +18,8 @@ import {
safeParse,
safeParseElectionDefinition,
safeParseJson,
SystemSettings,
SystemSettingsDbRow,
} from '@votingworks/types';
import { ParseCastVoteRecordResult, parseCvrs } from '@votingworks/utils';
import * as fs from 'fs';
Expand Down Expand Up @@ -235,6 +237,39 @@ export class Store {
return settings?.currentElectionId ?? undefined;
}

/**
* Creates a system settings record and returns its ID.
* Note `system_settings` are logical settings that span other machines eg. VxScan.
* `settings` are local to VxAdmin
*/
saveSystemSettings(systemSettings: SystemSettings): void {
this.client.run(
'insert into system_settings (are_poll_worker_card_pins_enabled) values (?)',
systemSettings.arePollWorkerCardPinsEnabled ? 1 : 0 // No booleans in sqlite3
);
}

/**
* Gets a specific system settings record.
*/
getSystemSettings(): SystemSettings | undefined {
const result = this.client.one(
`
select
are_poll_worker_card_pins_enabled as arePollWorkerCardPinsEnabled
from system_settings
`
) as SystemSettingsDbRow | undefined;

if (!result) {
return undefined;
}

return {
arePollWorkerCardPinsEnabled: result.arePollWorkerCardPinsEnabled === 1,
};
}

private convertCvrParseErrorsToApiError(
cvrParseResult: ParseCastVoteRecordResult
): AddCastVoteRecordError {
Expand Down
11 changes: 11 additions & 0 deletions apps/admin/backend/src/types.ts
Expand Up @@ -20,6 +20,17 @@ export type ConfigureResult = Result<
{ type: 'parsing'; message: string }
>;

/**
* Result of attempt to store and apply system settings
*/
export type SetSystemSettingsResult = Result<
Record<string, never>,
{
type: 'parsing' | 'database';
message: string;
}
>;

/**
* Errors that may occur when loading a cast vote record file from a path
*/
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/frontend/package.json
Expand Up @@ -19,8 +19,8 @@
"stylelint:run": "stylelint 'src/**/*.{js,jsx,ts,tsx}' && stylelint 'src/**/*.css' --config .stylelintrc-css.js",
"stylelint:run:fix": "stylelint 'src/**/*.{js,jsx,ts,tsx}' --fix && stylelint 'src/**/*.css' --config .stylelintrc-css.js --fix",
"test": "is-ci test:ci test:watch",
"test:ci": "TZ=UTC node scripts/test.js --coverage --watchAll=false --reporters=default --reporters=jest-junit --maxWorkers=7",
"test:coverage": "TZ=UTC node scripts/test.js --coverage --watchAll=false",
"test:ci": "TZ=UTC node scripts/test.js --coverage --watchAll=false --reporters=default --reporters=jest-junit --maxWorkers=7 --env=jest-environment-jsdom-sixteen",
"test:coverage": "TZ=UTC node scripts/test.js --coverage --watchAll=false --env=jest-environment-jsdom-sixteen",
"test:watch": "TZ=UTC node scripts/test.js --env=jest-environment-jsdom-sixteen",
"type-check": "tsc --build"
},
Expand Down

0 comments on commit 142a108

Please sign in to comment.