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/package-lock.json b/package-lock.json index 33f96044a..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, @@ -2770,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, @@ -2860,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, @@ -3446,6 +3771,16 @@ "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, @@ -3761,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", @@ -4334,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, @@ -5042,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, @@ -5436,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", @@ -5841,6 +6214,16 @@ "node": ">=4.0" } }, + "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", @@ -5928,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", @@ -8316,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" @@ -8468,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, @@ -9434,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", @@ -9640,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", @@ -10149,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", @@ -10166,7 +10872,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10854,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, @@ -11283,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, @@ -11411,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, @@ -11423,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, @@ -11974,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", @@ -11985,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": { @@ -12798,11 +13572,28 @@ "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", - "engines": { + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { "node": ">=0.10.0" } }, @@ -13114,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": { @@ -13125,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", @@ -13144,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/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", diff --git a/packages/backend/src/Controllers/Ballot/castVoteController.ts b/packages/backend/src/Controllers/Ballot/castVoteController.ts index 273cf4f42..bd6f8643b 100644 --- a/packages/backend/src/Controllers/Ballot/castVoteController.ts +++ b/packages/backend/src/Controllers/Ballot/castVoteController.ts @@ -1,17 +1,16 @@ 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"; 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"; @@ -27,9 +26,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 +35,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); @@ -75,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); @@ -129,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); @@ -148,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, @@ -166,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' @@ -184,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; } } } @@ -204,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) } @@ -218,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; @@ -233,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; @@ -287,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 16071742c..1390c4c4d 100644 --- a/packages/backend/src/DevElections/makeDevElections.ts +++ b/packages/backend/src/DevElections/makeDevElections.ts @@ -1,9 +1,11 @@ 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' 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 +178,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..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"); @@ -226,13 +226,27 @@ export class TestHelper { return r.send({ ballot: ballot }); } + async uploadBallots( + electionId: Uid, + ballots: Array<{ ballot: OrderedNewBallot; voter_id: string }>, + raceOrder: RaceCandidateOrder[], + userToken: string | null + ): Promise { + return this.postRequest( + `/API/Election/${electionId}/uploadBallots`, + { ballots, race_order: raceOrder }, + userToken + ); + } + 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"); @@ -244,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"); @@ -257,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"); @@ -267,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/backend/src/test/allowedSubmitTypes.test.ts b/packages/backend/src/test/allowedSubmitTypes.test.ts new file mode 100644 index 000000000..a583b1db5 --- /dev/null +++ b/packages/backend/src/test/allowedSubmitTypes.test.ts @@ -0,0 +1,164 @@ +import 'dotenv/config'; + +import { Election } from "@equal-vote/star-vote-shared/domain_model/Election"; +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"; + +const th = new TestHelper(); + +afterEach(() => { + jest.clearAllMocks(); + th.afterEach(); +}); + +// Based on testInputs.Election1, but opened and given a STAR race with two candidates. +const makeElection = (allowed_submit_types?: string[]): Election => ({ + ...testInputs.Election1, + title: "Submit Types Test Election", + state: "open", + 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]] }; + +// 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 () => { + 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 = { + ...makeElection(["submitted_via_browser", "submitted_via_admin"]), + title: "Closed Admin Upload Election", + 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/frontend/package.json b/packages/frontend/package.json index 4065aec25..61da53925 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": "vitest run" }, "browserslist": { "production": [ @@ -68,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/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 72% rename from packages/frontend/src/components/UploadElections.tsx rename to packages/frontend/src/components/Election/Upload/UploadElections.tsx index 6f865e05e..a6010fff5 100644 --- a/packages/frontend/src/components/UploadElections.tsx +++ b/packages/frontend/src/components/Election/Upload/UploadElections.tsx @@ -1,19 +1,18 @@ 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 { computeRaceOrder, encodeBallotRow, uploadBallotsBatched } from "./uploadUtils"; +import { PrimaryButton, SecondaryButton } from "../../styles"; +import { makeDefaultElection } from "../../ElectionForm/Wizard/Wizard"; const UploadElections = () => { const [addToPublicArchive, setAddToPublicArchive] = useState(true) @@ -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/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 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..ccfdffbee --- /dev/null +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.test.ts @@ -0,0 +1,151 @@ +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'; +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(); + 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', () => { + 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..871419359 --- /dev/null +++ b/packages/frontend/src/components/Election/Upload/uploadUtils.ts @@ -0,0 +1,84 @@ +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; +} + +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 +): Promise { + let batchSize = INITIAL_BATCH_SIZE; + let nextIndex = 0; + const 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 * BATCH_SHRINK_FACTOR); + if (batchSize < MIN_BATCH_SIZE) { + return { responses, aborted: true }; + } + } + } while (!uploadRes.ok); + + nextIndex += batchSize; + const res = await uploadRes.json(); + responses.push(...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/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 1d49b6d17..1ac145517 100644 --- a/packages/shared/src/domain_model/Ballot.ts +++ b/packages/shared/src/domain_model/Ballot.ts @@ -1,10 +1,14 @@ 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 { voter_id: string; ballot: OrderedNewBallot; @@ -29,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[] @@ -43,7 +47,7 @@ export interface AnonymizedBallot { } export interface BallotAction { - action_type:string; + action_type: BallotActionType; actor:Uid; timestamp:number; } @@ -114,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/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.test.ts b/packages/shared/src/domain_model/ElectionSettings.test.ts new file mode 100644 index 000000000..7b556228a --- /dev/null +++ b/packages/shared/src/domain_model/ElectionSettings.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect } from 'vitest'; +import { ElectionSettings, electionSettingsValidation } 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(); + }); +}); diff --git a/packages/shared/src/domain_model/ElectionSettings.ts b/packages/shared/src/domain_model/ElectionSettings.ts index 9a4efeb1c..fdcd03b8d 100644 --- a/packages/shared/src/domain_model/ElectionSettings.ts +++ b/packages/shared/src/domain_model/ElectionSettings.ts @@ -1,6 +1,6 @@ import { timeZones, TimeZone } from "./Util"; -import { ElectionState } from "./ElectionStates" import { getVoterAuthenticationMode } from "./VoterAuthenticationMode"; +import { BALLOT_SUBMIT_TYPES, BallotSubmitType } from "./Ballot"; export interface registration_field { field_name: string; @@ -19,11 +19,13 @@ 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']; + export interface ElectionSettings { voter_access?: VoterAccess; // Who is able to vote in election? voter_authentication: authentication; // How will voters be authenticated? @@ -41,8 +43,9 @@ 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 { +function settingsCompatiblityValidation(settings: ElectionSettings): string { let errorMsg = '' if (settings.ballot_updates) { if (settings.voter_access == 'open') { @@ -55,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"; } @@ -99,8 +102,20 @@ 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"; + } + 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); + const compatibilityError = settingsCompatiblityValidation(obj); if (compatibilityError) { return compatibilityError; } 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'], + }, +});