From 4306a6cc5938446f05b45756e4886635a7965ef7 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Thu, 3 Sep 2026 10:37:18 -0700 Subject: [PATCH 01/12] Move UploadElections, and create placeholder for UploadBallots --- packages/frontend/src/App.tsx | 7 +++---- .../src/components/Election/Upload/UploadBallots.tsx | 0 .../{ => Election/Upload}/UploadElections.tsx | 12 ++++++------ .../components/{ => Election/Upload}/cvrParsers.tsx | 0 4 files changed, 9 insertions(+), 10 deletions(-) create mode 100644 packages/frontend/src/components/Election/Upload/UploadBallots.tsx rename packages/frontend/src/components/{ => Election/Upload}/UploadElections.tsx (97%) rename packages/frontend/src/components/{ => Election/Upload}/cvrParsers.tsx (100%) diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index cdf34324b..c8e51dbaf 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,11 +1,10 @@ -import { useEffect } from 'react' -import { BrowserRouter as Router, Routes, Route, useLocation } from 'react-router-dom' +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import { ThemeContextProvider } from './theme' import Header from './components/Header' import Election from './components/Election/Election' import Sandbox from './components/Sandbox' import LandingPage from './components/LandingPage' -import { Box, CssBaseline, Typography } from '@mui/material' +import { Box, CssBaseline } from '@mui/material' import { SnackbarContextProvider } from './components/SnackbarContext' import Footer from './components/Footer' import { ConfirmDialogProvider } from './components/ConfirmationDialogProvider' @@ -18,7 +17,7 @@ import OpenElections from './components/Elections/OpenElections' import { FeatureFlagContextProvider } from './components/FeatureFlagContextProvider' import ComposeContextProviders from './components/ComposeContextProviders' import './i18n/i18n' -import UploadElections from './components/UploadElections' +import UploadElections from './components/Election/Upload/UploadElections' import Redirect from './components/Redirect' import PublicArchive from './components/Elections/PublicArchive' import NameMatchingTester from './components/NameMatchingTester' diff --git a/packages/frontend/src/components/Election/Upload/UploadBallots.tsx b/packages/frontend/src/components/Election/Upload/UploadBallots.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/frontend/src/components/UploadElections.tsx b/packages/frontend/src/components/Election/Upload/UploadElections.tsx similarity index 97% rename from packages/frontend/src/components/UploadElections.tsx rename to packages/frontend/src/components/Election/Upload/UploadElections.tsx index 6f865e05e..9b3cb6a2c 100644 --- a/packages/frontend/src/components/UploadElections.tsx +++ b/packages/frontend/src/components/Election/Upload/UploadElections.tsx @@ -1,19 +1,19 @@ import { Box, Checkbox, FormControlLabel, FormGroup, Typography } from "@mui/material"; import { useEffect, useRef, useState } from "react"; -import { useSubstitutedTranslation } from "./util"; -import EnhancedTable from "./EnhancedTable"; +import { useSubstitutedTranslation } from "../../util"; +import EnhancedTable from "../../EnhancedTable"; import { rankColumnCSV } from "./cvrParsers"; import { makeID, ID_PREFIXES, ID_LENGTHS } from '@equal-vote/star-vote-shared/utils/makeID'; import Papa from 'papaparse'; -import useAuthSession from "./AuthSessionContextProvider"; +import useAuthSession from "../../AuthSessionContextProvider"; import { Candidate } from "@equal-vote/star-vote-shared/domain_model/Candidate"; import { Election, NewElection } from '@equal-vote/star-vote-shared/domain_model/Election'; import { useGetElections } from "~/hooks/useAPI"; import { OrderedNewBallot, RaceCandidateOrder } from "@equal-vote/star-vote-shared/domain_model/Ballot"; import { encodeOrderedVote } from "@equal-vote/star-vote-shared/domain_model/OrderedVoteCodec"; -import { inferElectionSettings } from "./ElectionSettingInference"; -import { PrimaryButton, SecondaryButton } from "./styles"; -import { makeDefaultElection } from "./ElectionForm/Wizard/Wizard"; +import { inferElectionSettings } from "../../ElectionSettingInference"; +import { PrimaryButton, SecondaryButton } from "../../styles"; +import { makeDefaultElection } from "../../ElectionForm/Wizard/Wizard"; const UploadElections = () => { const [addToPublicArchive, setAddToPublicArchive] = useState(true) diff --git a/packages/frontend/src/components/cvrParsers.tsx b/packages/frontend/src/components/Election/Upload/cvrParsers.tsx similarity index 100% rename from packages/frontend/src/components/cvrParsers.tsx rename to packages/frontend/src/components/Election/Upload/cvrParsers.tsx From 3b213484619f7204460914f7397895a3e36765b7 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:25:53 +0000 Subject: [PATCH 02/12] RALPH: Add ADR-0003 dual-gate submission-channel design (#1606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: Issue #1606 (docs-only ADR for the dual-gate design from PRD #1603) PRD: #1603 (Allow election admins to bulk upload ballots) Decisions recorded: - Two independent checks are required to upload a ballot: the existing `canUploadBallots` role permission (who may upload) and the new `allowed_submit_types` election setting (does this election allow it). - Merging the two into one check conflates identity with policy and creates unsolvable edge cases in both directions (all-or-nothing vs. no role gate). - Each gate can evolve independently — role permission can be delegated; setting can gain new channel types — without coupling the two. Files changed: - dev-docs/adr/0003-dual-gate-submission-channel-design.md (new) - CONTEXT.md (added Submission Channel, Paper Ballots, Ballot Source terms) Notes: - No code changes; this is docs-only per the issue's acceptance criteria. - Ballot Source clarification was added to CONTEXT.md to prevent future confusion between the two orthogonal concepts. Co-Authored-By: Claude Sonnet 4.6 --- CONTEXT.md | 11 +++++++ ...003-dual-gate-submission-channel-design.md | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 dev-docs/adr/0003-dual-gate-submission-channel-design.md diff --git a/CONTEXT.md b/CONTEXT.md index 6d7f91649..1b43ec756 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,3 +7,14 @@ BetterVoting is an online election and polling platform built by the Equal Vote **Support Actions**: The canonical trio of ways a visitor can support the project — Volunteer, Donate, Merch — surfaced together in both the nav's "Support Us" dropdown and the landing page's support stripe. `/volunteer` redirects to the codebase contribution guide, so Volunteer already covers code/docs contribution — it isn't a separate action. _Avoid_: Contribute (same destination as Volunteer, not a distinct Support Action) + +**Submission Channel**: +The mechanism by which a ballot enters an election. Three channels are defined: **Online (browser)** (`submitted_via_browser`) — a voter submits directly through the web UI; **Paper ballots (admin upload)** (`submitted_via_admin`) — an election owner or system admin bulk-uploads ballots collected on paper; **Discord** (`submitted_via_discord`) — future, behind a feature flag, not yet implemented. An election's allowed channels are configured via the `allowed_submit_types` field on `ElectionSettings`. This is a per-election policy decision, locked once the election leaves draft. +_Distinct from Ballot Source_ (see below). + +**Paper Ballots**: +Ballots that were collected on paper (or in any out-of-band medium) and then uploaded into BetterVoting by an election owner or system admin via the Upload Ballots feature. They are stored alongside online ballots in the same table and are identified by their ballot-history action type (`submitted_via_admin`), not by a separate field. +_Avoid_: "admin ballots" (implies the admin voted, not that the admin uploaded someone else's vote). + +**Ballot Source** (existing concept, distinct from Submission Channel): +Refers to the origin election when a ballot is carried over from a prior election (e.g. `prior_election` sourced ballots used to seed a new election with historical data). Ballot Source governs ballot provenance across elections; Submission Channel governs how a ballot entered a single election. The two concepts are orthogonal — an admin-uploaded ballot is always `submitted_via_admin` regardless of its source election. diff --git a/dev-docs/adr/0003-dual-gate-submission-channel-design.md b/dev-docs/adr/0003-dual-gate-submission-channel-design.md new file mode 100644 index 000000000..920d1b966 --- /dev/null +++ b/dev-docs/adr/0003-dual-gate-submission-channel-design.md @@ -0,0 +1,33 @@ +# Dual-gate submission-channel design: role permission + election setting, checked independently + +## Problem + +The ballot-upload pipeline needs two distinct controls simultaneously: + +1. **Who may upload** — only election owners and system admins should ever be able to bulk-upload ballots into an election. This is already enforced by the existing `canUploadBallots` role permission. +2. **Whether this election allows it** — an election owner should be able to configure, per election, whether paper-ballot admin upload is an accepted submission channel. Without this, the role permission is either too permissive (every owner can inject ballots into any election they own, even one they never intended to accept paper ballots for) or too restrictive (locking out the pattern entirely unless the owner is a system admin). + +The straightforward alternatives both fail: + +- **Merge both controls into the role permission alone** — gives every election owner the ability to upload ballots for any election they own, with no per-election opt-in. An owner who set up an online-only election would have no way to prevent admin ballot injection into it. +- **Merge both controls into the election setting alone** — means a non-owner filling in for the owner (e.g. a system admin assisting with a batch upload) would be blocked the moment the setting isn't tailored to them specifically. Role-based access control is the right tool for "who may do this at all." + +## Decision + +Use two independent checks rather than collapsing them into one: + +- **`canUploadBallots` role permission** (already existing, unchanged) — answers "is this user allowed to upload ballots at all?" System admins and election owners pass; everyone else is rejected before any other check runs. +- **`allowed_submit_types` election setting** (new, on `ElectionSettings`) — answers "does this election accept paper-ballot admin upload as a channel?" Checked in the ballot-submission pipeline after the role gate, rejecting with a 400 if the incoming submission type isn't in the election's configured set. + +Passing both gates is required to upload a ballot. Neither replaces the other. The setting is evaluated with a runtime fallback (`election.settings.allowed_submit_types ?? DEFAULT_ALLOWED_SUBMIT_TYPES`) so that elections created before the field existed behave consistently — paper-ballot upload is **off by default**; online browser submission and (future) Discord are on by default. + +The setting is locked once the election leaves draft, so the submission-channel rules can't change after voting has started. + +## Why independent rather than merged + +The two checks answer categorically different questions. A role permission governs identity ("is this principal trusted to perform this class of action?"). An election setting governs policy ("has this election been configured to allow this class of input?"). Merging them into one check would conflate identity and policy in a way that makes the system harder to reason about and creates edge cases: + +- If only the role gate existed, every election owner could upload ballots into any of their elections regardless of whether paper-ballot intake was ever intended — silently. +- If only the election setting existed, a system admin helping upload into an election that has the setting enabled would need the setting to somehow encode their identity separately. That's what role-based access control is for. + +Keeping the gates independent also means each gate can change independently: the role permission can be broadened or narrowed (e.g. delegated per-election to a designated ballot-handler role) without touching the election-settings schema, and the election setting can gain new channel types (Discord, future channels) without touching role-permission logic. From 37e0ce489f6b1b85cfd855229ff7ff48cba64856 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:33:08 +0000 Subject: [PATCH 03/12] RALPH: Extract shared upload utilities and rewire UploadElections (#1605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task: Issue 1605 — extract computeRaceOrder, encodeBallotRow, uploadBallotsBatched from UploadElections.tsx into a standalone unit-tested module. Key decisions: - New module: packages/frontend/src/components/Election/Upload/uploadUtils.ts - computeRaceOrder derived from election.races (not from first ballot's votes) - encodeBallotRow looks up each race by race_id, not positional index; races absent from a ballot encode as all-undecided (all null marks) - uploadBallotsBatched fixes silent-discard bug: returns aborted:true + a synthetic failure entry for untried remainder when batch floor is hit - Added Jest to frontend (jest.config.cjs + tsconfig.test.json); test files excluded from main tsconfig to keep tsc build clean Files changed: - packages/frontend/src/components/Election/Upload/uploadUtils.ts (new) - packages/frontend/src/components/Election/Upload/uploadUtils.test.ts (new, 9 tests) - packages/frontend/src/components/Election/Upload/UploadElections.tsx (rewired) - packages/frontend/jest.config.cjs (new) - packages/frontend/tsconfig.test.json (new) - packages/frontend/tsconfig.json (exclude test files) - packages/frontend/package.json (add test script) - package-lock.json (updated) No blockers. All 9 frontend tests + 211 backend tests pass. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 24 --- packages/frontend/jest.config.cjs | 15 ++ packages/frontend/package.json | 3 +- .../Election/Upload/UploadElections.tsx | 90 +++-------- .../Election/Upload/uploadUtils.test.ts | 146 ++++++++++++++++++ .../components/Election/Upload/uploadUtils.ts | 97 ++++++++++++ packages/frontend/tsconfig.json | 3 +- packages/frontend/tsconfig.test.json | 11 ++ 8 files changed, 296 insertions(+), 93 deletions(-) create mode 100644 packages/frontend/jest.config.cjs create mode 100644 packages/frontend/src/components/Election/Upload/uploadUtils.test.ts create mode 100644 packages/frontend/src/components/Election/Upload/uploadUtils.ts create mode 100644 packages/frontend/tsconfig.test.json diff --git a/package-lock.json b/package-lock.json index 33f96044a..74683ea61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2387,9 +2387,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2404,9 +2401,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2420,9 +2414,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2436,9 +2427,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2537,9 +2525,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2552,9 +2537,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2567,9 +2549,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3437,9 +3416,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ diff --git a/packages/frontend/jest.config.cjs b/packages/frontend/jest.config.cjs new file mode 100644 index 000000000..570cc4076 --- /dev/null +++ b/packages/frontend/jest.config.cjs @@ -0,0 +1,15 @@ +module.exports = { + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + tsconfig: './tsconfig.test.json', + }, + ], + }, + moduleNameMapper: { + '^~/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: ['/node_modules/', '/build/'], + testMatch: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 4065aec25..e693111f6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -36,7 +36,8 @@ "clean": "npx rimraf build node_modules ../node_modules", "build": "tsc && rsbuild build", "start": "rsbuild preview", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "test": "jest --config jest.config.cjs" }, "browserslist": { "production": [ diff --git a/packages/frontend/src/components/Election/Upload/UploadElections.tsx b/packages/frontend/src/components/Election/Upload/UploadElections.tsx index 9b3cb6a2c..a6010fff5 100644 --- a/packages/frontend/src/components/Election/Upload/UploadElections.tsx +++ b/packages/frontend/src/components/Election/Upload/UploadElections.tsx @@ -9,9 +9,8 @@ import useAuthSession from "../../AuthSessionContextProvider"; import { Candidate } from "@equal-vote/star-vote-shared/domain_model/Candidate"; import { Election, NewElection } from '@equal-vote/star-vote-shared/domain_model/Election'; import { useGetElections } from "~/hooks/useAPI"; -import { OrderedNewBallot, RaceCandidateOrder } from "@equal-vote/star-vote-shared/domain_model/Ballot"; -import { encodeOrderedVote } from "@equal-vote/star-vote-shared/domain_model/OrderedVoteCodec"; import { inferElectionSettings } from "../../ElectionSettingInference"; +import { computeRaceOrder, encodeBallotRow, uploadBallotsBatched } from "./uploadUtils"; import { PrimaryButton, SecondaryButton } from "../../styles"; import { makeDefaultElection } from "../../ElectionForm/Wizard/Wizard"; @@ -157,75 +156,32 @@ const UploadElections = () => { // #5 : Convert Rows to Ballots const {ballots, errors} = rankColumnCSV(parsed_csv, election) - const raceOrder: RaceCandidateOrder[] = ballots[0].votes.map(v => ({ - race_id: v.race_id, - candidate_id_order: v.scores.map(s => s.candidate_id) - })) - const orderedBallots: OrderedNewBallot[] = ballots - .filter((b, i) => !errorRows.has(i)) - .map(b => { - // TODO: define subBallot type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const subBallot: any = {...b}; - delete subBallot.votes; - return { - ...subBallot, - orderedVotes: b.votes.map(v => encodeOrderedVote(v.scores.map(s => s.score), v.overvote_rank, v.has_duplicate_rank ?? false)) - } - }); + const raceOrder = computeRaceOrder(election) + const orderedBallots = ballots + .filter((_, i) => !errorRows.has(i)) + .map(b => encodeBallotRow(b, raceOrder)); // #6 : Upload Ballots - let batchSize = 700; - let nextIndex = 0; - let responses = []; - // TODO: this batching isn't ideal since it'll be tricky to recovered from a partial failure - // that said this will mainly be relevant when uploading batches for an existing election so I'll leave it for now - while(nextIndex+1 < ballots.length && nextIndex < 2000000 /* a dummy check to avoid infinite loops*/){ - updateElection(cvr.name, (e) => ({ + const uploadResult = await uploadBallotsBatched( + election.election_id, + raceOrder, + orderedBallots, + (uploaded, total) => { + updateElection(cvr.name, (e) => ({ + ...e, + message: `uploading ${uploaded}/${total}...` + })) + } + ); + + if (uploadResult.aborted) { + console.log('ERRORS for', cvr.name, errors); + updateElection(cvr.name, e => ({ ...e, - message: `uploading ${nextIndex}/${parsed_csv.data.length}...` + upload_status: "Error", + message: "(see console)" })) - - let uploadRes; - do{ - uploadRes = await fetch(`/API/Election/${election.election_id}/uploadBallots`, { - method: 'post', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - race_order: raceOrder, - ballots: orderedBallots.slice(nextIndex, nextIndex+batchSize).map((b, i) => ({ - voter_id: i, - ballot: b - })) - }) - }) - - if (!uploadRes.ok){ - errors.push({ - code: "UploadBallotsFailed", - message: `Error making request: ${uploadRes.status.toString()}`, - row: -1, - type: "UploadBallotsFailed" - }) - batchSize = Math.round(batchSize * 0.75); - if(batchSize < 10){ - console.log('ERRORS for', cvr.name, errors); - updateElection(cvr.name, e => ({ - ...e, - upload_status: "Error", - message: "(see console)" - })) - return; - } - } - }while(!uploadRes.ok); - nextIndex += batchSize; - - const res = await uploadRes.json(); - responses = [...responses, ...res.responses]; + return; } updateElection(cvr.name, (e) => ({ diff --git a/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts b/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts new file mode 100644 index 000000000..925a59285 --- /dev/null +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts @@ -0,0 +1,146 @@ +import { computeRaceOrder, encodeBallotRow } from './uploadUtils'; +import { Election } from '@equal-vote/star-vote-shared/domain_model/Election'; +import { NewBallot, RaceCandidateOrder } from '@equal-vote/star-vote-shared/domain_model/Ballot'; +import { ORDERED_VOTE_TAIL_LENGTH } from '@equal-vote/star-vote-shared/domain_model/OrderedVoteCodec'; + +const mockElection = { + election_id: 'elec1', + title: 'Test Election', + description: '', + frontend_url: 'http://localhost', + owner_id: 'owner1', + state: 'closed' as const, + ballot_source: 'prior_election' as const, + settings: {} as Election['settings'], + create_date: new Date(), + update_date: new Date(), + head: true, + races: [ + { + race_id: 'race1', + title: 'Mayor', + voting_method: 'IRV' as const, + num_winners: 1, + candidates: [ + { candidate_id: 'c1', candidate_name: 'Alice' }, + { candidate_id: 'c2', candidate_name: 'Bob' }, + ], + }, + { + race_id: 'race2', + title: 'Council', + voting_method: 'IRV' as const, + num_winners: 1, + candidates: [ + { candidate_id: 'c3', candidate_name: 'Charlie' }, + { candidate_id: 'c4', candidate_name: 'Dana' }, + { candidate_id: 'c5', candidate_name: 'Eve' }, + ], + }, + ], +} as unknown as Election; + +const raceOrder: RaceCandidateOrder[] = [ + { race_id: 'race1', candidate_id_order: ['c1', 'c2'] }, + { race_id: 'race2', candidate_id_order: ['c3', 'c4', 'c5'] }, +]; + +describe('computeRaceOrder', () => { + it('returns one entry per race', () => { + const result = computeRaceOrder(mockElection); + expect(result).toHaveLength(2); + }); + + it('maps race_id and candidate_id_order from the election', () => { + const result = computeRaceOrder(mockElection); + expect(result[0]).toEqual({ race_id: 'race1', candidate_id_order: ['c1', 'c2'] }); + expect(result[1]).toEqual({ race_id: 'race2', candidate_id_order: ['c3', 'c4', 'c5'] }); + }); + + it('returns an empty array for an election with no races', () => { + const noRaces = { ...mockElection, races: [] } as unknown as Election; + expect(computeRaceOrder(noRaces)).toEqual([]); + }); +}); + +describe('encodeBallotRow', () => { + const makeRow = (votes: NewBallot['votes']): NewBallot => ({ + election_id: 'elec1', + status: 'submitted', + date_submitted: 0, + votes, + } as NewBallot); + + it('produces one orderedVote per race in raceOrder', () => { + const row = makeRow([ + { race_id: 'race1', scores: [{ candidate_id: 'c1', score: 1 }, { candidate_id: 'c2', score: null }] }, + { race_id: 'race2', scores: [{ candidate_id: 'c3', score: 2 }, { candidate_id: 'c4', score: 1 }, { candidate_id: 'c5', score: null }] }, + ]); + const result = encodeBallotRow(row, raceOrder); + expect(result.orderedVotes).toHaveLength(2); + }); + + it('encodes scores in candidate_id_order sequence', () => { + const row = makeRow([ + { race_id: 'race1', scores: [{ candidate_id: 'c1', score: 1 }, { candidate_id: 'c2', score: 2 }] }, + { race_id: 'race2', scores: [{ candidate_id: 'c3', score: null }, { candidate_id: 'c4', score: null }, { candidate_id: 'c5', score: null }] }, + ]); + const result = encodeBallotRow(row, raceOrder); + // race1: marks are [1, 2] + 2 tail entries + expect(result.orderedVotes[0][0]).toBe(1); + expect(result.orderedVotes[0][1]).toBe(2); + expect(result.orderedVotes[0]).toHaveLength(2 + ORDERED_VOTE_TAIL_LENGTH); + }); + + it('encodes a race missing from votes as all-undecided (all null marks)', () => { + const row = makeRow([ + { race_id: 'race1', scores: [{ candidate_id: 'c1', score: 1 }, { candidate_id: 'c2', score: null }] }, + // race2 is absent + ]); + const result = encodeBallotRow(row, raceOrder); + const race2Vote = result.orderedVotes[1]; + // length = 3 candidates + 2 tail entries + expect(race2Vote).toHaveLength(3 + ORDERED_VOTE_TAIL_LENGTH); + // marks are all null + for (let i = 0; i < 3; i++) { + expect(race2Vote[i]).toBeNull(); + } + }); + + it('handles a row with no votes at all (all races missing)', () => { + const row = makeRow([]); + const result = encodeBallotRow(row, raceOrder); + expect(result.orderedVotes).toHaveLength(2); + // Both races should be all-undecided + const race1Vote = result.orderedVotes[0]; + expect(race1Vote[0]).toBeNull(); + expect(race1Vote[1]).toBeNull(); + }); + + it('preserves non-votes fields from the row', () => { + const row = makeRow([ + { race_id: 'race1', scores: [{ candidate_id: 'c1', score: 1 }, { candidate_id: 'c2', score: null }] }, + { race_id: 'race2', scores: [{ candidate_id: 'c3', score: null }, { candidate_id: 'c4', score: null }, { candidate_id: 'c5', score: null }] }, + ]); + const result = encodeBallotRow(row, raceOrder); + expect(result.election_id).toBe('elec1'); + expect(result.status).toBe('submitted'); + expect(result.votes).toBeUndefined(); + }); + + it('looks up races by race_id, not by position in votes array', () => { + // votes in reversed order compared to raceOrder + const row = makeRow([ + { race_id: 'race2', scores: [{ candidate_id: 'c3', score: 1 }, { candidate_id: 'c4', score: 2 }, { candidate_id: 'c5', score: 3 }] }, + { race_id: 'race1', scores: [{ candidate_id: 'c1', score: 1 }, { candidate_id: 'c2', score: null }] }, + ]); + const result = encodeBallotRow(row, raceOrder); + // orderedVotes[0] should be race1 (score c1=1, c2=null), not race2 + expect(result.orderedVotes[0][0]).toBe(1); // c1's score + expect(result.orderedVotes[0][1]).toBeNull(); // c2's score + // orderedVotes[1] should be race2 (score c3=1, c4=2, c5=3) + expect(result.orderedVotes[1][0]).toBe(1); // c3's score + expect(result.orderedVotes[1][1]).toBe(2); // c4's score + expect(result.orderedVotes[1][2]).toBe(3); // c5's score + }); +}); diff --git a/packages/frontend/src/components/Election/Upload/uploadUtils.ts b/packages/frontend/src/components/Election/Upload/uploadUtils.ts new file mode 100644 index 000000000..74121c5ab --- /dev/null +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.ts @@ -0,0 +1,97 @@ +import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; +import { NewBallot, OrderedNewBallot, RaceCandidateOrder } from "@equal-vote/star-vote-shared/domain_model/Ballot"; +import { encodeOrderedVote } from "@equal-vote/star-vote-shared/domain_model/OrderedVoteCodec"; + +export function computeRaceOrder(election: Election): RaceCandidateOrder[] { + return election.races.map(race => ({ + race_id: race.race_id, + candidate_id_order: race.candidates.map(c => c.candidate_id) + })); +} + +export function encodeBallotRow(row: NewBallot, raceOrder: RaceCandidateOrder[]): OrderedNewBallot { + const { votes, ...rest } = row; + return { + ...rest, + orderedVotes: raceOrder.map(({ race_id, candidate_id_order }) => { + const vote = (votes ?? []).find(v => v.race_id === race_id); + if (!vote) { + // Race missing from this row's votes — encode as all-undecided + return encodeOrderedVote(candidate_id_order.map(() => null), undefined, false); + } + return encodeOrderedVote( + vote.scores.map(s => s.score), + vote.overvote_rank, + vote.has_duplicate_rank ?? false + ); + }) + }; +} + +export interface UploadBatchResult { + responses: unknown[]; + aborted: boolean; +} + +export interface UploadBatchedOptions { + initialBatchSize?: number; + shrinkFactor?: number; + minBatchSize?: number; +} + +export async function uploadBallotsBatched( + electionId: string, + raceOrder: RaceCandidateOrder[], + ballots: OrderedNewBallot[], + onProgress?: (uploaded: number, total: number) => void, + options?: UploadBatchedOptions +): Promise { + const initialBatchSize = options?.initialBatchSize ?? 700; + const shrinkFactor = options?.shrinkFactor ?? 0.75; + const minBatchSize = options?.minBatchSize ?? 10; + + let batchSize = initialBatchSize; + let nextIndex = 0; + let responses: unknown[] = []; + + while (nextIndex < ballots.length) { + onProgress?.(nextIndex, ballots.length); + + let uploadRes: Response; + do { + uploadRes = await fetch(`/API/Election/${electionId}/uploadBallots`, { + method: 'post', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + race_order: raceOrder, + ballots: ballots.slice(nextIndex, nextIndex + batchSize).map((b, i) => ({ + voter_id: i, + ballot: b + })) + }) + }); + + if (!uploadRes.ok) { + batchSize = Math.round(batchSize * shrinkFactor); + if (batchSize < minBatchSize) { + const remaining = ballots.length - nextIndex; + responses.push({ + success: false, + voter_id: nextIndex, + message: `Upload aborted: batch size floor reached with ${remaining} ballots remaining`, + }); + return { responses, aborted: true }; + } + } + } while (!uploadRes.ok); + + nextIndex += batchSize; + const res = await uploadRes.json(); + responses = [...responses, ...res.responses]; + } + + return { responses, aborted: false }; +} diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 19e3febd5..2e4a3d5bc 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -22,5 +22,6 @@ "node" ] }, - "include": ["./src"] + "include": ["./src"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"] } diff --git a/packages/frontend/tsconfig.test.json b/packages/frontend/tsconfig.test.json new file mode 100644 index 000000000..f3429eb9f --- /dev/null +++ b/packages/frontend/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "types": ["jest", "node"], + "isolatedModules": true + }, + "exclude": [] +} From be1d40b03771319f642c2e97f02ce20104357e75 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:36:53 +0000 Subject: [PATCH 04/12] Trim unused knobs from uploadBallotsBatched - Drop the UploadBatchedOptions interface since no caller overrides the batch-size defaults; inline them as module constants. - Push into `responses` instead of rebuilding it every batch. - Remove the aborted-branch synthetic error entry that the caller never reads (it only checks `aborted`). Co-Authored-By: Claude Opus 4.7 --- .../components/Election/Upload/uploadUtils.ts | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/packages/frontend/src/components/Election/Upload/uploadUtils.ts b/packages/frontend/src/components/Election/Upload/uploadUtils.ts index 74121c5ab..871419359 100644 --- a/packages/frontend/src/components/Election/Upload/uploadUtils.ts +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.ts @@ -33,26 +33,19 @@ export interface UploadBatchResult { aborted: boolean; } -export interface UploadBatchedOptions { - initialBatchSize?: number; - shrinkFactor?: number; - minBatchSize?: number; -} +const INITIAL_BATCH_SIZE = 700; +const BATCH_SHRINK_FACTOR = 0.75; +const MIN_BATCH_SIZE = 10; export async function uploadBallotsBatched( electionId: string, raceOrder: RaceCandidateOrder[], ballots: OrderedNewBallot[], - onProgress?: (uploaded: number, total: number) => void, - options?: UploadBatchedOptions + onProgress?: (uploaded: number, total: number) => void ): Promise { - const initialBatchSize = options?.initialBatchSize ?? 700; - const shrinkFactor = options?.shrinkFactor ?? 0.75; - const minBatchSize = options?.minBatchSize ?? 10; - - let batchSize = initialBatchSize; + let batchSize = INITIAL_BATCH_SIZE; let nextIndex = 0; - let responses: unknown[] = []; + const responses: unknown[] = []; while (nextIndex < ballots.length) { onProgress?.(nextIndex, ballots.length); @@ -75,14 +68,8 @@ export async function uploadBallotsBatched( }); if (!uploadRes.ok) { - batchSize = Math.round(batchSize * shrinkFactor); - if (batchSize < minBatchSize) { - const remaining = ballots.length - nextIndex; - responses.push({ - success: false, - voter_id: nextIndex, - message: `Upload aborted: batch size floor reached with ${remaining} ballots remaining`, - }); + batchSize = Math.round(batchSize * BATCH_SHRINK_FACTOR); + if (batchSize < MIN_BATCH_SIZE) { return { responses, aborted: true }; } } @@ -90,7 +77,7 @@ export async function uploadBallotsBatched( nextIndex += batchSize; const res = await uploadRes.json(); - responses = [...responses, ...res.responses]; + responses.push(...res.responses); } return { responses, aborted: false }; From 59bf96cfa68f24109513492f65da7384227f53bc Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:38:24 +0000 Subject: [PATCH 05/12] RALPH: Enforce allowed_submit_types in ballot-submission pipeline (#1604, PRD #1603) Key decisions: - BallotSubmitType and BallotActionType moved from castVoteController.ts into shared Ballot.ts; BallotAction.action_type retypes from string to BallotActionType - NewBallot gains election_id, status, date_submitted as optional fields - ElectionSettings gains optional allowed_submit_types?: BallotSubmitType[] with DEFAULT_ALLOWED_SUBMIT_TYPES = ['submitted_via_browser', 'submitted_via_discord'] - electionSettingsValidation rejects an explicitly empty allowed_submit_types array - makeBallotEvent enforces allowed_submit_types inside the existing draft/prior_election bypass block, throwing BadRequest 400 naming the disallowed type - makeDevElections.ts typed action_type against BallotActionType (closing the drift gap) - 11 backend tests cover: settings validation, admin accepted/rejected per setting, default-array rejection, draft bypass, and compose with already-voted check Files changed: - packages/shared/src/domain_model/Ballot.ts - packages/shared/src/domain_model/ElectionSettings.ts - packages/backend/src/Controllers/Ballot/castVoteController.ts - packages/backend/src/DevElections/makeDevElections.ts - packages/backend/src/test/TestHelper.ts (added uploadBallots helper) - packages/backend/src/test/allowedSubmitTypes.test.ts (new, 11 tests) Notes: - Upload endpoint returns per-ballot success/failure (HTTP 200 with success:false for rejected ballots), consistent with existing bulk-upload behavior - Frontend enforcement (hiding Vote button, Settings UI checkboxes) is out of scope for this issue per the spec Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/Ballot/castVoteController.ts | 11 +- .../src/DevElections/makeDevElections.ts | 3 +- packages/backend/src/test/TestHelper.ts | 13 + .../src/test/allowedSubmitTypes.test.ts | 229 ++++++++++++++++++ packages/shared/src/domain_model/Ballot.ts | 7 +- .../src/domain_model/ElectionSettings.ts | 17 ++ 6 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/test/allowedSubmitTypes.test.ts diff --git a/packages/backend/src/Controllers/Ballot/castVoteController.ts b/packages/backend/src/Controllers/Ballot/castVoteController.ts index 273cf4f42..773628008 100644 --- a/packages/backend/src/Controllers/Ballot/castVoteController.ts +++ b/packages/backend/src/Controllers/Ballot/castVoteController.ts @@ -1,6 +1,7 @@ import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/ElectionRoll"; -import { Ballot, ballotValidation, NewBallot, OrderedNewBallot, RaceCandidateOrder } from '@equal-vote/star-vote-shared/domain_model/Ballot'; +import { Ballot, BallotSubmitType, ballotValidation, NewBallot, OrderedNewBallot, RaceCandidateOrder } from '@equal-vote/star-vote-shared/domain_model/Ballot'; +import { DEFAULT_ALLOWED_SUBMIT_TYPES } from '@equal-vote/star-vote-shared/domain_model/ElectionSettings'; import ServiceLocator from "../../ServiceLocator"; import Logger from "../../Services/Logging/Logger"; import { BadRequest, Conflict, InternalServerError, Unauthorized } from "@curveball/http-errors"; @@ -27,9 +28,6 @@ const EmailService = ServiceLocator.emailService(); const AccountService = ServiceLocator.accountService(); -// NOTE: discord isn't implemented yet, but that's the plan for the future -type BallotSubmitType = 'submitted_via_browser' | 'submitted_via_admin' | 'submitted_via_discord'; - const castVoteEventQueue = "castVoteEvent"; async function makeBallotEvent(req: IElectionRequest, targetElection: Election, inputBallot: NewBallot, submitType: BallotSubmitType, voter_id?: string, adminUsername?: string){ @@ -39,6 +37,11 @@ async function makeBallotEvent(req: IElectionRequest, targetElection: Election, // TODO: we may be able to shortcut further for elections that don't require authentication // ^ that could be huge when creating elections from a set of ballots if(targetElection.state !== 'draft' && req.election.ballot_source !== 'prior_election') { + const allowedTypes = targetElection.settings.allowed_submit_types ?? DEFAULT_ALLOWED_SUBMIT_TYPES; + if (!allowedTypes.includes(submitType)) { + throw new BadRequest(`Ballot submission type '${submitType}' is not allowed for this election`); + } + const missingAuthData = checkForMissingAuthenticationData(req, targetElection, req, voter_id) if (missingAuthData !== null) { throw new Unauthorized(missingAuthData); diff --git a/packages/backend/src/DevElections/makeDevElections.ts b/packages/backend/src/DevElections/makeDevElections.ts index 16071742c..dd54c6522 100644 --- a/packages/backend/src/DevElections/makeDevElections.ts +++ b/packages/backend/src/DevElections/makeDevElections.ts @@ -4,6 +4,7 @@ require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }) import servicelocator from '../ServiceLocator' import { DevElectionDefinition, validateDefinition } from './types' import { ElectionState } from '@equal-vote/star-vote-shared/domain_model/ElectionStates' +import { BallotActionType } from '@equal-vote/star-vote-shared/domain_model/Ballot' const DAY_MS = 24 * 60 * 60 * 1000; @@ -176,7 +177,7 @@ async function main() { update_date: ms.toString(), date_submitted: ms, history: [{ - action_type: isUpload ? 'submitted_via_admin' : 'submitted_via_browser', + action_type: (isUpload ? 'submitted_via_admin' : 'submitted_via_browser') as BallotActionType, actor: isUpload ? 'devadmin' : '', timestamp: ms, }], diff --git a/packages/backend/src/test/TestHelper.ts b/packages/backend/src/test/TestHelper.ts index 2bfed1f0b..b30043fd5 100644 --- a/packages/backend/src/test/TestHelper.ts +++ b/packages/backend/src/test/TestHelper.ts @@ -226,6 +226,19 @@ export class TestHelper { return r.send({ ballot: ballot }); } + async uploadBallots( + electionId: Uid, + ballots: Array<{ ballot: any; voter_id: string }>, + raceOrder: any[], + userToken: string | null + ): Promise { + return this.postRequest( + `/API/Election/${electionId}/uploadBallots`, + { ballots, race_order: raceOrder }, + userToken + ); + } + async submitElectionRoll( electionId: Uid, electionRoll: any[], diff --git a/packages/backend/src/test/allowedSubmitTypes.test.ts b/packages/backend/src/test/allowedSubmitTypes.test.ts new file mode 100644 index 000000000..dcfdd9348 --- /dev/null +++ b/packages/backend/src/test/allowedSubmitTypes.test.ts @@ -0,0 +1,229 @@ +require("dotenv").config(); + +import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; +import { ElectionSettings, electionSettingsValidation, DEFAULT_ALLOWED_SUBMIT_TYPES } from "@equal-vote/star-vote-shared/domain_model/ElectionSettings"; +import { Race } from "@equal-vote/star-vote-shared/domain_model/Race"; +import testInputs from "./testInputs"; +import { TestHelper } from "./TestHelper"; + +const th = new TestHelper(); + +afterEach(() => { + jest.clearAllMocks(); + th.afterEach(); +}); + +// A simple open election with one STAR race and two candidates. +const makeElection = (allowed_submit_types?: string[]): Election => ({ + election_id: "0", + title: "Submit Types Test Election", + state: "open", + frontend_url: "", + owner_id: "Alice1234", + races: [ + { + race_id: "race0", + title: "Best Candidate", + num_winners: 1, + voting_method: "STAR", + candidates: [ + { candidate_id: "cand0", candidate_name: "Alice" }, + { candidate_id: "cand1", candidate_name: "Bob" }, + ], + }, + ] as Race[], + settings: { + voter_access: "open", + voter_authentication: {}, + ...(allowed_submit_types !== undefined ? { allowed_submit_types } : {}), + } as ElectionSettings, +} as Election); + +// race_order and a single ballot in ordered-vote format for the election above. +const RACE_ORDER = [{ race_id: "race0", candidate_id_order: ["cand0", "cand1"] }]; +// orderedVote = [aliceScore, bobScore, overvote_rank, has_duplicate_rank] +const ORDERED_BALLOT = { orderedVotes: [[5, 0, null, null]] }; + +describe("electionSettingsValidation — allowed_submit_types", () => { + test("accepts undefined (no field set, defaults apply)", () => { + const settings: ElectionSettings = { voter_access: "open", voter_authentication: {} }; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("accepts a non-empty array of valid types", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_admin"], + } as ElectionSettings; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("accepts all three valid types", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_browser", "submitted_via_admin", "submitted_via_discord"], + } as ElectionSettings; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("rejects an explicitly empty allowed_submit_types array", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: [], + } as ElectionSettings; + const err = electionSettingsValidation(settings); + expect(err).not.toBeNull(); + expect(err).toContain("empty"); + }); + + test("rejects an array containing an invalid type string", () => { + const settings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_browser", "submitted_via_pigeon"], + } as any; + const err = electionSettingsValidation(settings); + expect(err).not.toBeNull(); + }); + + test("DEFAULT_ALLOWED_SUBMIT_TYPES includes browser and discord, but not admin", () => { + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_browser"); + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_discord"); + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).not.toContain("submitted_via_admin"); + }); +}); + +describe("allowed_submit_types enforcement — admin ballot upload", () => { + test("admin ballot is accepted when allowed_submit_types includes submitted_via_admin", async () => { + const elec = makeElection(["submitted_via_browser", "submitted_via_admin"]); + const created = await th.createElection(elec, testInputs.user1token); + expect(created.statusCode).toBe(200); + const id = created.election.election_id; + + const res = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter1" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(res.statusCode).toBe(200); + expect(res.body.responses[0].success).toBe(true); + th.testComplete(); + }); + + test("admin ballot is rejected when allowed_submit_types excludes submitted_via_admin", async () => { + const elec = makeElection(["submitted_via_browser"]); + const created = await th.createElection(elec, testInputs.user1token); + expect(created.statusCode).toBe(200); + const id = created.election.election_id; + + const res = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter2" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(res.statusCode).toBe(200); + expect(res.body.responses[0].success).toBe(false); + th.testComplete(); + }); + + test("admin ballot is rejected by default (no allowed_submit_types field set)", async () => { + // Default is ['submitted_via_browser', 'submitted_via_discord'] — admin is not included. + const elec = makeElection(undefined); + const created = await th.createElection(elec, testInputs.user1token); + expect(created.statusCode).toBe(200); + const id = created.election.election_id; + + const res = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter3" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(res.statusCode).toBe(200); + expect(res.body.responses[0].success).toBe(false); + th.testComplete(); + }); + + test("admin ballot is accepted when election is in draft (bypass block skips the check)", async () => { + const draftElec: Election = { + ...makeElection(["submitted_via_browser"]), // admin NOT in allowed list + state: "draft", + } as Election; + const created = await th.createElection(draftElec, testInputs.user1token); + expect(created.statusCode).toBe(200); + const id = created.election.election_id; + + const res = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter4" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(res.statusCode).toBe(200); + expect(res.body.responses[0].success).toBe(true); + th.testComplete(); + }); + + test("allowed_submit_types check and already-voted check compose without interfering", async () => { + // Election that allows admin submissions with a closed voter roll. + const closedElec: Election = { + election_id: "0", + title: "Closed Admin Upload Election", + state: "open", + frontend_url: "", + owner_id: "Alice1234", + races: [ + { + race_id: "race0", + title: "Best Candidate", + num_winners: 1, + voting_method: "STAR", + candidates: [ + { candidate_id: "cand0", candidate_name: "Alice" }, + { candidate_id: "cand1", candidate_name: "Bob" }, + ], + }, + ] as Race[], + settings: { + voter_access: "closed", + voter_authentication: { voter_id: true }, + allowed_submit_types: ["submitted_via_browser", "submitted_via_admin"], + } as ElectionSettings, + } as Election; + + const created = await th.createElection(closedElec, testInputs.user1token); + expect(created.statusCode).toBe(200); + const id = created.election.election_id; + + // Add voter roll with voter5. + const rollRes = await th.submitElectionRoll(id, [{ voter_id: "voter5" }], testInputs.user1token); + expect(rollRes.statusCode).toBe(200); + + // First upload for voter5 should succeed (admin is allowed). + const firstUpload = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter5" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(firstUpload.statusCode).toBe(200); + expect(firstUpload.body.responses[0].success).toBe(true); + + // Second upload for voter5 should fail (already voted) — not because of allowed_submit_types. + const secondUpload = await th.uploadBallots( + id, + [{ ballot: ORDERED_BALLOT, voter_id: "voter5" }], + RACE_ORDER, + testInputs.user1token, + ); + expect(secondUpload.statusCode).toBe(200); + expect(secondUpload.body.responses[0].success).toBe(false); + + th.testComplete(); + }); +}); diff --git a/packages/shared/src/domain_model/Ballot.ts b/packages/shared/src/domain_model/Ballot.ts index 1d49b6d17..947eaee0d 100644 --- a/packages/shared/src/domain_model/Ballot.ts +++ b/packages/shared/src/domain_model/Ballot.ts @@ -5,6 +5,9 @@ import { Score } from "./Score"; import { Uid } from "./Uid"; import { OrderedVote, Vote } from "./Vote"; +export type BallotSubmitType = 'submitted_via_browser' | 'submitted_via_admin' | 'submitted_via_discord'; +export type BallotActionType = BallotSubmitType; + export interface NewBallotWithVoterID { voter_id: string; ballot: OrderedNewBallot; @@ -29,7 +32,7 @@ export interface RaceCandidateOrder { candidate_id_order: Uid[]; } -export interface NewBallot extends PartialBy {} +export interface NewBallot extends PartialBy {} export interface OrderedNewBallot extends PartialBy { orderedVotes: OrderedVote[] @@ -43,7 +46,7 @@ export interface AnonymizedBallot { } export interface BallotAction { - action_type:string; + action_type: BallotActionType; actor:Uid; timestamp:number; } diff --git a/packages/shared/src/domain_model/ElectionSettings.ts b/packages/shared/src/domain_model/ElectionSettings.ts index 9a4efeb1c..98cbd41e6 100644 --- a/packages/shared/src/domain_model/ElectionSettings.ts +++ b/packages/shared/src/domain_model/ElectionSettings.ts @@ -1,6 +1,7 @@ import { timeZones, TimeZone } from "./Util"; import { ElectionState } from "./ElectionStates" import { getVoterAuthenticationMode } from "./VoterAuthenticationMode"; +import { BallotSubmitType } from "./Ballot"; export interface registration_field { field_name: string; @@ -24,6 +25,8 @@ export type VoterAccess = typeof VoterAcessArray[number]; const InvitationTypes = ['email', 'address'] as const; export type InvitationType = typeof InvitationTypes[number]; +export const DEFAULT_ALLOWED_SUBMIT_TYPES: BallotSubmitType[] = ['submitted_via_browser', 'submitted_via_discord']; + export interface ElectionSettings { voter_access?: VoterAccess; // Who is able to vote in election? voter_authentication: authentication; // How will voters be authenticated? @@ -41,6 +44,7 @@ export interface ElectionSettings { contact_email?: string; // Public contact email for voters to reach out to exhaust_on_N_repeated_skipped_marks?: number; // number of skipped ranks before exhausting draggable_ballot?: boolean; // Use draggable interface for IRV ballots + allowed_submit_types?: BallotSubmitType[]; // Which submission channels are allowed for this election } function settingsCompatiblityValidation(settings: ElectionSettings, electionState?: ElectionState): string { let errorMsg = '' @@ -99,6 +103,19 @@ export function electionSettingsValidation(obj:ElectionSettings, electionState?: if (obj.draggable_ballot && typeof obj.draggable_ballot !== 'boolean'){ return "Invalid Draggable Ballot"; } + if (obj.allowed_submit_types !== undefined) { + if (!Array.isArray(obj.allowed_submit_types)) { + return "Invalid Allowed Submit Types"; + } + const validTypes: BallotSubmitType[] = ['submitted_via_browser', 'submitted_via_admin', 'submitted_via_discord']; + if (!obj.allowed_submit_types.every((t: string) => validTypes.includes(t as BallotSubmitType))) { + return "Invalid Allowed Submit Types value"; + } + const resolved = obj.allowed_submit_types ?? DEFAULT_ALLOWED_SUBMIT_TYPES; + if (resolved.length === 0) { + return "allowed_submit_types must not be empty"; + } + } const compatibilityError = settingsCompatiblityValidation(obj, electionState); if (compatibilityError) { From 059fb86b7b929a9ad16bbbf0754303cc2417edac Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:43:03 +0000 Subject: [PATCH 06/12] Refactor allowed_submit_types validation - Extract BALLOT_SUBMIT_TYPES as a const array in Ballot.ts so the BallotSubmitType union is derived from a single source of truth - Consume BALLOT_SUBMIT_TYPES in electionSettingsValidation instead of re-declaring the list locally - Fix dead code in the empty-array check (the `?? DEFAULT_ALLOWED_SUBMIT_TYPES` fallback was unreachable inside `if (obj.allowed_submit_types !== undefined)`) - Reorder the checks so the empty-array error triggers before value validation Co-Authored-By: Claude Opus 4.7 --- packages/shared/src/domain_model/Ballot.ts | 3 ++- .../shared/src/domain_model/ElectionSettings.ts | 13 ++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/domain_model/Ballot.ts b/packages/shared/src/domain_model/Ballot.ts index 947eaee0d..70f9b6cbe 100644 --- a/packages/shared/src/domain_model/Ballot.ts +++ b/packages/shared/src/domain_model/Ballot.ts @@ -5,7 +5,8 @@ import { Score } from "./Score"; import { Uid } from "./Uid"; import { OrderedVote, Vote } from "./Vote"; -export type BallotSubmitType = 'submitted_via_browser' | 'submitted_via_admin' | 'submitted_via_discord'; +export const BALLOT_SUBMIT_TYPES = ['submitted_via_browser', 'submitted_via_admin', 'submitted_via_discord'] as const; +export type BallotSubmitType = typeof BALLOT_SUBMIT_TYPES[number]; export type BallotActionType = BallotSubmitType; export interface NewBallotWithVoterID { diff --git a/packages/shared/src/domain_model/ElectionSettings.ts b/packages/shared/src/domain_model/ElectionSettings.ts index 98cbd41e6..48e75f984 100644 --- a/packages/shared/src/domain_model/ElectionSettings.ts +++ b/packages/shared/src/domain_model/ElectionSettings.ts @@ -1,7 +1,7 @@ import { timeZones, TimeZone } from "./Util"; import { ElectionState } from "./ElectionStates" import { getVoterAuthenticationMode } from "./VoterAuthenticationMode"; -import { BallotSubmitType } from "./Ballot"; +import { BALLOT_SUBMIT_TYPES, BallotSubmitType } from "./Ballot"; export interface registration_field { field_name: string; @@ -107,14 +107,13 @@ export function electionSettingsValidation(obj:ElectionSettings, electionState?: if (!Array.isArray(obj.allowed_submit_types)) { return "Invalid Allowed Submit Types"; } - const validTypes: BallotSubmitType[] = ['submitted_via_browser', 'submitted_via_admin', 'submitted_via_discord']; - if (!obj.allowed_submit_types.every((t: string) => validTypes.includes(t as BallotSubmitType))) { - return "Invalid Allowed Submit Types value"; - } - const resolved = obj.allowed_submit_types ?? DEFAULT_ALLOWED_SUBMIT_TYPES; - if (resolved.length === 0) { + if (obj.allowed_submit_types.length === 0) { return "allowed_submit_types must not be empty"; } + const validTypes: readonly string[] = BALLOT_SUBMIT_TYPES; + if (!obj.allowed_submit_types.every((t: string) => validTypes.includes(t))) { + return "Invalid Allowed Submit Types value"; + } } const compatibilityError = settingsCompatiblityValidation(obj, electionState); From 0e7e1b4bd5e56279533bd3776f9fd3d6fe5ab759 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 12:05:52 -0700 Subject: [PATCH 07/12] Delete dev-docs/adr/0003-dual-gate-submission-channel-design.md --- ...003-dual-gate-submission-channel-design.md | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 dev-docs/adr/0003-dual-gate-submission-channel-design.md diff --git a/dev-docs/adr/0003-dual-gate-submission-channel-design.md b/dev-docs/adr/0003-dual-gate-submission-channel-design.md deleted file mode 100644 index 920d1b966..000000000 --- a/dev-docs/adr/0003-dual-gate-submission-channel-design.md +++ /dev/null @@ -1,33 +0,0 @@ -# Dual-gate submission-channel design: role permission + election setting, checked independently - -## Problem - -The ballot-upload pipeline needs two distinct controls simultaneously: - -1. **Who may upload** — only election owners and system admins should ever be able to bulk-upload ballots into an election. This is already enforced by the existing `canUploadBallots` role permission. -2. **Whether this election allows it** — an election owner should be able to configure, per election, whether paper-ballot admin upload is an accepted submission channel. Without this, the role permission is either too permissive (every owner can inject ballots into any election they own, even one they never intended to accept paper ballots for) or too restrictive (locking out the pattern entirely unless the owner is a system admin). - -The straightforward alternatives both fail: - -- **Merge both controls into the role permission alone** — gives every election owner the ability to upload ballots for any election they own, with no per-election opt-in. An owner who set up an online-only election would have no way to prevent admin ballot injection into it. -- **Merge both controls into the election setting alone** — means a non-owner filling in for the owner (e.g. a system admin assisting with a batch upload) would be blocked the moment the setting isn't tailored to them specifically. Role-based access control is the right tool for "who may do this at all." - -## Decision - -Use two independent checks rather than collapsing them into one: - -- **`canUploadBallots` role permission** (already existing, unchanged) — answers "is this user allowed to upload ballots at all?" System admins and election owners pass; everyone else is rejected before any other check runs. -- **`allowed_submit_types` election setting** (new, on `ElectionSettings`) — answers "does this election accept paper-ballot admin upload as a channel?" Checked in the ballot-submission pipeline after the role gate, rejecting with a 400 if the incoming submission type isn't in the election's configured set. - -Passing both gates is required to upload a ballot. Neither replaces the other. The setting is evaluated with a runtime fallback (`election.settings.allowed_submit_types ?? DEFAULT_ALLOWED_SUBMIT_TYPES`) so that elections created before the field existed behave consistently — paper-ballot upload is **off by default**; online browser submission and (future) Discord are on by default. - -The setting is locked once the election leaves draft, so the submission-channel rules can't change after voting has started. - -## Why independent rather than merged - -The two checks answer categorically different questions. A role permission governs identity ("is this principal trusted to perform this class of action?"). An election setting governs policy ("has this election been configured to allow this class of input?"). Merging them into one check would conflate identity and policy in a way that makes the system harder to reason about and creates edge cases: - -- If only the role gate existed, every election owner could upload ballots into any of their elections regardless of whether paper-ballot intake was ever intended — silently. -- If only the election setting existed, a system admin helping upload into an election that has the setting enabled would need the setting to somehow encode their identity separately. That's what role-based access control is for. - -Keeping the gates independent also means each gate can change independently: the role permission can be broadened or narrowed (e.g. delegated per-election to a designated ballot-handler role) without touching the election-settings schema, and the election setting can gain new channel types (Discord, future channels) without touching role-permission logic. From 6cd6d62762b619758ffa057a43f93b0e34d6011f Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 17:30:07 -0700 Subject: [PATCH 08/12] Address PR #1611 review comments - Restore package-lock.json libc metadata accidentally stripped by the branch - Document why BallotActionType currently mirrors BallotSubmitType - Fix encodeBallotRow test to also assert race2Vote when all races are missing - Reuse testInputs.Election1 as the base for allowedSubmitTypes.test's makeElection - Move electionSettingsValidation unit tests from the backend to the shared package, next to the function they test, adding vitest as its test runner - Switch frontend unit tests from jest to vitest, since frontend never declared its own jest/ts-jest dependency (it only worked via hoisting from backend's devDependencies) - Fix lint-staged failures uncovered while touching these files: unused imports/empty interface in Ballot.ts, and a require()-style dotenv import in allowedSubmitTypes.test.ts Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A2V3GyzesaLkqYxrYLF4cr --- package-lock.json | 1281 ++++++++++++++++- .../src/test/allowedSubmitTypes.test.ts | 81 +- packages/frontend/jest.config.cjs | 15 - packages/frontend/package.json | 5 +- .../Election/Upload/uploadUtils.test.ts | 5 + packages/frontend/tsconfig.test.json | 11 - packages/frontend/vitest.config.ts | 13 + packages/shared/package.json | 7 +- packages/shared/src/domain_model/Ballot.ts | 9 +- .../src/domain_model/ElectionSettings.test.ts | 54 + packages/shared/tsconfig.json | 3 +- packages/shared/vitest.config.mts | 7 + 12 files changed, 1368 insertions(+), 123 deletions(-) delete mode 100644 packages/frontend/jest.config.cjs delete mode 100644 packages/frontend/tsconfig.test.json create mode 100644 packages/frontend/vitest.config.ts create mode 100644 packages/shared/src/domain_model/ElectionSettings.test.ts create mode 100644 packages/shared/vitest.config.mts diff --git a/package-lock.json b/package-lock.json index 74683ea61..c357b9e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2169,6 +2169,17 @@ ], "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "dev": true, @@ -2251,6 +2262,302 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@rsbuild/core": { "version": "1.7.5", "dev": true, @@ -2387,6 +2694,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2401,6 +2711,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2414,6 +2727,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2427,6 +2743,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2525,6 +2844,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2537,6 +2859,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2549,6 +2874,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2749,6 +3077,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/compression": { "version": "1.8.1", "dev": true, @@ -2839,6 +3178,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "dev": true, @@ -3416,12 +3762,25 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ "linux" ] }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "dev": true, @@ -3737,6 +4096,16 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4310,6 +4679,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -5018,6 +5397,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "dev": true, @@ -5412,6 +5802,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "license": "MIT", @@ -5817,10 +6214,20 @@ "node": ">=4.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "license": "BSD-2-Clause", - "engines": { + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { "node": ">=0.10.0" } }, @@ -5904,6 +6311,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "license": "MIT", @@ -8292,6 +8709,291 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "license": "MIT" @@ -8444,6 +9146,16 @@ "node": ">=12" } }, + "node_modules/magic-string": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "4.0.0", "dev": true, @@ -9410,7 +10122,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -9616,6 +10330,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -10125,7 +10853,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -10142,7 +10872,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10830,6 +11560,41 @@ "version": "5.1.2", "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "dev": true, @@ -11259,6 +12024,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "dev": true, @@ -11387,6 +12159,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "dev": true, @@ -11399,6 +12178,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -11950,6 +12736,16 @@ "version": "1.3.3", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyexec": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", @@ -11961,7 +12757,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -12774,12 +13572,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/word-wrap": { - "version": "1.2.5", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, "engines": { - "node": ">=0.10.0" + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/wordwrap": { @@ -13090,7 +13905,8 @@ "globals": "^16.0.0", "rimraf": "^5.0.5", "typescript": "^5.9.0", - "typescript-eslint": "^8.30.1" + "typescript-eslint": "^8.30.1", + "vitest": "^5.0.0" } }, "packages/frontend/node_modules/@types/node": { @@ -13101,11 +13917,230 @@ "undici-types": "~7.16.0" } }, + "packages/frontend/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "packages/frontend/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "packages/frontend/node_modules/undici-types": { "version": "7.16.0", "dev": true, "license": "MIT" }, + "packages/frontend/node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/frontend/node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/frontend/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "packages/shared": { "name": "@equal-vote/star-vote-shared", "version": "1.0.0", @@ -13120,11 +14155,231 @@ "rimraf": "^5.0.5", "typescript": "^5.9.0", "typescript-eslint": "^8.65.0", - "typescript-json-schema": "^0.65.1" + "typescript-json-schema": "^0.65.1", + "vitest": "^5.0.0" }, "peerDependencies": { "typescript": "^5.9.0" } + }, + "packages/shared/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "packages/shared/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/shared/node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "packages/shared/node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "packages/shared/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } } } } diff --git a/packages/backend/src/test/allowedSubmitTypes.test.ts b/packages/backend/src/test/allowedSubmitTypes.test.ts index dcfdd9348..a583b1db5 100644 --- a/packages/backend/src/test/allowedSubmitTypes.test.ts +++ b/packages/backend/src/test/allowedSubmitTypes.test.ts @@ -1,7 +1,7 @@ -require("dotenv").config(); +import 'dotenv/config'; import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; -import { ElectionSettings, electionSettingsValidation, DEFAULT_ALLOWED_SUBMIT_TYPES } from "@equal-vote/star-vote-shared/domain_model/ElectionSettings"; +import { ElectionSettings } from "@equal-vote/star-vote-shared/domain_model/ElectionSettings"; import { Race } from "@equal-vote/star-vote-shared/domain_model/Race"; import testInputs from "./testInputs"; import { TestHelper } from "./TestHelper"; @@ -13,13 +13,11 @@ afterEach(() => { th.afterEach(); }); -// A simple open election with one STAR race and two candidates. +// Based on testInputs.Election1, but opened and given a STAR race with two candidates. const makeElection = (allowed_submit_types?: string[]): Election => ({ - election_id: "0", + ...testInputs.Election1, title: "Submit Types Test Election", state: "open", - frontend_url: "", - owner_id: "Alice1234", races: [ { race_id: "race0", @@ -44,57 +42,9 @@ const RACE_ORDER = [{ race_id: "race0", candidate_id_order: ["cand0", "cand1"] } // orderedVote = [aliceScore, bobScore, overvote_rank, has_duplicate_rank] const ORDERED_BALLOT = { orderedVotes: [[5, 0, null, null]] }; -describe("electionSettingsValidation — allowed_submit_types", () => { - test("accepts undefined (no field set, defaults apply)", () => { - const settings: ElectionSettings = { voter_access: "open", voter_authentication: {} }; - expect(electionSettingsValidation(settings)).toBeNull(); - }); - - test("accepts a non-empty array of valid types", () => { - const settings: ElectionSettings = { - voter_access: "open", - voter_authentication: {}, - allowed_submit_types: ["submitted_via_admin"], - } as ElectionSettings; - expect(electionSettingsValidation(settings)).toBeNull(); - }); - - test("accepts all three valid types", () => { - const settings: ElectionSettings = { - voter_access: "open", - voter_authentication: {}, - allowed_submit_types: ["submitted_via_browser", "submitted_via_admin", "submitted_via_discord"], - } as ElectionSettings; - expect(electionSettingsValidation(settings)).toBeNull(); - }); - - test("rejects an explicitly empty allowed_submit_types array", () => { - const settings: ElectionSettings = { - voter_access: "open", - voter_authentication: {}, - allowed_submit_types: [], - } as ElectionSettings; - const err = electionSettingsValidation(settings); - expect(err).not.toBeNull(); - expect(err).toContain("empty"); - }); - - test("rejects an array containing an invalid type string", () => { - const settings = { - voter_access: "open", - voter_authentication: {}, - allowed_submit_types: ["submitted_via_browser", "submitted_via_pigeon"], - } as any; - const err = electionSettingsValidation(settings); - expect(err).not.toBeNull(); - }); - - test("DEFAULT_ALLOWED_SUBMIT_TYPES includes browser and discord, but not admin", () => { - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_browser"); - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_discord"); - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).not.toContain("submitted_via_admin"); - }); -}); +// electionSettingsValidation unit tests for allowed_submit_types live in +// packages/shared/src/domain_model/ElectionSettings.test.ts, next to the +// function they test. describe("allowed_submit_types enforcement — admin ballot upload", () => { test("admin ballot is accepted when allowed_submit_types includes submitted_via_admin", async () => { @@ -172,23 +122,8 @@ describe("allowed_submit_types enforcement — admin ballot upload", () => { test("allowed_submit_types check and already-voted check compose without interfering", async () => { // Election that allows admin submissions with a closed voter roll. const closedElec: Election = { - election_id: "0", + ...makeElection(["submitted_via_browser", "submitted_via_admin"]), title: "Closed Admin Upload Election", - state: "open", - frontend_url: "", - owner_id: "Alice1234", - races: [ - { - race_id: "race0", - title: "Best Candidate", - num_winners: 1, - voting_method: "STAR", - candidates: [ - { candidate_id: "cand0", candidate_name: "Alice" }, - { candidate_id: "cand1", candidate_name: "Bob" }, - ], - }, - ] as Race[], settings: { voter_access: "closed", voter_authentication: { voter_id: true }, diff --git a/packages/frontend/jest.config.cjs b/packages/frontend/jest.config.cjs deleted file mode 100644 index 570cc4076..000000000 --- a/packages/frontend/jest.config.cjs +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = { - transform: { - '^.+\\.[tj]sx?$': [ - 'ts-jest', - { - tsconfig: './tsconfig.test.json', - }, - ], - }, - moduleNameMapper: { - '^~/(.*)$': '/src/$1', - }, - testPathIgnorePatterns: ['/node_modules/', '/build/'], - testMatch: ['**/*.test.ts', '**/*.test.tsx'], -}; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e693111f6..61da53925 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -37,7 +37,7 @@ "build": "tsc && rsbuild build", "start": "rsbuild preview", "lint": "eslint . --fix", - "test": "jest --config jest.config.cjs" + "test": "vitest run" }, "browserslist": { "production": [ @@ -69,6 +69,7 @@ "globals": "^16.0.0", "rimraf": "^5.0.5", "typescript": "^5.9.0", - "typescript-eslint": "^8.30.1" + "typescript-eslint": "^8.30.1", + "vitest": "^5.0.0" } } diff --git a/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts b/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts index 925a59285..ccfdffbee 100644 --- a/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts @@ -1,3 +1,4 @@ +import { describe, it, expect } from 'vitest'; import { computeRaceOrder, encodeBallotRow } from './uploadUtils'; import { Election } from '@equal-vote/star-vote-shared/domain_model/Election'; import { NewBallot, RaceCandidateOrder } from '@equal-vote/star-vote-shared/domain_model/Ballot'; @@ -115,6 +116,10 @@ describe('encodeBallotRow', () => { const race1Vote = result.orderedVotes[0]; expect(race1Vote[0]).toBeNull(); expect(race1Vote[1]).toBeNull(); + const race2Vote = result.orderedVotes[1]; + expect(race2Vote[0]).toBeNull(); + expect(race2Vote[1]).toBeNull(); + expect(race2Vote[2]).toBeNull(); }); it('preserves non-votes fields from the row', () => { diff --git a/packages/frontend/tsconfig.test.json b/packages/frontend/tsconfig.test.json deleted file mode 100644 index f3429eb9f..000000000 --- a/packages/frontend/tsconfig.test.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", - "lib": ["ES2022"], - "types": ["jest", "node"], - "isolatedModules": true - }, - "exclude": [] -} diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts new file mode 100644 index 000000000..bff0784bf --- /dev/null +++ b/packages/frontend/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '~': path.resolve(import.meta.dirname, './src'), + }, + }, + test: { + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + }, +}); diff --git a/packages/shared/package.json b/packages/shared/package.json index d8411d986..a0e3d7bf0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,7 +5,7 @@ "main": "./dist/index.js", "types": "./dist/types/index.d.ts", "scripts": { - "test": "npm install && npm test", + "test": "vitest run", "clean": "npx rimraf dist node_modules ../node_modules", "build": "tsc --project ./ && npm run generate:schema", "generate:schema": "typescript-json-schema tsconfig.json \"*\" --required --out dist/schema.json && node ./fixSchemaRefs.ts", @@ -15,13 +15,14 @@ "author": "", "license": "ISC", "devDependencies": { - "@types/sanitize-html": "^2.13.0", "@eslint/js": "^9.39.5", + "@types/sanitize-html": "^2.13.0", "eslint": "^9.39.5", "rimraf": "^5.0.5", "typescript": "^5.9.0", + "typescript-eslint": "^8.65.0", "typescript-json-schema": "^0.65.1", - "typescript-eslint": "^8.65.0" + "vitest": "^5.0.0" }, "peerDependencies": { "typescript": "^5.9.0" diff --git a/packages/shared/src/domain_model/Ballot.ts b/packages/shared/src/domain_model/Ballot.ts index 70f9b6cbe..1ac145517 100644 --- a/packages/shared/src/domain_model/Ballot.ts +++ b/packages/shared/src/domain_model/Ballot.ts @@ -1,12 +1,12 @@ import { Election, PartialBy, getApprovedRaces } from "./Election"; -import { ElectionRoll } from "./ElectionRoll"; import { Race } from "./Race"; -import { Score } from "./Score"; import { Uid } from "./Uid"; import { OrderedVote, Vote } from "./Vote"; export const BALLOT_SUBMIT_TYPES = ['submitted_via_browser', 'submitted_via_admin', 'submitted_via_discord'] as const; export type BallotSubmitType = typeof BALLOT_SUBMIT_TYPES[number]; +// BallotActionType currently mirrors BallotSubmitType 1:1 because submitting is the only ballot action we track. +// Once other action types exist (e.g. edits, retractions), this should become its own union that includes them. export type BallotActionType = BallotSubmitType; export interface NewBallotWithVoterID { @@ -33,7 +33,7 @@ export interface RaceCandidateOrder { candidate_id_order: Uid[]; } -export interface NewBallot extends PartialBy {} +export type NewBallot = PartialBy; export interface OrderedNewBallot extends PartialBy { orderedVotes: OrderedVote[] @@ -118,10 +118,9 @@ export function ballotValidation(election: Election, obj:NewBallot): string | nu }) if (['RankedRobin', 'IRV', 'STV'].includes(race.voting_method)) { - const numCandidates = race.candidates.length; vote.scores.forEach(score => { // Arend: Removing check against numCandidates, that's not necessarily true for public RCV elections - if (score && score.score !== null && (/*score.score > numCandidates ||*/ (maxRankings && score.score > maxRankings) || score.score < 0)) { + if (score && score.score !== null && ((maxRankings && score.score > maxRankings) || score.score < 0)) { outOfBoundsError += `Race: ${race.title}, Score: ${score.score}; `; } }) diff --git a/packages/shared/src/domain_model/ElectionSettings.test.ts b/packages/shared/src/domain_model/ElectionSettings.test.ts new file mode 100644 index 000000000..4e600b6a3 --- /dev/null +++ b/packages/shared/src/domain_model/ElectionSettings.test.ts @@ -0,0 +1,54 @@ +import { describe, test, expect } from 'vitest'; +import { ElectionSettings, electionSettingsValidation, DEFAULT_ALLOWED_SUBMIT_TYPES } from "./ElectionSettings"; + +describe("electionSettingsValidation — allowed_submit_types", () => { + test("accepts undefined (no field set, defaults apply)", () => { + const settings: ElectionSettings = { voter_access: "open", voter_authentication: {} }; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("accepts a non-empty array of valid types", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_admin"], + } as ElectionSettings; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("accepts all three valid types", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_browser", "submitted_via_admin", "submitted_via_discord"], + } as ElectionSettings; + expect(electionSettingsValidation(settings)).toBeNull(); + }); + + test("rejects an explicitly empty allowed_submit_types array", () => { + const settings: ElectionSettings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: [], + } as ElectionSettings; + const err = electionSettingsValidation(settings); + expect(err).not.toBeNull(); + expect(err).toContain("empty"); + }); + + test("rejects an array containing an invalid type string", () => { + const settings = { + voter_access: "open", + voter_authentication: {}, + allowed_submit_types: ["submitted_via_browser", "submitted_via_pigeon"], + } as unknown as ElectionSettings; + const err = electionSettingsValidation(settings); + expect(err).not.toBeNull(); + }); + + test("DEFAULT_ALLOWED_SUBMIT_TYPES includes browser and discord, but not admin", () => { + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_browser"); + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_discord"); + expect(DEFAULT_ALLOWED_SUBMIT_TYPES).not.toContain("submitted_via_admin"); + }); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 6a3b2262e..63b9e17b5 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -18,5 +18,6 @@ "declarationMap": true, "declarationDir": "./dist/types" }, - "include": ["./src/**/*"] + "include": ["./src/**/*"], + "exclude": ["./src/**/*.test.ts"] } diff --git a/packages/shared/vitest.config.mts b/packages/shared/vitest.config.mts new file mode 100644 index 000000000..48efbcd84 --- /dev/null +++ b/packages/shared/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); From 1eb60eb2b18e90cbd49ab2ed0bf7e2c5bdffd9dc Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:16:06 -0700 Subject: [PATCH 09/12] Remove test for DEFAULT_ALLOWED_SUBMIT_TYPES Removed test for DEFAULT_ALLOWED_SUBMIT_TYPES. --- packages/shared/src/domain_model/ElectionSettings.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/shared/src/domain_model/ElectionSettings.test.ts b/packages/shared/src/domain_model/ElectionSettings.test.ts index 4e600b6a3..60450df62 100644 --- a/packages/shared/src/domain_model/ElectionSettings.test.ts +++ b/packages/shared/src/domain_model/ElectionSettings.test.ts @@ -45,10 +45,4 @@ describe("electionSettingsValidation — allowed_submit_types", () => { const err = electionSettingsValidation(settings); expect(err).not.toBeNull(); }); - - test("DEFAULT_ALLOWED_SUBMIT_TYPES includes browser and discord, but not admin", () => { - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_browser"); - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).toContain("submitted_via_discord"); - expect(DEFAULT_ALLOWED_SUBMIT_TYPES).not.toContain("submitted_via_admin"); - }); }); From 6c217af90b9204bec75845b915c6ab389e1526e7 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:17:57 -0700 Subject: [PATCH 10/12] Wire frontend and shared unit tests into the root test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root "test" script only ever ran the backend suite (cd packages/backend && npm test), so CI (npm test in node.js.yml) and the pre-push hook (which runs the same command) never executed the frontend or shared tests, even after this branch added real ones. Switch to `npm test -ws --if-present` so it runs test in every workspace that defines one, in workspace order (shared, backend, frontend) — same entry points, now covering all three packages. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A2V3GyzesaLkqYxrYLF4cr --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e79577b0..a104356e1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "prepare": "husky || true # https://typicode.github.io/husky/how-to.html#ci-server-and-docker", "clean": "npx rimraf node_modules", "clean:ws": "npm run clean -ws && npm run clean", - "test": "cd packages/backend && npm install && npm test", + "test": "npm test -ws --if-present", "makedevelections": "tsx ./packages/backend/src/DevElections/makeDevElections.ts", "start": "./replace.sh && node ./packages/backend/build/src/Migrators/migrate-to-latest.js && node ./packages/backend/build/src/index.js", "heroku-postbuild": "cd packages/frontend && npm install && npm run build && cd ../backend && npm install && npm run-script build", From 6c94405b9523c84c432674a618f42ae5ddb897b8 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Tue, 8 Sep 2026 18:21:27 -0700 Subject: [PATCH 11/12] Drop now-unused DEFAULT_ALLOWED_SUBMIT_TYPES import Rebasing onto the removal of the DEFAULT_ALLOWED_SUBMIT_TYPES test left the import unused, which fails lint. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A2V3GyzesaLkqYxrYLF4cr --- packages/shared/src/domain_model/ElectionSettings.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/domain_model/ElectionSettings.test.ts b/packages/shared/src/domain_model/ElectionSettings.test.ts index 60450df62..7b556228a 100644 --- a/packages/shared/src/domain_model/ElectionSettings.test.ts +++ b/packages/shared/src/domain_model/ElectionSettings.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { ElectionSettings, electionSettingsValidation, DEFAULT_ALLOWED_SUBMIT_TYPES } from "./ElectionSettings"; +import { ElectionSettings, electionSettingsValidation } from "./ElectionSettings"; describe("electionSettingsValidation — allowed_submit_types", () => { test("accepts undefined (no field set, defaults apply)", () => { From c22d2d3e7b307e45feb4f9b041fd416efb9fb407 Mon Sep 17 00:00:00 2001 From: Arend Peter Castelein Date: Wed, 9 Sep 2026 12:00:56 -0700 Subject: [PATCH 12/12] Fix lint:diff errors touched by upload-ballots-refactor Cleans up eslint errors (no-explicit-any, no-unused-vars, no-require-imports, prefer-const, no-wrapper-object-types, no-empty-object-type) in files this branch modifies, since lint:diff lints whole changed files rather than just diff hunks. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QWJJYaaaBTSMwGvfpAQwh7 --- .../Controllers/Ballot/castVoteController.ts | 42 ++++++------- .../src/DevElections/makeDevElections.ts | 3 +- packages/backend/src/test/TestHelper.ts | 61 ++++++++++--------- packages/shared/src/domain_model/Election.ts | 8 +-- .../src/domain_model/ElectionSettings.ts | 11 ++-- 5 files changed, 63 insertions(+), 62 deletions(-) diff --git a/packages/backend/src/Controllers/Ballot/castVoteController.ts b/packages/backend/src/Controllers/Ballot/castVoteController.ts index 773628008..bd6f8643b 100644 --- a/packages/backend/src/Controllers/Ballot/castVoteController.ts +++ b/packages/backend/src/Controllers/Ballot/castVoteController.ts @@ -1,5 +1,4 @@ import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; -import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/ElectionRoll"; import { Ballot, BallotSubmitType, ballotValidation, NewBallot, OrderedNewBallot, RaceCandidateOrder } from '@equal-vote/star-vote-shared/domain_model/Ballot'; import { DEFAULT_ALLOWED_SUBMIT_TYPES } from '@equal-vote/star-vote-shared/domain_model/ElectionSettings'; import ServiceLocator from "../../ServiceLocator"; @@ -7,12 +6,11 @@ import Logger from "../../Services/Logging/Logger"; import { BadRequest, Conflict, InternalServerError, Unauthorized } from "@curveball/http-errors"; import { ILoggingContext } from "../../Services/Logging/ILogger"; import { randomUUID } from "crypto"; -import { Uid } from "@equal-vote/star-vote-shared/domain_model/Uid"; import { Receipt } from "../../Services/Email/EmailTemplates" import { getOrCreateElectionRoll, checkForMissingAuthenticationData, getVoterAuthorization } from "../Roll/voterRollUtils" import { innerGetGlobalElectionStats } from "../Election"; import { IElectionRequest } from "../../IRequest"; -import { Response, NextFunction } from 'express'; +import { Response } from 'express'; import { io } from "../../socketHandler"; import { Server } from "socket.io"; import { expectPermission } from "../controllerUtils"; @@ -78,7 +76,7 @@ async function makeBallotEvent(req: IElectionRequest, targetElection: Election, if (targetElection.settings.ballot_updates && targetElection.state !== 'draft') { try { updatableBallot = await BallotModel.getBallotByVoterID(roll!.voter_id, inputBallot.election_id, req); - } catch(e: any) { + } catch(e: unknown) { const msg = "Error searching for prior ballot"; Logger.error(req, msg, e); throw new InternalServerError(msg); @@ -132,12 +130,12 @@ const mapOrderedNewBallot = (ballot: OrderedNewBallot, raceOrder: RaceCandidateO ...subBallot, votes: orderedVotesToVotes(orderedVotes, raceOrder) } - } catch (err: any) { + } catch (err: unknown) { if (err instanceof OrderedVoteFormatError) throw new BadRequest(err.message); throw err; } } -async function uploadBallotsController(req: IElectionRequest, res: Response, next: NextFunction) { +async function uploadBallotsController(req: IElectionRequest, res: Response) { Logger.info(req, "Upload Ballots Controller"); expectPermission(req.user_auth.roles, permissions.canUploadBallots); @@ -151,7 +149,7 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex throw new BadRequest(errMsg); } - let events = await Promise.all( + const events = await Promise.all( req.body.ballots.map(({ballot, voter_id} : {ballot: OrderedNewBallot, voter_id: string}) => makeBallotEvent( req, @@ -169,7 +167,7 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex ) ); - let output = events.map((event, i) => ({ + const output = events.map((event, i) => ({ voter_id: req.body.ballots[i].voter_id, success: !('error' in event), message: ('error' in event)? event.error : 'Success' @@ -187,19 +185,20 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex `Admin submits a ballot for prior election` ) } else { - const validEvents = events.filter((event: any) => !('error' in event)) as CastVoteEvent[]; + const validEvents = events.filter((event) => !('error' in event)) as CastVoteEvent[]; const successfullySavedEvents: CastVoteEvent[] = []; for (const event of validEvents) { const ctx = Logger.createContext(event.requestId); try { await ServiceLocator.castVoteStore().submitBallotEvent(event, ctx); successfullySavedEvents.push(event); - } catch (e: any) { - Logger.error(req, `Could not upload ballot for ${event.roll?.voter_id || event.inputBallot.user_id || 'unknown'}: ${e.message}`); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + Logger.error(req, `Could not upload ballot for ${event.roll?.voter_id || event.inputBallot.user_id || 'unknown'}: ${message}`); const index = events.indexOf(event); if (index !== -1) { output[index].success = false; - output[index].message = e.message; + output[index].message = message; } } } @@ -207,9 +206,9 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex await (await EventQueue).publishBatch(castVoteEventQueue, successfullySavedEvents); } } - }catch(err: any){ + }catch(err: unknown){ const msg = `Could not upload ballots`; - Logger.error(req, `${msg}: ${err.message}`); + Logger.error(req, `${msg}: ${err instanceof Error ? err.message : String(err)}`); throw new InternalServerError(msg) } @@ -221,7 +220,7 @@ async function uploadBallotsController(req: IElectionRequest, res: Response, nex Logger.debug(req, "CastVoteController done, saved event to store"); }; -async function castVoteController(req: IElectionRequest, res: Response, next: NextFunction) { +async function castVoteController(req: IElectionRequest, res: Response) { Logger.info(req, "Cast Vote Controller"); const targetElection = req.election; @@ -236,20 +235,21 @@ async function castVoteController(req: IElectionRequest, res: Response, next: Ne throw new BadRequest("Election is not open"); } - let event = await makeBallotEvent(req, targetElection, req.body.ballot, 'submitted_via_browser') + const event = await makeBallotEvent(req, targetElection, req.body.ballot, 'submitted_via_browser') event.userEmail = event.roll?.email ?? AccountService.extractUserFromRequest(req)?.email ?? req.body.receiptEmail; const ctx = Logger.createContext(event.requestId); try { await ServiceLocator.castVoteStore().submitBallotEvent(event, ctx); - } catch (e: any) { - if (e.message === "ALREADY_VOTED") { + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + if (message === "ALREADY_VOTED") { Logger.info(req, "Ballot Rejected. User has already voted."); throw new BadRequest("User has already voted"); } - if (e.message === "CONCURRENT_BALLOT_UPDATE_DETECTED" || e.message === "CONCURRENT_ROLL_EDIT_DETECTED") { - Logger.info(req, `Ballot Rejected: ${e.message}`); + if (message === "CONCURRENT_BALLOT_UPDATE_DETECTED" || message === "CONCURRENT_ROLL_EDIT_DETECTED") { + Logger.info(req, `Ballot Rejected: ${message}`); throw new Conflict("Concurrent edit detected, please retry."); } throw e; @@ -290,7 +290,7 @@ async function handleCastVoteEvent(job: { id: string; data: CastVoteEvent; }):Pr } } -function assertVoterMayVote(voterAuthorization:any, election: Election, ctx:ILoggingContext ): void{ +function assertVoterMayVote(voterAuthorization: ReturnType, election: Election, ctx:ILoggingContext ): void{ Logger.debug(ctx, "assert voter may vote"); if (voterAuthorization.authorized_voter === false){ throw new Unauthorized("User not authorized to vote"); diff --git a/packages/backend/src/DevElections/makeDevElections.ts b/packages/backend/src/DevElections/makeDevElections.ts index dd54c6522..1390c4c4d 100644 --- a/packages/backend/src/DevElections/makeDevElections.ts +++ b/packages/backend/src/DevElections/makeDevElections.ts @@ -1,5 +1,6 @@ import * as path from 'path' -require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }) +import dotenv from 'dotenv' +dotenv.config({ path: path.resolve(__dirname, '../../.env') }) import servicelocator from '../ServiceLocator' import { DevElectionDefinition, validateDefinition } from './types' diff --git a/packages/backend/src/test/TestHelper.ts b/packages/backend/src/test/TestHelper.ts index b30043fd5..3f87783a5 100644 --- a/packages/backend/src/test/TestHelper.ts +++ b/packages/backend/src/test/TestHelper.ts @@ -1,4 +1,4 @@ -import { Ballot, NewBallot } from "@equal-vote/star-vote-shared/domain_model/Ballot"; +import { Ballot, NewBallot, OrderedNewBallot, RaceCandidateOrder } from "@equal-vote/star-vote-shared/domain_model/Ballot"; import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; import { Uid } from "@equal-vote/star-vote-shared/domain_model/Uid"; import { VoterAuth } from "@equal-vote/star-vote-shared/domain_model/VoterAuth"; @@ -6,13 +6,12 @@ import makeApp from "../app"; import Logger from "../Services/Logging/Logger"; import { TestLoggerImpl } from "../Services/Logging/TestLoggerImpl"; import ServiceLocator from "../ServiceLocator" -import { MockEventQueue } from "../Services/EventQueue/MockEventQueue"; import { candidate, rawVote } from "@equal-vote/star-vote-shared/domain_model/ITabulators"; -const request = require("supertest"); +import request, { Test as SupertestTest, Response as SupertestResponse } from "supertest"; type ElectionResponse = { statusCode: number; - err: Object | null; + err: object | null; election: Election; precinctFilteredElection: Election; voterAuth: VoterAuth; @@ -20,7 +19,7 @@ type ElectionResponse = { type BallotResponse = { statusCode: number; - err: Object | null; + err: object | null; election: Election; voterAuth: VoterAuth; }; @@ -38,8 +37,9 @@ export const mapMethodInputs = (names: string[], votes: (number | null)[][]): [c export class TestHelper { public expressApp; public logger: TestLoggerImpl; - public emailService: any; - public eventQueue:any; + public emailService: ReturnType; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tests swap in MockEventQueue, which exposes extra methods beyond IEventQueue + public eventQueue: any; private ctx = Logger.createContext("testHelper"); @@ -50,16 +50,16 @@ export class TestHelper { this.logger = new TestLoggerImpl().setup(); } - getRequest(url: string, userToken: string | null, customToken: string| null = null, tempId: string|null=null) { - var r = request(this.expressApp) + getRequest(url: string, userToken: string | null, customToken: string| null = null, tempId: string|null=null): SupertestTest { + let r = request(this.expressApp) .get(url) .set("Accept", "application/json"); r = this.addUserTokenVoterIdCookie(r, userToken, null, customToken, tempId); return r; } - postRequest(url: string, body: Object, userToken: string | null, customToken: string| null = null, tempId: string|null=null) { - var r = request(this.expressApp) + postRequest(url: string, body: object, userToken: string | null, customToken: string| null = null, tempId: string|null=null): SupertestTest { + let r = request(this.expressApp) .post(url) .set("Accept", "application/json"); r = this.addUserTokenVoterIdCookie(r, userToken, null, customToken, tempId); @@ -117,7 +117,7 @@ export class TestHelper { return this.electionResponse(res); } - private electionResponse(res: any): ElectionResponse { + private electionResponse(res: SupertestResponse): ElectionResponse { if (res.statusCode != 200) { return { statusCode: res.statusCode, @@ -155,7 +155,7 @@ export class TestHelper { electionId: Uid, ballot: Ballot | NewBallot, userToken: string | null - ): Promise { + ): Promise { return this.postRequest( `/API/Election/${electionId}/vote`, { ballot: ballot }, @@ -174,7 +174,7 @@ export class TestHelper { userToken, customToken ); - var err = null; + let err = null; if (res.statusCode != 200) { err = res.body; } @@ -192,14 +192,14 @@ export class TestHelper { voterId: string | null, customToken: string| null = null ): Promise { - var req = request(this.expressApp) + let req: SupertestTest = request(this.expressApp) .post(`/API/Election/${electionId}/ballot`) .set("Accept", "application/json"); req = this.addUserTokenVoterIdCookie(req, userToken, voterId, customToken, null); const res = await req.send({}); - var err = null; + let err = null; if (res.statusCode != 200) { err = res.body; } @@ -217,8 +217,8 @@ export class TestHelper { userToken: string | null, voterId: string | null, customToken: string| null = null - ): Promise { - var r = request(this.expressApp) + ): Promise { + let r = request(this.expressApp) .post(`/API/Election/${electionId}/vote`) .set("Accept", "application/json"); @@ -228,10 +228,10 @@ export class TestHelper { async uploadBallots( electionId: Uid, - ballots: Array<{ ballot: any; voter_id: string }>, - raceOrder: any[], + ballots: Array<{ ballot: OrderedNewBallot; voter_id: string }>, + raceOrder: RaceCandidateOrder[], userToken: string | null - ): Promise { + ): Promise { return this.postRequest( `/API/Election/${electionId}/uploadBallots`, { ballots, race_order: raceOrder }, @@ -241,11 +241,12 @@ export class TestHelper { async submitElectionRoll( electionId: Uid, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tests intentionally post partial/malformed rolls to exercise validation electionRoll: any[], userToken: string | null, customToken: string| null = null - ): Promise { - var r = request(this.expressApp) + ): Promise { + let r = request(this.expressApp) .post(`/API/Election/${electionId}/rolls`) .set("Accept", "application/json"); @@ -257,8 +258,8 @@ export class TestHelper { electionId: Uid, userToken: string | null, customToken: string| null = null - ): Promise { - var r = request(this.expressApp) + ): Promise { + let r = request(this.expressApp) .delete(`/API/Election/${electionId}/rolls`) .set("Accept", "application/json"); @@ -270,8 +271,8 @@ export class TestHelper { electionId: Uid, userToken: string | null, customToken: string| null = null - ): Promise { - var r = request(this.expressApp) + ): Promise { + let r = request(this.expressApp) .get(`/API/Election/${electionId}/rolls`) .set("Accept", "application/json"); @@ -280,13 +281,13 @@ export class TestHelper { } private addUserTokenVoterIdCookie( - req: any, + req: SupertestTest, userToken: string | null, voterId: string | null, customToken: string | null, tempId: string | null, - ): any { - var cookies = ""; + ): SupertestTest { + let cookies = ""; if (userToken != null) { cookies = "id_token=" + userToken; } diff --git a/packages/shared/src/domain_model/Election.ts b/packages/shared/src/domain_model/Election.ts index 8ce227687..f6ddd0618 100644 --- a/packages/shared/src/domain_model/Election.ts +++ b/packages/shared/src/domain_model/Election.ts @@ -4,7 +4,7 @@ import { ElectionState, validElectionStates } from "./ElectionStates"; import { Race } from "./Race"; import { Uid } from "./Uid"; import { raceValidation } from "./Race"; -import { checkForDuplicates, emailRegex } from "./Util"; +import { checkForDuplicates } from "./Util"; export { ElectionState } @@ -34,7 +34,7 @@ export interface Election { type Omit = Pick> export type PartialBy = Omit & Partial> -export interface NewElection extends PartialBy {} +export type NewElection = PartialBy; export function electionValidation(obj:Election): string | null { if (!obj){ @@ -108,7 +108,7 @@ export function electionValidation(obj:Election): string | null { } let raceErrors = '' obj.races.forEach(race => { - let raceError = raceValidation(race) + const raceError = raceValidation(race) if (raceError){ raceErrors += `race_id: ${race.race_id}: ${raceError} ` } @@ -123,7 +123,7 @@ export function electionValidation(obj:Election): string | null { if (!obj.settings){ return "Invalid Election Settings"; } else { - const settingsError = electionSettingsValidation(obj.settings, obj.state); + const settingsError = electionSettingsValidation(obj.settings); if (settingsError){ return settingsError; } diff --git a/packages/shared/src/domain_model/ElectionSettings.ts b/packages/shared/src/domain_model/ElectionSettings.ts index 48e75f984..fdcd03b8d 100644 --- a/packages/shared/src/domain_model/ElectionSettings.ts +++ b/packages/shared/src/domain_model/ElectionSettings.ts @@ -1,5 +1,4 @@ import { timeZones, TimeZone } from "./Util"; -import { ElectionState } from "./ElectionStates" import { getVoterAuthenticationMode } from "./VoterAuthenticationMode"; import { BALLOT_SUBMIT_TYPES, BallotSubmitType } from "./Ballot"; @@ -20,9 +19,9 @@ export interface authentication { } const TermTypes = ['poll', 'election'] as const; export type TermType = typeof TermTypes[number]; -const VoterAcessArray = ['open', 'closed', 'registration'] as const; +export const VoterAcessArray = ['open', 'closed', 'registration'] as const; export type VoterAccess = typeof VoterAcessArray[number]; -const InvitationTypes = ['email', 'address'] as const; +export const InvitationTypes = ['email', 'address'] as const; export type InvitationType = typeof InvitationTypes[number]; export const DEFAULT_ALLOWED_SUBMIT_TYPES: BallotSubmitType[] = ['submitted_via_browser', 'submitted_via_discord']; @@ -46,7 +45,7 @@ export interface ElectionSettings { draggable_ballot?: boolean; // Use draggable interface for IRV ballots allowed_submit_types?: BallotSubmitType[]; // Which submission channels are allowed for this election } -function settingsCompatiblityValidation(settings: ElectionSettings, electionState?: ElectionState): string { +function settingsCompatiblityValidation(settings: ElectionSettings): string { let errorMsg = '' if (settings.ballot_updates) { if (settings.voter_access == 'open') { @@ -59,7 +58,7 @@ function settingsCompatiblityValidation(settings: ElectionSettings, electionStat return errorMsg; } -export function electionSettingsValidation(obj:ElectionSettings, electionState?: ElectionState): string | null { +export function electionSettingsValidation(obj:ElectionSettings): string | null { if (!obj){ return "ElectionSettings is null"; } @@ -116,7 +115,7 @@ export function electionSettingsValidation(obj:ElectionSettings, electionState?: } } - const compatibilityError = settingsCompatiblityValidation(obj, electionState); + const compatibilityError = settingsCompatiblityValidation(obj); if (compatibilityError) { return compatibilityError; }