diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5f3c21c4147..166196e79c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,6 +10,70 @@ on: branches: [ 'main', 'release/[0-9]+.[0-9]+' ] jobs: + # sequent-core is compiled against a different feature set by nearly every + # consumer, and a module gated one way with its re-export gated another only + # fails for the sets nobody happens to build locally: `cargo build --workspace` + # unifies features across the graph and hides it entirely. That is exactly how + # an ungated `pub use build::{…}` reached CI and reddened fourteen jobs in the + # slow suites below, each of which reports it as its own failure. + # + # `--lib` rather than `--all-targets`: the question is whether the library + # compiles for a consumer that asked for this much and no more. The test + # modules need `default_features` and are covered by the suites below. + # + # This job is `cargo check` only, so it finishes in a couple of minutes and + # names the gate that broke. + feature-gates: + name: sequent-core at ${{ matrix.gate }} + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # This job reads code and runs `cargo check`. Without a block it inherits the + # repository default, which can include write scopes. + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - gate: no features + cache: none + features: "" + - gate: default_features + cache: default + features: --features default_features + - gate: keycloak,default_features + cache: keycloak + features: --features keycloak,default_features + - gate: election_config_templates + cache: templates + features: --features election_config_templates + - gate: election_config_archive + cache: archive + features: --features election_config_archive + - gate: election_config_xlsx + cache: xlsx + features: --features election_config_xlsx + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + # Or the job token stays in .git/config, readable by every later step. + persist-credentials: false + + - name: Set up Rust tests + uses: ./.github/actions/setup-rust-tests + with: + # Per gate, or six jobs would evict each other's artifacts all day. + cargo-build-name: sequent-core-${{ matrix.cache }} + cargo-build-path: packages/target + cargo-lock-path: packages/Cargo.lock + + - name: cargo check ${{ matrix.gate }} + env: + PROTOC: "/usr/bin/protoc" + run: cargo check -p sequent-core --lib ${{ matrix.features }} + working-directory: packages + run-tests: name: Run Rust tests runs-on: ubuntu-24.04 diff --git a/packages/Cargo.lock b/packages/Cargo.lock index e922864aace..37212bf9223 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -632,7 +632,7 @@ dependencies = [ "attohttpc", "home", "log", - "quick-xml", + "quick-xml 0.32.0", "rust-ini 0.21.3", "serde", "thiserror 1.0.69", @@ -1761,6 +1761,22 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "calamine" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" +dependencies = [ + "byteorder", + "chrono", + "codepage", + "encoding_rs", + "log", + "quick-xml 0.31.0", + "serde", + "zip 2.4.2", +] + [[package]] name = "cap-fs-ext" version = "3.4.5" @@ -2122,6 +2138,15 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -7722,6 +7747,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -8535,7 +8570,7 @@ dependencies = [ "minidom", "native-tls", "percent-encoding", - "quick-xml", + "quick-xml 0.32.0", "serde", "serde_derive", "serde_json", @@ -9057,6 +9092,7 @@ dependencies = [ "aws-smithy-types", "base64 0.22.1", "borsh", + "calamine", "cfg-if", "chrono", "console_error_panic_hook", @@ -9093,6 +9129,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sha256", "strand", "strum 0.27.2", @@ -9113,6 +9150,7 @@ dependencies = [ "wasmtime", "wasmtime-wasi", "web-sys", + "zip 2.4.2", ] [[package]] diff --git a/packages/sequent-core/Cargo.toml b/packages/sequent-core/Cargo.toml index a332ad8810d..09f71d0b77b 100644 --- a/packages/sequent-core/Cargo.toml +++ b/packages/sequent-core/Cargo.toml @@ -124,6 +124,20 @@ kuchiki = { version = "0.8", optional = true } rusqlite = { version = "0.32", features = ["bundled"], optional = true } csv = "1.3.0" +# reading authoring spreadsheets; optional so front ends with no workbook to read +# do not carry it into their WASM bundle +calamine = { version = "0.26", features = ["dates"], optional = true } + +# uuid5 for the deterministic ids a generated bundle uses. The `uuid` crate is not +# used for this: its v4 feature pulls getrandom, whose WASM support is version +# specific and already pinned elsewhere in this workspace, and nothing here needs +# randomness. +sha1 = { version = "0.10", optional = true } + +# writing the importable archive; optional so a front end that only validates a +# bundle carries no zip writer +zip = { version = "2.1", optional = true } + # WASM plugin management wasmtime = {version = "41.0.2", optional = true} @@ -150,7 +164,17 @@ lambda_inplace = [] lambda_openwhisk = [] lambda_aws_lambda = [] plugins_wit = ["dep:wasmtime", "dep:wasmtime-wasi"] -default_features = ["dep:strand", "dep:num-bigint", "dep:tempfile", "dep:ammonia"] +default_features = ["dep:strand", "dep:num-bigint", "dep:tempfile", "dep:ammonia", "dep:sha1"] +# Reading .xlsx into an election_config::sheet::Workbook. Separate from +# default_features because admin-portal and voting-portal have no workbook to +# read and should not pay for a spreadsheet parser in their bundle. +election_config_xlsx = ["default_features", "dep:calamine", "dep:zip"] +# Rendering the base entity templates. Separate from `reports`, which also brings +# a headless browser and three AWS SDKs; this needs only the template engine. +election_config_templates = ["default_features", "dep:handlebars"] +# Writing the importable zip. Separate again: a front end that only validates an +# existing bundle has nothing to write. +election_config_archive = ["election_config_templates", "dep:zip"] sqlite = ["dep:tokio", "dep:rusqlite"] time = ["dep:time"] diff --git a/packages/sequent-core/examples/validate_bundle.rs b/packages/sequent-core/examples/validate_bundle.rs new file mode 100644 index 00000000000..729e87fdb24 --- /dev/null +++ b/packages/sequent-core/examples/validate_bundle.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Validate an election event bundle from a file. +//! +//! `cargo run -p sequent-core --features default_features --example validate_bundle -- ` +//! +//! A thin harness for checking a real export against the shared rules; the same +//! call step-cli and the browser make. + +use sequent_core::election_config::{validate, ImportElectionEventSchema}; + +fn main() -> Result<(), Box> { + let path = std::env::args() + .nth(1) + .ok_or("usage: validate_bundle ")?; + let text = std::fs::read_to_string(&path)?; + let bundle: ImportElectionEventSchema = serde_json::from_str(&text)?; + + let report = validate(&bundle); + print!("{report}"); + println!( + "{} error(s), {} warning(s)", + report.errors().count(), + report.warnings().count() + ); + if report.has_errors() { + std::process::exit(1); + } + Ok(()) +} diff --git a/packages/sequent-core/src/election_config/architect.rs b/packages/sequent-core/src/election_config/architect.rs new file mode 100644 index 00000000000..a160f5d722d --- /dev/null +++ b/packages/sequent-core/src/election_config/architect.rs @@ -0,0 +1,1304 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The Election Architect's plan, and how it becomes a bundle. +//! +//! The architect is a wizard: somebody answers questions and gets an importable +//! election event. This module is the half of it that decides what the answers +//! mean. The other half — what the questions look like — is React, in +//! `beyond/packages/election-architect`, and it contains no mapping, no CSV and no +//! zip. +//! +//! # Why this is so small +//! +//! A [`Blueprint`] does not become a bundle here. It becomes a +//! [`Workbook`] — the same rows the spreadsheet reader produces — and the existing +//! [`super::build`] takes it from there. +//! +//! That is the whole design. A wizard is not a different kind of election event; +//! it is a different way of filling in the same fields. Going through the workbook +//! shape means the architect inherits the entity templates, the deterministic ids, +//! the CSV byte shapes, the Keycloak realm handling, the archive layout and every +//! validation rule, for free and without a second copy of any of them. What is +//! left here is only what is genuinely the architect's own: its plan, the checks +//! that apply to a plan rather than to a bundle, and the three files it produces +//! that are not part of an import at all. +//! +//! # What the TypeScript version got wrong, and why +//! +//! Its output was not the importable format: `election_config.json` inside a +//! nested `official_election_setup.zip`, where the importer looks for +//! `export_election_event-.json` at the archive root. Its scheduled-events +//! CSV was built by string interpolation, one of three hand-written copies of that +//! byte shape. It stamped `new Date()` into every entity, so no two runs of the +//! same answers produced the same file. It embedded a Keycloak realm copied from +//! one environment, which the importer takes wholesale and would have used to +//! replace whatever the target environment had provisioned. And it validated +//! nothing. +//! +//! None of those are mistakes anybody made twice. They are what happens when a +//! format is implemented a second time, which is why this implementation does not. + +use std::cmp::Ordering; + +use crate::election_config::paths::Cell; +use crate::election_config::problem::{Code, Problem, Report, Severity}; +use crate::election_config::sheet::{Sheet, Workbook}; +use crate::election_config::time::{self, Timestamp}; +use serde::{Deserialize, Serialize}; + +/// The plan format's version. +/// +/// Written into every saved plan and checked on load. A plan is a document +/// somebody spent an afternoon on; being able to say "this is from an older +/// version" beats failing to deserialize it with a serde error about a missing +/// field. +pub const BLUEPRINT_VERSION: u32 = 1; + +/// What the wizard collected. +/// +/// This is the artifact worth keeping. The bundle is derived from it and is +/// disposable; the plan is what somebody edits next month when a candidate +/// withdraws. +/// +/// The TypeScript version had no such thing — it reconstructed the wizard's state +/// by parsing its own generated bundle back in. That loses every answer the bundle +/// has no field for (the trustee threshold, the ceremony dates, the points of +/// contact) and breaks whenever the bundle's shape changes. Saving the plan is both +/// simpler and lossless. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Blueprint { + /// [`BLUEPRINT_VERSION`] at the time it was saved. + pub version: u32, + + /// Stable identifier for the event, and the seed for every generated id. + /// + /// Not shown to voters. Two plans with the same one produce the same + /// identifiers, which is what makes regenerating diffable. + pub external_id: String, + + /// The event's name, per language. `en` is the fallback. + #[serde(default)] + pub name: Translated, + + /// BCP 47 or ISO 639-2/T codes, in the order the picker should show them. + #[serde(default)] + pub languages: Vec, + + #[serde(default)] + pub logo_url: Option, + + #[serde(default)] + pub contacts: Vec, + + #[serde(default)] + pub trustees: Vec, + + /// How many trustees must take part to open the tally. + #[serde(default = "default_threshold")] + pub trustee_threshold: u32, + + #[serde(default)] + pub schedule: Schedule, + + /// The areas voters belong to. + /// + /// Empty means one ballot for everybody, and one is synthesised. See + /// [`DEFAULT_AREA_EXTERNAL_ID`]. + #[serde(default)] + pub areas: Vec, + + #[serde(default)] + pub elections: Vec, + + #[serde(default)] + pub policies: Policies, + + /// Anything the wizard has no field for. Carried, not interpreted. + #[serde(default)] + pub notes: String, +} + +fn default_threshold() -> u32 { + 2 +} + +/// Text in as many languages as the plan enables. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Translated { + /// `language code -> text`. + #[serde(flatten)] + pub by_language: std::collections::BTreeMap, +} + +impl Translated { + pub fn new(english: &str) -> Self { + let mut by_language = std::collections::BTreeMap::new(); + by_language.insert("en".to_string(), english.to_string()); + Translated { by_language } + } + + /// The text in `language`, falling back to English and then to anything. + /// + /// A missing translation shows the English rather than an empty ballot line. + /// Blank is never the right answer for a candidate's name. + pub fn get(&self, language: &str) -> Option<&str> { + self.by_language + .get(language) + .or_else(|| self.by_language.get("en")) + .or_else(|| self.by_language.values().next()) + .map(String::as_str) + .filter(|text| !text.is_empty()) + } + + pub fn is_empty(&self) -> bool { + self.by_language.values().all(|text| text.is_empty()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Contact { + pub name: String, + #[serde(default)] + pub role: String, + #[serde(default)] + pub email: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Trustee { + pub name: String, + #[serde(default)] + pub email: String, +} + +/// The dates an election runs to. +/// +/// Each moment carries the zone it was written in, because the platform acts on +/// an instant and a wall clock is not one. See [`super::time`] — a plan that +/// says `2027-03-01T09:00` produced a `scheduled_date` the scheduler could not +/// parse, so voting never opened and nothing said why. +/// +/// A plan written before zones existed still opens: a bare string reads as UTC, +/// which is what it always meant, and validation says so rather than guessing. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Schedule { + /// When the trustees generate the election key. Not an imported event: it is + /// something people have to attend, so it travels in the ceremony file. + #[serde(default)] + pub key_ceremony: Option, + + #[serde(default)] + pub voting_opens: Option, + + #[serde(default)] + pub voting_closes: Option, + + #[serde(default)] + pub tally_ceremony: Option, + + /// Anything else with a date on it, for the schedule the client is handed. + #[serde(default)] + pub milestones: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Milestone { + pub event: String, + pub date: String, +} + +/// A group of voters who get the same ballot. +/// +/// The name is plain text rather than translated, and that is not an oversight: +/// the voters CSV identifies a voter's area *by name*, so it is an identifier the +/// importer matches on. Two areas sharing a name would silently put voters in +/// whichever one the importer found first. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlannedArea { + pub external_id: String, + + /// What the importer matches a voter's `area_name` against. + #[serde(default)] + pub name: String, + + /// The area this one sits inside, if any. + /// + /// A tree, because that is how districting is actually described — a local + /// inside a region inside a state — and because the platform models it that + /// way. A contest assigned to a parent is not automatically on its children's + /// ballots; assignment is explicit, so that "who votes on this" is answerable + /// by reading one list rather than walking a tree. + #[serde(default)] + pub parent_external_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlannedElection { + pub external_id: String, + #[serde(default)] + pub name: Translated, + #[serde(default)] + pub contests: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlannedContest { + pub external_id: String, + #[serde(default)] + pub name: Translated, + #[serde(default)] + pub description: String, + + /// How many candidates a voter may choose. + #[serde(default = "one")] + pub max_votes: i64, + + /// How many candidates the contest elects. + /// + /// The TypeScript hard-coded this to 1 while letting `max_votes` be anything, + /// so a "choose 3" contest silently elected one person. + #[serde(default = "one")] + pub winners: i64, + + #[serde(default)] + pub candidates: Vec, + + /// Which areas put this contest on their ballot. + /// + /// Empty means every area, which is what a plan that has never thought about + /// districting wants and what one with a single area always wants. Naming + /// areas explicitly is how a contest becomes local to some of them. + #[serde(default)] + pub areas: Vec, +} + +fn one() -> i64 { + 1 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlannedCandidate { + pub external_id: String, + #[serde(default)] + pub name: Translated, + /// A "none of the above" option rather than a person. + #[serde(default)] + pub explicit_blank: bool, + /// A "spoil my ballot" option rather than a person. + #[serde(default)] + pub explicit_invalid: bool, +} + +/// What the ballot does when a voter does something unusual. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Policy { + /// Let it happen without comment. + Allowed, + /// Let it happen, but say something first. + Warn, + /// Do not let it happen. + Restricted, +} + +impl Default for Policy { + fn default() -> Self { + Policy::Warn + } +} + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, +)] +pub struct Policies { + /// Choosing more than `max_votes`. + #[serde(default)] + pub over_vote: Policy, + /// Choosing nothing at all. + #[serde(default)] + pub blank_vote: Policy, + /// Choosing fewer than `max_votes`. + #[serde(default)] + pub under_vote: Policy, + /// Deliberately spoiling the ballot. + #[serde(default)] + pub invalid_vote: Policy, +} + +impl Policies { + /// The platform's `over_vote_policy` values. + fn over_vote(self) -> &'static str { + match self.over_vote { + Policy::Allowed => "allowed", + Policy::Warn => "allowed-with-msg", + Policy::Restricted => "not-allowed-with-msg-and-disable", + } + } + + fn under_vote(self) -> &'static str { + match self.under_vote { + Policy::Allowed => "allowed", + Policy::Warn => "warn", + Policy::Restricted => "warn-only-in-review", + } + } + + fn blank_vote(self) -> &'static str { + match self.blank_vote { + Policy::Allowed => "allowed", + Policy::Warn => "warn", + Policy::Restricted => "not-allowed", + } + } + + fn invalid_vote(self) -> &'static str { + match self.invalid_vote { + Policy::Allowed => "allowed", + Policy::Warn => "warn", + Policy::Restricted => "not-allowed", + } + } +} + +/// The area used when a plan names none. +/// +/// A bundle needs an area and a ballot link or no voter sees anything, so a plan +/// that has never thought about districting gets one covering everybody. +/// +/// Named rather than anonymous because the voters CSV resolves an area by name, +/// and because a delivery engineer opening the generated event should be able to +/// tell nobody chose it. +pub const DEFAULT_AREA_EXTERNAL_ID: &str = "all-voters"; +pub const DEFAULT_AREA_NAME: &str = "All voters"; + +/// Check a plan, before anything is built from it. +/// +/// These are questions about the *plan*, in the wizard's own vocabulary — a +/// trustee threshold higher than the number of trustees, a voting window that +/// closes before it opens. [`super::validate`] then checks the bundle, and a +/// problem there is phrased in the bundle's vocabulary. Both run; they are asking +/// different questions and an author needs both answers. +pub fn validate_plan(plan: &Blueprint) -> Report { + let mut report = Report::default(); + + if plan.version > BLUEPRINT_VERSION { + report.push(Problem::error( + Code::InvalidValue, + "version", + format!( + "this plan was saved by a newer version ({} against {}). Opening \ + it here would silently drop whatever that version added.", + plan.version, BLUEPRINT_VERSION + ), + )); + } + + if plan.external_id.trim().is_empty() { + report.push(Problem::error( + Code::MissingField, + "external_id", + "the event needs an identifier: every generated id is derived from it, \ + so without one nothing can be built twice the same way", + )); + } + + if plan.name.is_empty() { + report.push(Problem::error( + Code::MissingField, + "name", + "the event needs a name. Voters see it above the ballot, and it \ + becomes the login page's title.", + )); + } + + check_trustees(plan, &mut report); + check_schedule(plan, &mut report); + check_areas(plan, &mut report); + check_ballot(plan, &mut report); + + if plan.contacts.is_empty() { + report.push(Problem::warning( + Code::MissingField, + "contacts", + "nobody is listed as a point of contact. On election day this is who \ + gets called.", + )); + } + + report +} + +fn check_trustees(plan: &Blueprint, report: &mut Report) { + if plan.trustees.is_empty() { + report.push(Problem::warning( + Code::MissingField, + "trustees", + "no trustees. The election key needs somebody to hold it, and the \ + tally needs them to come back.", + )); + return; + } + + if plan.trustee_threshold == 0 { + report.push(Problem::error( + Code::InvalidValue, + "trustee_threshold", + "a threshold of zero means the tally can be opened by nobody at all", + )); + } + + if plan.trustee_threshold as usize > plan.trustees.len() { + // The failure mode is the worst kind: everything works until the tally, + // and then the result cannot be decrypted by anyone. + report.push(Problem::error( + Code::ContestArithmetic, + "trustee_threshold", + format!( + "{} of {} trustees are required, which cannot be met. The key \ + would be generated and the result could never be decrypted.", + plan.trustee_threshold, + plan.trustees.len() + ), + )); + } + + if plan.trustee_threshold == 1 && plan.trustees.len() > 1 { + report.push(Problem::warning( + Code::InvalidValue, + "trustee_threshold", + "one trustee alone can open the tally, which is the same guarantee as \ + having a single trustee", + )); + } +} + +fn check_schedule(plan: &Blueprint, report: &mut Report) { + let schedule = &plan.schedule; + + // Each moment on its own first: an ordering complaint about a time that is + // not a time would send somebody looking at the wrong field. + let mut found = Vec::new(); + for (at, moment) in [ + ("schedule.key_ceremony", &schedule.key_ceremony), + ("schedule.voting_opens", &schedule.voting_opens), + ("schedule.voting_closes", &schedule.voting_closes), + ("schedule.tally_ceremony", &schedule.tally_ceremony), + ] { + if let Some(moment) = moment { + time::check(moment, at, &mut found); + } + } + let unreadable = found + .iter() + .any(|problem| problem.severity == Severity::Error); + for problem in found { + report.push(problem); + } + if unreadable { + return; + } + + match (&schedule.voting_opens, &schedule.voting_closes) { + (None, _) | (_, None) => report.push(Problem::warning( + Code::MissingSchedule, + "schedule", + "the voting window is incomplete, so the period will have to be opened \ + or closed by hand in the Admin Portal", + )), + (Some(opens), Some(closes)) => { + // By instant, not by text. Two moments in different zones sort by + // their strings in whatever order the digits happen to fall. + if !opens.is_empty() + && !closes.is_empty() + && time::compare(closes, opens) != Ordering::Greater + { + report.push(Problem::error( + Code::InvalidValue, + "schedule.voting_closes", + "voting closes before it opens, so it would never be open", + )); + } + + // Both endpoints in one zone but at different offsets means the + // window crosses a daylight-saving change — legitimate, and worth + // knowing about, because an hour moves under whoever planned it. + if opens.zone == closes.zone + && !opens.zone.trim().is_empty() + && opens.offset_minutes != closes.offset_minutes + { + report.push(Problem::warning( + Code::InvalidValue, + "schedule", + "the voting window crosses a daylight-saving change, so it is \ + an hour longer or shorter than the clock times suggest", + )); + } + } + } + + if let (Some(ceremony), Some(opens)) = + (&schedule.key_ceremony, &schedule.voting_opens) + { + if !ceremony.is_empty() + && !opens.is_empty() + && time::compare(ceremony, opens) != Ordering::Less + { + report.push(Problem::error( + Code::InvalidValue, + "schedule.key_ceremony", + "the key ceremony is not before voting opens. The election key has \ + to exist before a vote can be encrypted with it.", + )); + } + } + + if let (Some(tally), Some(closes)) = + (&schedule.tally_ceremony, &schedule.voting_closes) + { + if !tally.is_empty() + && !closes.is_empty() + && time::compare(tally, closes) != Ordering::Greater + { + report.push(Problem::error( + Code::InvalidValue, + "schedule.tally_ceremony", + "the tally ceremony is not after voting closes, so it would count \ + votes that had not been cast yet", + )); + } + } +} + +/// Refuse an identifier a plan already used for the same kind of thing. +/// +/// [`super::ids::IdFactory::uid`] keys on the kind and the `external_id` and +/// nothing else — no enclosing election, no row number — so a repeat anywhere in +/// the plan mints one id for two things and the second silently replaces the first +/// in every table that references it. The builder catches it as a workbook row +/// number; an author who never saw a workbook needs it in the plan's own terms. +fn require_unique_external_id<'plan>( + what: &str, + external_id: &'plan str, + at: &str, + seen: &mut Vec<(&'plan str, String)>, + report: &mut Report, +) { + let id = external_id.trim(); + if id.is_empty() { + // Absent is a different fault, reported where the field is required. + return; + } + + match seen.iter().find(|(earlier_id, _)| *earlier_id == id) { + Some((_, earlier)) => report.push( + Problem::error( + Code::DuplicateId, + format!("{at}.external_id"), + format!( + "'{id}' is already the identifier of the {what} at {earlier}. \ + Every generated id derives from it, so the two would become \ + one." + ), + ) + .about(Some(external_id)), + ), + None => seen.push((id, at.to_string())), + } +} + +/// Districting: the areas themselves, before any contest points at one. +fn check_areas(plan: &Blueprint, report: &mut Report) { + let mut seen: Vec<(&str, String)> = Vec::new(); + + for (index, area) in plan.areas.iter().enumerate() { + let at = format!("areas[{index}]"); + + require_unique_external_id( + "area", + &area.external_id, + &at, + &mut seen, + report, + ); + + if area.external_id.trim().is_empty() { + report.push(Problem::error( + Code::MissingField, + &at, + "an area needs an identifier", + )); + } + + if area.name.trim().is_empty() { + report.push( + Problem::error( + Code::MissingField, + format!("{at}.name"), + "an area needs a name: the voters CSV identifies a voter's \ + area by name, not by id, so an unnamed area is one no voter \ + can be put in", + ) + .about(Some(&area.external_id)), + ); + } + + // The voters CSV resolves by name, so a duplicate silently assigns voters + // to whichever one the importer happens to find first. + if let Some(earlier) = plan.areas[..index].iter().find(|other| { + !other.name.trim().is_empty() && other.name == area.name + }) { + report.push( + Problem::error( + Code::DuplicateId, + format!("{at}.name"), + format!( + "two areas are both named '{}' ('{}' and '{}'). The voters \ + CSV resolves an area by name, so voters would land in \ + whichever the importer found first.", + area.name, earlier.external_id, area.external_id + ), + ) + .about(Some(&area.external_id)), + ); + } + + if let Some(parent) = area + .parent_external_id + .as_ref() + .filter(|parent| !parent.is_empty()) + { + if parent == &area.external_id { + report.push( + Problem::error( + Code::AreaCycle, + format!("{at}.parent_external_id"), + "an area cannot be inside itself", + ) + .about(Some(&area.external_id)), + ); + } else if let Some(loop_at) = climbs_into_a_loop(plan, area) { + // Longer than one hop: A inside B inside A. Reported here in the + // plan's own vocabulary rather than left to the bundle validator, + // which names generated ids the author never chose. + report.push( + Problem::error( + Code::AreaCycle, + format!("{at}.parent_external_id"), + format!( + "the areas are inside each other: '{loop_at}' is reached \ + again by walking up from here" + ), + ) + .about(Some(&area.external_id)), + ); + } else if !plan + .areas + .iter() + .any(|other| &other.external_id == parent) + { + report.push( + Problem::error( + Code::DanglingReference, + format!("{at}.parent_external_id"), + format!("no area has the identifier '{parent}'"), + ) + .about(Some(&area.external_id)), + ); + } + } + } +} + +/// The identifier a walk up the parent chain reaches twice, if any. +/// +/// Self-parenting is caught by its own message; this is for A inside B inside A and +/// anything longer. The walk stops at the first repeat, so a chain hanging off a loop +/// reports the loop rather than running forever. +fn climbs_into_a_loop(plan: &Blueprint, from: &PlannedArea) -> Option { + let mut seen = vec![from.external_id.as_str()]; + let mut at = from + .parent_external_id + .as_deref() + .filter(|id| !id.is_empty()); + + while let Some(parent) = at { + if seen.contains(&parent) { + return Some(parent.to_string()); + } + seen.push(parent); + at = plan + .areas + .iter() + .find(|area| area.external_id == parent) + .and_then(|area| { + area.parent_external_id + .as_deref() + .filter(|id| !id.is_empty()) + }); + } + None +} + +fn check_ballot(plan: &Blueprint, report: &mut Report) { + if plan.elections.is_empty() { + report.push(Problem::error( + Code::MissingField, + "elections", + "an election event needs at least one election", + )); + return; + } + + // Across the whole plan, not per election: `uid` keys a contest on its + // external_id alone, so two elections naming the same contest name one contest. + let mut elections_seen: Vec<(&str, String)> = Vec::new(); + let mut contests_seen: Vec<(&str, String)> = Vec::new(); + let mut candidates_seen: Vec<(&str, String)> = Vec::new(); + + for (index, election) in plan.elections.iter().enumerate() { + let at = format!("elections[{index}]"); + + require_unique_external_id( + "election", + &election.external_id, + &at, + &mut elections_seen, + report, + ); + + if election.contests.is_empty() { + report.push( + Problem::warning( + Code::BallotCoverage, + &at, + "this election has no contests, so nobody votes in it", + ) + .about(Some(&election.external_id)), + ); + } + + for (contest_index, contest) in election.contests.iter().enumerate() { + let at = format!("{at}.contests[{contest_index}]"); + + require_unique_external_id( + "contest", + &contest.external_id, + &at, + &mut contests_seen, + report, + ); + + for (candidate_index, candidate) in + contest.candidates.iter().enumerate() + { + require_unique_external_id( + "candidate", + &candidate.external_id, + &format!("{at}.candidates[{candidate_index}]"), + &mut candidates_seen, + report, + ); + } + + let choices = contest + .candidates + .iter() + .filter(|candidate| { + !candidate.explicit_blank && !candidate.explicit_invalid + }) + .count(); + + if contest.max_votes < 1 { + report.push( + Problem::error( + Code::ContestArithmetic, + &at, + "a voter may choose fewer than one candidate, so there is \ + nothing to vote for", + ) + .about(Some(&contest.external_id)), + ); + } + + if contest.winners < 1 { + report.push( + Problem::error( + Code::ContestArithmetic, + &at, + "the contest elects nobody", + ) + .about(Some(&contest.external_id)), + ); + } + + // The bug the TypeScript shipped: winners was fixed at 1 while + // max_votes was free, so "choose 3" quietly elected one person. + if contest.winners > contest.max_votes { + report.push( + Problem::error( + Code::ContestArithmetic, + &at, + format!( + "the contest elects {} but a voter may only choose {}", + contest.winners, contest.max_votes + ), + ) + .about(Some(&contest.external_id)), + ); + } + + for area in &contest.areas { + if !plan + .areas + .iter() + .any(|planned| &planned.external_id == area) + { + report.push( + Problem::error( + Code::DanglingReference, + format!("{at}.areas"), + format!("no area has the identifier '{area}'"), + ) + .about(Some(&contest.external_id)), + ); + } + } + + if choices == 0 { + report.push( + Problem::warning( + Code::BallotCoverage, + &at, + "no candidates yet", + ) + .about(Some(&contest.external_id)), + ); + } else if (contest.winners as usize) > choices { + report.push( + Problem::error( + Code::ContestArithmetic, + &at, + format!( + "the contest elects {} from a field of {choices}", + contest.winners + ), + ) + .about(Some(&contest.external_id)), + ); + } + } + } +} + +/// Turn a plan into the rows the builder reads. +/// +/// This is the only mapping in the architect, and it produces a +/// [`Workbook`] rather than a bundle so that everything downstream — templates, +/// ids, CSV shapes, the realm, the archive — is the code the workbook reader +/// already uses. +pub fn to_workbook(plan: &Blueprint) -> Result { + let languages = plan.languages_or_english(); + + let mut sheets = vec![ + event_sheet(plan, &languages)?, + elections_sheet(plan, &languages)?, + contests_sheet(plan, &languages)?, + candidates_sheet(plan, &languages)?, + areas_sheet(plan)?, + area_contests_sheet(plan)?, + ]; + + if let Some(schedule) = scheduled_events_sheet(plan)? { + sheets.push(schedule); + } + + Workbook::new(sheets) +} + +impl Blueprint { + /// The languages to write, never empty. + /// + /// A plan with no language still has to produce a ballot somebody can read. + fn languages_or_english(&self) -> Vec { + let chosen: Vec = self + .languages + .iter() + .map(|code| code.trim().to_string()) + .filter(|code| !code.is_empty()) + .collect(); + if chosen.is_empty() { + vec!["en".to_string()] + } else { + chosen + } + } +} + +/// A header and its column of values, as the sheet reader wants them. +fn sheet_of( + name: &str, + columns: Vec, + rows: Vec>, +) -> Result { + let mut grid = + vec![columns.iter().map(|c| Cell::text(c.clone())).collect()]; + grid.extend(rows); + Sheet::from_grid(name, &grid) +} + +/// `presentation.i18n..name` columns, one per language. +fn i18n_columns(prefix: &str, languages: &[String]) -> Vec { + languages + .iter() + .map(|language| format!("{prefix}.i18n.{language}.name")) + .collect() +} + +fn i18n_values(text: &Translated, languages: &[String]) -> Vec { + languages + .iter() + .map(|language| match text.get(language) { + Some(value) => Cell::text(value), + None => Cell::Blank, + }) + .collect() +} + +fn event_sheet( + plan: &Blueprint, + languages: &[String], +) -> Result { + let mut columns = vec!["external_id".to_string()]; + columns.extend(i18n_columns("presentation", languages)); + columns + .push("presentation.language_conf.enabled_language_codes".to_string()); + columns + .push("presentation.language_conf.default_language_code".to_string()); + + let mut row = vec![Cell::text(plan.external_id.clone())]; + row.extend(i18n_values(&plan.name, languages)); + // A JSON array in one cell: the reader parses bracketed text as JSON, which is + // how a list fits in a spreadsheet and therefore in a synthesised one too. + row.push(Cell::text( + serde_json::to_string(languages).unwrap_or_else(|_| "[]".to_string()), + )); + row.push(Cell::text( + languages + .first() + .cloned() + .unwrap_or_else(|| "en".to_string()), + )); + + if let Some(logo) = plan.logo_url.as_ref().filter(|url| !url.is_empty()) { + columns.push("presentation.logo_url".to_string()); + row.push(Cell::text(logo.clone())); + } + + sheet_of("ElectionEvent", columns, vec![row]) +} + +fn elections_sheet( + plan: &Blueprint, + languages: &[String], +) -> Result { + let mut columns = vec!["external_id".to_string()]; + columns.extend(i18n_columns("presentation", languages)); + + let rows = plan + .elections + .iter() + .map(|election| { + let mut row = vec![Cell::text(election.external_id.clone())]; + row.extend(i18n_values(&election.name, languages)); + row + }) + .collect(); + + sheet_of("Elections", columns, rows) +} + +fn contests_sheet( + plan: &Blueprint, + languages: &[String], +) -> Result { + let mut columns = vec![ + "external_id".to_string(), + "election.external_id".to_string(), + "max_votes".to_string(), + "min_votes".to_string(), + "winning_candidates_num".to_string(), + // Written out rather than left to `contest.hbs` to supply. The wizard + // offers one voting method today, and a workbook that says which one it + // means is a document somebody can read and edit; a template default is + // not, and the builder now warns about every column it has to stand in + // for. + "voting_type".to_string(), + "counting_algorithm".to_string(), + "description".to_string(), + "presentation.over_vote_policy".to_string(), + "presentation.under_vote_policy".to_string(), + "presentation.blank_vote_policy".to_string(), + "presentation.invalid_vote_policy".to_string(), + "presentation.sort_order".to_string(), + ]; + columns.extend(i18n_columns("presentation", languages)); + + let mut rows = Vec::new(); + for election in &plan.elections { + for (order, contest) in election.contests.iter().enumerate() { + let mut row = vec![ + Cell::text(contest.external_id.clone()), + Cell::text(election.external_id.clone()), + Cell::Int(contest.max_votes), + // The wizard does not ask, and a required minimum is a way to + // stop somebody voting at all. + Cell::Int(0), + Cell::Int(contest.winners), + Cell::text("non-preferential"), + Cell::text("plurality-at-large"), + Cell::text(contest.description.clone()), + Cell::text(plan.policies.over_vote()), + Cell::text(plan.policies.under_vote()), + Cell::text(plan.policies.blank_vote()), + Cell::text(plan.policies.invalid_vote()), + Cell::Int(order as i64), + ]; + row.extend(i18n_values(&contest.name, languages)); + rows.push(row); + } + } + + sheet_of("Contests", columns, rows) +} + +fn candidates_sheet( + plan: &Blueprint, + languages: &[String], +) -> Result { + let mut columns = vec![ + "external_id".to_string(), + "contest.external_id".to_string(), + "presentation.sort_order".to_string(), + "presentation.is_explicit_blank".to_string(), + "presentation.is_explicit_invalid".to_string(), + ]; + columns.extend(i18n_columns("presentation", languages)); + + let mut rows = Vec::new(); + for election in &plan.elections { + for contest in &election.contests { + for (order, candidate) in contest.candidates.iter().enumerate() { + let mut row = vec![ + Cell::text(candidate.external_id.clone()), + Cell::text(contest.external_id.clone()), + Cell::Int(order as i64), + Cell::Bool(candidate.explicit_blank), + Cell::Bool(candidate.explicit_invalid), + ]; + row.extend(i18n_values(&candidate.name, languages)); + rows.push(row); + } + } + } + + sheet_of("Candidates", columns, rows) +} + +/// The plan's areas, or the one that covers everybody. +/// +/// A plan that has never thought about districting still needs an area and a +/// ballot link, or no voter sees anything. See [`DEFAULT_AREA_EXTERNAL_ID`]. +fn areas_sheet(plan: &Blueprint) -> Result { + let columns = vec![ + "external_id".to_string(), + "name".to_string(), + "parent.external_id".to_string(), + ]; + + if plan.areas.is_empty() { + return sheet_of( + "Areas", + columns, + vec![vec![ + Cell::text(DEFAULT_AREA_EXTERNAL_ID), + Cell::text(DEFAULT_AREA_NAME), + Cell::Blank, + ]], + ); + } + + let rows = plan + .areas + .iter() + .map(|area| { + vec![ + Cell::text(area.external_id.clone()), + Cell::text(area.name.clone()), + match &area.parent_external_id { + Some(parent) if !parent.is_empty() => { + Cell::text(parent.clone()) + } + _ => Cell::Blank, + }, + ] + }) + .collect(); + + sheet_of("Areas", columns, rows) +} + +/// Which contests appear on which area's ballot. +fn area_contests_sheet(plan: &Blueprint) -> Result { + let every_area: Vec = if plan.areas.is_empty() { + vec![DEFAULT_AREA_EXTERNAL_ID.to_string()] + } else { + plan.areas + .iter() + .map(|area| area.external_id.clone()) + .collect() + }; + + let mut rows = Vec::new(); + for contest in plan + .elections + .iter() + .flat_map(|election| &election.contests) + { + // An empty list means everywhere. A contest nobody assigned is one + // somebody has not got to yet, and dropping it off every ballot would be + // a silent way of losing it. + let on: Vec<&String> = if contest.areas.is_empty() { + every_area.iter().collect() + } else { + contest.areas.iter().collect() + }; + + for area in on { + // A contest may not be listed twice for the same area: both rows + // would mint the same id and one would overwrite the other. + let link = vec![ + Cell::text(area.clone()), + Cell::text(contest.external_id.clone()), + ]; + if !rows.contains(&link) { + rows.push(link); + } + } + } + + sheet_of( + "AreaContests", + vec![ + "area.external_id".to_string(), + "contest.external_id".to_string(), + ], + rows, + ) +} + +/// The voting window, or nothing. +/// +/// Event-wide rather than per election: the wizard asks once, and an event-wide +/// scheduled event covers every election in it. +fn scheduled_events_sheet(plan: &Blueprint) -> Result, Problem> { + let mut rows = Vec::new(); + + for (name, processor, at) in [ + ( + "Voting opens", + "START_VOTING_PERIOD", + &plan.schedule.voting_opens, + ), + ( + "Voting closes", + "END_VOTING_PERIOD", + &plan.schedule.voting_closes, + ), + ] { + if let Some(at) = at.as_ref().filter(|at| !at.is_empty()) { + // An instant, not the wall clock somebody typed. The scheduler reads + // this back through `DateTime::parse_from_rfc3339`, which requires an + // offset — so a bare `2027-03-01T09:00` yields no date, the poller + // drops the event, and the voting period never opens. Nothing on that + // path reports anything. + rows.push(vec![ + Cell::text(name), + Cell::text(processor), + Cell::text(at.to_rfc3339()?), + ]); + } + } + + if rows.is_empty() { + return Ok(None); + } + + sheet_of( + "ScheduledEvents", + vec![ + "event_name".to_string(), + "event_type".to_string(), + "scheduled_datetime".to_string(), + ], + rows, + ) + .map(Some) +} + +/// The files the architect produces that are not part of an import. +/// +/// A ceremony schedule, a list of who to call, and a list of who holds the key. +/// None of them has a home in an election event, and all three are what the client +/// actually asks for — so they travel beside the archive, like the administrator +/// and template files the workbook reader produces. +/// +/// The plan itself is written out too. That is what makes the wizard resumable +/// without parsing its own output back, which is how the TypeScript version did it +/// and why its round trip lost the trustee threshold and every ceremony date. +pub fn side_files(plan: &Blueprint) -> Vec<(String, String)> { + let mut files = Vec::new(); + + let plan_json = + serde_json::to_string_pretty(plan).unwrap_or_else(|_| "{}".to_string()); + files.push(("blueprint.json".to_string(), plan_json + "\n")); + + let ceremony = serde_json::json!({ + "_comment": "Dates people have to attend. Not part of the import.", + "key_ceremony": plan.schedule.key_ceremony, + "tally_ceremony": plan.schedule.tally_ceremony, + "voting_opens": plan.schedule.voting_opens, + "voting_closes": plan.schedule.voting_closes, + "milestones": plan.schedule.milestones, + }); + files.push(("ceremony_schedule.json".to_string(), pretty(&ceremony))); + + if !plan.contacts.is_empty() { + files.push(( + "points_of_contact.json".to_string(), + pretty(&serde_json::json!(plan.contacts)), + )); + } + + if !plan.trustees.is_empty() { + files.push(( + "trustees_list.json".to_string(), + pretty(&serde_json::json!({ + "threshold": plan.trustee_threshold, + "trustees": plan.trustees, + })), + )); + } + + files +} + +fn pretty(value: &serde_json::Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()) + + "\n" +} + +#[cfg(test)] +#[path = "architect_tests.rs"] +mod architect_tests; diff --git a/packages/sequent-core/src/election_config/architect_tests.rs b/packages/sequent-core/src/election_config/architect_tests.rs new file mode 100644 index 00000000000..03e044990a4 --- /dev/null +++ b/packages/sequent-core/src/election_config/architect_tests.rs @@ -0,0 +1,1040 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Tests for [`super`]. + +use super::*; +use crate::election_config::{ + build, validate, BuildOptions, Bundle, ImportElectionEventSchema, + TemplateSet, +}; + +/// A moment in a zone that does not observe daylight saving. +/// +/// Phoenix rather than Los Angeles on purpose: a March window in California +/// crosses the clock change, which is a real thing worth warning about and a +/// distraction in a fixture every other test builds on. The crossing has its +/// own test. +fn at(local: &str) -> Timestamp { + Timestamp::new(local, "America/Phoenix", -420) +} + +/// A plan somebody could plausibly have filled in, and which has nothing wrong +/// with it. Every test breaks one thing about it. +fn sound() -> Blueprint { + Blueprint { + version: BLUEPRINT_VERSION, + external_id: "union-2027".to_string(), + name: Translated::new("Union Election 2027"), + languages: vec!["en".to_string(), "es".to_string()], + logo_url: None, + contacts: vec![Contact { + name: "Dana Reed".to_string(), + role: "Returning officer".to_string(), + email: "dana@example.org".to_string(), + }], + trustees: vec![ + Trustee { + name: "A".to_string(), + email: "a@example.org".to_string(), + }, + Trustee { + name: "B".to_string(), + email: "b@example.org".to_string(), + }, + Trustee { + name: "C".to_string(), + email: "c@example.org".to_string(), + }, + ], + trustee_threshold: 2, + areas: vec![], + schedule: Schedule { + key_ceremony: Some(at("2027-02-01T10:00")), + voting_opens: Some(at("2027-03-01T09:00")), + voting_closes: Some(at("2027-03-15T17:00")), + tally_ceremony: Some(at("2027-03-16T10:00")), + milestones: vec![Milestone { + event: "Candidate nominations close".to_string(), + date: "2027-01-15".to_string(), + }], + }, + elections: vec![PlannedElection { + external_id: "officers".to_string(), + name: Translated::new("Officers"), + contests: vec![PlannedContest { + external_id: "president".to_string(), + name: Translated::new("President"), + description: "Elects the president".to_string(), + max_votes: 1, + winners: 1, + candidates: vec![ + PlannedCandidate { + external_id: "alice".to_string(), + name: Translated::new("Alice"), + explicit_blank: false, + explicit_invalid: false, + }, + PlannedCandidate { + external_id: "bob".to_string(), + name: Translated::new("Bob"), + explicit_blank: false, + explicit_invalid: false, + }, + ], + areas: vec![], + }], + }], + policies: Policies::default(), + notes: String::new(), + } +} + +fn compiled(plan: &Blueprint) -> Bundle { + let workbook = to_workbook(plan).expect("the plan compiles to rows"); + let templates = TemplateSet::builtin().unwrap(); + match build(&workbook, &templates, &BuildOptions::default()) { + Ok(bundle) => bundle, + Err(report) => panic!("expected a clean build, got:\n{report}"), + } +} + +fn codes(report: &Report) -> Vec { + report + .problems + .iter() + .map(|problem| format!("{:?}", problem.code)) + .collect() +} + +fn says(report: &Report, needle: &str) -> bool { + report + .problems + .iter() + .any(|problem| problem.message.contains(needle)) +} + +// -- the property the whole design rests on -------------------------------- + +#[test] +fn a_plan_becomes_a_bundle_the_platform_accepts() { + // The wizard is a different way of filling in the same rows, so it inherits + // the builder, the templates, the ids, the CSV shapes and the validator. If + // this passes, none of those had to be written a second time. + let bundle = compiled(&sound()); + + let schema: ImportElectionEventSchema = serde_json::from_value( + bundle.export.clone(), + ) + .expect("the compiled export must deserialize into the import schema"); + + let report = validate(&schema); + assert!( + !report.has_errors(), + "a sound plan must compile to a valid bundle:\n{report}" + ); +} + +#[test] +fn a_plan_leaves_nothing_for_the_base_template_to_stand_in_for() { + // `contest.hbs` carries values for the five ballot-shaping fields, so a + // workbook that omits one gets it without being asked and `build` warns. The + // wizard is not allowed to be one of those workbooks: `contests_sheet` writes + // all five, so the compiled bundle says what the plan meant and nothing more. + let bundle = compiled(&sound()); + let stood_in: Vec<&str> = bundle + .warnings + .problems + .iter() + .map(|problem| problem.message.as_str()) + .filter(|message| message.contains("stands in")) + .collect(); + assert!(stood_in.is_empty(), "{stood_in:#?}"); +} + +#[test] +fn the_same_plan_compiles_to_the_same_bytes_twice() { + // The TypeScript stamped new Date() into every entity, so no two runs of the + // same answers agreed. Ids are derived and timestamps fixed, so these do. + let first = serde_json::to_string(&compiled(&sound()).export).unwrap(); + let second = serde_json::to_string(&compiled(&sound()).export).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn it_produces_the_members_the_importer_dispatches_on() { + // Not `election_config.json` inside a nested `official_election_setup.zip`, + // which is what the TypeScript wrote and what no importer reads. + let bundle = compiled(&sound()); + let layout = crate::election_config::archive::layout(&bundle); + let names: Vec<&str> = + layout.importable.iter().map(|a| a.name.as_str()).collect(); + + assert!(names + .iter() + .any(|name| name.starts_with("export_election_event") + && name.ends_with(".json"))); + assert!(names + .iter() + .any(|name| name.starts_with("export_scheduled_events"))); + assert!(!names + .iter() + .any(|name| name.contains("election_config.json"))); + assert!(!names + .iter() + .any(|name| name.contains("official_election_setup"))); +} + +#[test] +fn no_realm_is_invented() { + // The TypeScript embedded a realm copied from one environment. The importer + // takes keycloak_event_realm wholesale, so that would replace whatever the + // target environment had provisioned. + let bundle = compiled(&sound()); + assert_eq!( + bundle.export["keycloak_event_realm"], + serde_json::Value::Null + ); +} + +// -- what the plan turns into ---------------------------------------------- + +#[test] +fn every_election_contest_and_candidate_arrives() { + let bundle = compiled(&sound()); + assert_eq!(bundle.export["elections"].as_array().unwrap().len(), 1); + assert_eq!(bundle.export["contests"].as_array().unwrap().len(), 1); + assert_eq!(bundle.export["candidates"].as_array().unwrap().len(), 2); +} + +#[test] +fn names_arrive_in_every_language_the_plan_enables() { + let mut plan = sound(); + plan.name + .by_language + .insert("es".to_string(), "Elección Sindical 2027".to_string()); + let bundle = compiled(&plan); + + let i18n = &bundle.export["election_event"]["presentation"]["i18n"]; + assert_eq!(i18n["en"]["name"], serde_json::json!("Union Election 2027")); + assert_eq!( + i18n["es"]["name"], + serde_json::json!("Elección Sindical 2027") + ); +} + +#[test] +fn a_missing_translation_falls_back_rather_than_leaving_a_blank_ballot_line() { + // Spanish is enabled and the contest has no Spanish name. Showing nothing + // would be worse than showing the English. + let bundle = compiled(&sound()); + let i18n = &bundle.export["contests"][0]["presentation"]["i18n"]; + assert_eq!(i18n["en"]["name"], serde_json::json!("President")); + assert_eq!(i18n["es"]["name"], serde_json::json!("President")); +} + +#[test] +fn the_enabled_languages_reach_the_login_page() { + // Nothing else in the platform sets supportedLocales, so this is the only + // thing that puts a language in Keycloak's picker. + let bundle = compiled(&sound()); + assert_eq!( + bundle.realm_patch.patch["supportedLocales"], + serde_json::json!(["en", "es"]) + ); + assert_eq!( + bundle.realm_patch.patch["displayName"], + serde_json::json!("Union Election 2027") + ); +} + +#[test] +fn a_plan_with_no_language_still_produces_a_readable_ballot() { + let mut plan = sound(); + plan.languages = vec![]; + let bundle = compiled(&plan); + assert_eq!( + bundle.export["election_event"]["presentation"]["i18n"]["en"]["name"], + serde_json::json!("Union Election 2027") + ); +} + +#[test] +fn the_voting_window_becomes_the_scheduled_events_the_importer_reads() { + let bundle = compiled(&sound()); + assert_eq!(bundle.scheduled_events.len(), 2); + + // Event-wide: the wizard asks once, and that covers every election. + let payload = &bundle.scheduled_events.rows[0][10]; + assert_eq!( + *payload, + crate::election_config::JsonField::Value( + serde_json::json!({"election_id": null}) + ) + ); +} + +#[test] +fn a_plan_with_no_dates_still_builds_and_says_it_needs_hands() { + let mut plan = sound(); + plan.schedule = Schedule::default(); + let bundle = compiled(&plan); + assert!(bundle.scheduled_events.is_empty()); + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("by hand"))); +} + +#[test] +fn every_contest_lands_on_the_one_area() { + // The wizard does not do districting, so one area covers everybody. Without + // it there is no ballot and no voter sees anything. + let bundle = compiled(&sound()); + assert_eq!(bundle.export["areas"].as_array().unwrap().len(), 1); + assert_eq!( + bundle.export["areas"][0]["name"], + serde_json::json!(DEFAULT_AREA_NAME) + ); + assert_eq!(bundle.export["area_contests"].as_array().unwrap().len(), 1); +} + +#[test] +fn the_policies_become_the_platforms_own_values() { + let mut plan = sound(); + plan.policies = Policies { + over_vote: Policy::Restricted, + blank_vote: Policy::Allowed, + under_vote: Policy::Warn, + invalid_vote: Policy::Restricted, + }; + let presentation = + compiled(&plan).export["contests"][0]["presentation"].clone(); + + assert_eq!( + presentation["over_vote_policy"], + serde_json::json!("not-allowed-with-msg-and-disable") + ); + assert_eq!( + presentation["blank_vote_policy"], + serde_json::json!("allowed") + ); + assert_eq!(presentation["under_vote_policy"], serde_json::json!("warn")); + assert_eq!( + presentation["invalid_vote_policy"], + serde_json::json!("not-allowed") + ); +} + +#[test] +fn a_multi_winner_contest_elects_what_the_plan_says() { + // The TypeScript hard-coded winning_candidates_num to 1 while letting + // max_votes be anything, so "choose 3" silently elected one person. + let mut plan = sound(); + let contest = &mut plan.elections[0].contests[0]; + contest.max_votes = 3; + contest.winners = 3; + contest.candidates.push(PlannedCandidate { + external_id: "carol".to_string(), + name: Translated::new("Carol"), + explicit_blank: false, + explicit_invalid: false, + }); + + let bundle = compiled(&plan); + assert_eq!( + bundle.export["contests"][0]["max_votes"], + serde_json::json!(3) + ); + assert_eq!( + bundle.export["contests"][0]["winning_candidates_num"], + serde_json::json!(3) + ); +} + +#[test] +fn a_blank_option_is_marked_as_one_rather_than_becoming_a_candidate() { + let mut plan = sound(); + plan.elections[0].contests[0] + .candidates + .push(PlannedCandidate { + external_id: "none-of-the-above".to_string(), + name: Translated::new("None of the above"), + explicit_blank: true, + explicit_invalid: false, + }); + + let bundle = compiled(&plan); + let candidates = bundle.export["candidates"].as_array().unwrap(); + let blank = candidates + .iter() + .find(|c| c["external_id"] == serde_json::json!("none-of-the-above")) + .expect("the blank option"); + assert_eq!( + blank["presentation"]["is_explicit_blank"], + serde_json::json!(true) + ); +} + +#[test] +fn the_order_things_were_arranged_in_survives() { + // The wizard lets somebody drag candidates into an order. Losing it means a + // ballot in a different order than the one they approved. + let mut plan = sound(); + plan.elections[0].contests[0].candidates.reverse(); + let bundle = compiled(&plan); + + let candidates = bundle.export["candidates"].as_array().unwrap(); + let bob = candidates + .iter() + .find(|c| c["external_id"] == serde_json::json!("bob")) + .unwrap(); + assert_eq!(bob["presentation"]["sort_order"], serde_json::json!(0)); +} + +// -- the plan's own checks ------------------------------------------------- + +#[test] +fn a_sound_plan_has_nothing_to_report() { + let report = validate_plan(&sound()); + assert!(report.is_empty(), "{report}"); +} + +#[test] +fn a_threshold_no_number_of_trustees_can_meet_is_an_error() { + // The worst failure mode there is: everything works until the tally, and then + // the result cannot be decrypted by anybody. + let mut plan = sound(); + plan.trustee_threshold = 5; + let report = validate_plan(&plan); + assert!(report.has_errors()); + assert!(says(&report, "could never be decrypted")); +} + +#[test] +fn a_threshold_of_one_is_allowed_but_said_out_loud() { + let mut plan = sound(); + plan.trustee_threshold = 1; + let report = validate_plan(&plan); + assert!(!report.has_errors()); + assert!(says(&report, "one trustee alone")); +} + +#[test] +fn a_threshold_of_zero_is_refused() { + let mut plan = sound(); + plan.trustee_threshold = 0; + assert!(validate_plan(&plan).has_errors()); +} + +#[test] +fn voting_that_closes_before_it_opens_is_refused() { + let mut plan = sound(); + plan.schedule.voting_closes = Some(at("2027-01-01T00:00")); + let report = validate_plan(&plan); + assert!(says(&report, "closes before it opens")); +} + +#[test] +fn a_key_ceremony_after_voting_opens_is_refused() { + // The key has to exist before a vote can be encrypted with it. + let mut plan = sound(); + plan.schedule.key_ceremony = Some(at("2027-03-02T10:00")); + let report = validate_plan(&plan); + assert!(says(&report, "before voting opens")); +} + +#[test] +fn a_tally_before_voting_closes_is_refused() { + let mut plan = sound(); + plan.schedule.tally_ceremony = Some(at("2027-03-01T10:00")); + let report = validate_plan(&plan); + assert!(says(&report, "votes that had not been cast")); +} + +/// The whole reason this plan carries offsets. The scheduler reads the emitted +/// date with `DateTime::parse_from_rfc3339`, which requires one — a wall clock +/// yields no date, the poller drops the event, and voting never opens with +/// nothing anywhere saying why. +#[test] +fn the_voting_window_is_emitted_as_an_instant_the_scheduler_can_read() { + let workbook = to_workbook(&sound()).expect("a sound plan should compile"); + let rows = workbook.rows("scheduledevents"); + assert_eq!(rows.len(), 2, "an opening and a closing"); + + for row in rows { + let written = row.text("scheduled_datetime").expect("a date"); + assert!( + chrono::DateTime::parse_from_rfc3339(&written).is_ok(), + "the platform's own parser rejects {written:?}" + ); + } +} + +/// Real, common, and invisible in the clock times: a March window in California +/// is an hour shorter than it looks. Said out loud rather than refused. +#[test] +fn a_voting_window_crossing_a_clock_change_is_said_out_loud() { + let mut plan = sound(); + plan.schedule.voting_opens = Some(Timestamp::new( + "2027-03-01T09:00", + "America/Los_Angeles", + -480, + )); + plan.schedule.voting_closes = Some(Timestamp::new( + "2027-03-15T17:00", + "America/Los_Angeles", + -420, + )); + + let report = validate_plan(&plan); + + assert!(says(&report, "daylight-saving change")); + assert!(!report.has_errors(), "legitimate, so not an error"); +} + +/// A plan written before the wizard knew about zones still opens, and still +/// means what it meant — UTC — rather than failing or silently shifting. +#[test] +fn a_plan_saved_before_timezones_existed_still_opens() { + let text = r#"{ + "version": 1, + "external_id": "old", + "schedule": { + "voting_opens": "2027-03-01T09:00", + "voting_closes": "2027-03-15T17:00" + } + }"#; + + let plan: Blueprint = serde_json::from_str(text).expect("an older plan"); + let opens = plan.schedule.voting_opens.as_ref().expect("a time"); + + assert_eq!(opens.local, "2027-03-01T09:00"); + assert_eq!(opens.offset_minutes, 0); + assert_eq!(opens.to_rfc3339().unwrap(), "2027-03-01T09:00:00+00:00"); +} + +#[test] +fn an_incomplete_voting_window_is_a_warning_not_an_error() { + // A plan being filled in is not a broken plan, and it still has to be + // saveable. + let mut plan = sound(); + plan.schedule.voting_closes = None; + let report = validate_plan(&plan); + assert!(!report.has_errors(), "{report}"); + assert!(says(&report, "opened")); +} + +#[test] +fn a_contest_electing_more_than_a_voter_may_choose_is_refused() { + let mut plan = sound(); + plan.elections[0].contests[0].winners = 3; + let report = validate_plan(&plan); + assert!(says(&report, "a voter may only choose")); + assert!(codes(&report).contains(&"ContestArithmetic".to_string())); +} + +#[test] +fn a_contest_electing_more_than_it_has_candidates_is_refused() { + let mut plan = sound(); + plan.elections[0].contests[0].max_votes = 5; + plan.elections[0].contests[0].winners = 5; + let report = validate_plan(&plan); + assert!(says(&report, "from a field of 2")); +} + +#[test] +fn blank_and_invalid_options_do_not_count_as_candidates() { + // Filling a two-winner contest with "none of the above" is not a field of two. + let mut plan = sound(); + let contest = &mut plan.elections[0].contests[0]; + contest.candidates.truncate(1); + contest.candidates.push(PlannedCandidate { + external_id: "blank".to_string(), + name: Translated::new("None of the above"), + explicit_blank: true, + explicit_invalid: false, + }); + contest.max_votes = 2; + contest.winners = 2; + + let report = validate_plan(&plan); + assert!(says(&report, "from a field of 1")); +} + +#[test] +fn a_contest_with_no_candidates_yet_is_a_warning() { + let mut plan = sound(); + plan.elections[0].contests[0].candidates.clear(); + let report = validate_plan(&plan); + assert!(!report.has_errors(), "{report}"); + assert!(says(&report, "no candidates yet")); +} + +#[test] +fn areas_inside_each_other_are_refused_by_the_plan_validator() { + // Self-parenting had its own message; a two-hop loop passed the plan validator + // and was only caught later, in bundle vocabulary the author never wrote. + let mut plan = sound(); + plan.areas = vec![ + PlannedArea { + external_id: "north".to_string(), + name: "North".to_string(), + parent_external_id: Some("south".to_string()), + }, + PlannedArea { + external_id: "south".to_string(), + name: "South".to_string(), + parent_external_id: Some("north".to_string()), + }, + ]; + + let report = validate_plan(&plan); + assert!( + report + .errors() + .any(|problem| problem.code == Code::AreaCycle), + "expected an area cycle, got:\n{report}" + ); +} + +#[test] +fn a_plan_with_no_elections_is_refused() { + let mut plan = sound(); + plan.elections.clear(); + assert!(validate_plan(&plan).has_errors()); +} + +#[test] +fn a_plan_with_no_name_or_identifier_is_refused() { + let mut plan = sound(); + plan.name = Translated::default(); + plan.external_id = " ".to_string(); + let report = validate_plan(&plan); + assert_eq!(report.errors().count(), 2, "{report}"); +} + +#[test] +fn no_points_of_contact_is_worth_saying() { + let mut plan = sound(); + plan.contacts.clear(); + assert!(says(&validate_plan(&plan), "who gets called")); +} + +#[test] +fn a_plan_from_a_newer_version_is_refused_rather_than_half_read() { + // Opening it would silently drop whatever that version added, and the author + // would not know which parts survived. + let mut plan = sound(); + plan.version = BLUEPRINT_VERSION + 1; + let report = validate_plan(&plan); + assert!(report.has_errors()); + assert!(says(&report, "newer version")); +} + +// -- round trip and side files --------------------------------------------- + +#[test] +fn a_plan_survives_being_saved_and_opened() { + // What the TypeScript could not do: it reconstructed its state by parsing the + // generated bundle, so the threshold and the ceremony dates were lost every + // time. + let plan = sound(); + let saved = serde_json::to_string(&plan).unwrap(); + let opened: Blueprint = serde_json::from_str(&saved).unwrap(); + assert_eq!(plan, opened); +} + +#[test] +fn an_older_plan_missing_the_newer_fields_still_opens() { + // Everything optional has a default, so a plan saved before a field existed + // is still readable. A wizard whose saved documents stop opening is a wizard + // nobody trusts. + let minimal = serde_json::json!({ + "version": 1, + "external_id": "union-2027", + }); + let plan: Blueprint = serde_json::from_value(minimal).unwrap(); + assert_eq!(plan.external_id, "union-2027"); + assert_eq!(plan.trustee_threshold, 2); + assert!(plan.elections.is_empty()); +} + +#[test] +fn the_plan_travels_with_its_output() { + let files = side_files(&sound()); + let names: Vec<&str> = + files.iter().map(|(name, _)| name.as_str()).collect(); + assert!(names.contains(&"blueprint.json")); + assert!(names.contains(&"ceremony_schedule.json")); + assert!(names.contains(&"points_of_contact.json")); + assert!(names.contains(&"trustees_list.json")); +} + +#[test] +fn the_saved_plan_is_the_plan() { + // Not a summary of it: opening the archive and reading blueprint.json has to + // give back something the wizard can resume from. + let plan = sound(); + let files = side_files(&plan); + let (_, saved) = files + .iter() + .find(|(name, _)| name == "blueprint.json") + .unwrap(); + let reopened: Blueprint = serde_json::from_str(saved).unwrap(); + assert_eq!(reopened, plan); +} + +#[test] +fn the_side_files_are_json_and_end_in_a_newline() { + for (name, contents) in side_files(&sound()) { + serde_json::from_str::(&contents) + .unwrap_or_else(|error| panic!("{name}: {error}")); + assert!(contents.ends_with('\n'), "{name}"); + } +} + +#[test] +fn a_plan_with_nobody_in_it_writes_no_empty_lists() { + // An empty points_of_contact.json is a file somebody has to open to discover + // it says nothing. + let mut plan = sound(); + plan.contacts.clear(); + plan.trustees.clear(); + let names: Vec = side_files(&plan) + .into_iter() + .map(|(name, _)| name) + .collect(); + assert!(!names.contains(&"points_of_contact.json".to_string())); + assert!(!names.contains(&"trustees_list.json".to_string())); + // The plan and the ceremony dates are always worth writing. + assert!(names.contains(&"blueprint.json".to_string())); +} + +// -- districting ----------------------------------------------------------- + +/// The sound plan, districted: two locals inside a region, and a contest that +/// only one of them votes on. +fn districted() -> Blueprint { + let mut plan = sound(); + plan.areas = vec![ + PlannedArea { + external_id: "region-north".to_string(), + name: "North Region".to_string(), + parent_external_id: None, + }, + PlannedArea { + external_id: "local-1".to_string(), + name: "North Local 1".to_string(), + parent_external_id: Some("region-north".to_string()), + }, + PlannedArea { + external_id: "local-2".to_string(), + name: "North Local 2".to_string(), + parent_external_id: Some("region-north".to_string()), + }, + ]; + // The president is everywhere; the local officer is one local's business. + plan.elections[0].contests.push(PlannedContest { + external_id: "local-officer".to_string(), + name: Translated::new("Local Officer"), + description: String::new(), + max_votes: 1, + winners: 1, + candidates: vec![PlannedCandidate { + external_id: "carol".to_string(), + name: Translated::new("Carol"), + explicit_blank: false, + explicit_invalid: false, + }], + areas: vec!["local-1".to_string()], + }); + plan +} + +#[test] +fn a_plan_with_no_areas_still_puts_every_contest_on_one_ballot() { + // Districting is optional. Without an area and a link, no voter sees anything. + let bundle = compiled(&sound()); + assert_eq!(bundle.export["areas"].as_array().unwrap().len(), 1); + assert_eq!( + bundle.export["areas"][0]["name"], + serde_json::json!(DEFAULT_AREA_NAME) + ); + assert_eq!(bundle.export["area_contests"].as_array().unwrap().len(), 1); +} + +#[test] +fn a_districted_plan_becomes_a_bundle_the_platform_accepts() { + let bundle = compiled(&districted()); + let schema: ImportElectionEventSchema = + serde_json::from_value(bundle.export.clone()).unwrap(); + let report = validate(&schema); + assert!(!report.has_errors(), "{report}"); +} + +#[test] +fn the_areas_arrive_with_their_tree_intact() { + let bundle = compiled(&districted()); + let areas = bundle.export["areas"].as_array().unwrap(); + assert_eq!(areas.len(), 3); + + let region = areas + .iter() + .find(|area| area["name"] == serde_json::json!("North Region")) + .unwrap(); + let local = areas + .iter() + .find(|area| area["name"] == serde_json::json!("North Local 1")) + .unwrap(); + + assert_eq!(region["parent_id"], serde_json::Value::Null); + assert_eq!(local["parent_id"], region["id"]); +} + +#[test] +fn a_contest_assigned_to_no_area_is_on_every_ballot() { + // What a plan that has not thought about it wants, and what dropping the + // contest instead would silently cost. + let bundle = compiled(&districted()); + let president = &bundle.export["contests"] + .as_array() + .unwrap() + .iter() + .find(|contest| { + contest["external_id"] == serde_json::json!("president") + }) + .unwrap()["id"]; + + let on: Vec<&serde_json::Value> = bundle.export["area_contests"] + .as_array() + .unwrap() + .iter() + .filter(|link| &link["contest_id"] == president) + .collect(); + assert_eq!(on.len(), 3, "the president should be on all three ballots"); +} + +#[test] +fn a_local_contest_is_only_on_the_ballots_it_names() { + let bundle = compiled(&districted()); + let local_officer = &bundle.export["contests"] + .as_array() + .unwrap() + .iter() + .find(|contest| { + contest["external_id"] == serde_json::json!("local-officer") + }) + .unwrap()["id"]; + + let links: Vec<&serde_json::Value> = bundle.export["area_contests"] + .as_array() + .unwrap() + .iter() + .filter(|link| &link["contest_id"] == local_officer) + .collect(); + assert_eq!(links.len(), 1); + + let local_1 = bundle.export["areas"] + .as_array() + .unwrap() + .iter() + .find(|area| area["name"] == serde_json::json!("North Local 1")) + .unwrap(); + assert_eq!(links[0]["area_id"], local_1["id"]); +} + +#[test] +fn assigning_a_contest_to_the_same_area_twice_produces_one_link() { + // Both rows would mint the same id and one would overwrite the other. + let mut plan = districted(); + plan.elections[0].contests[1].areas = + vec!["local-1".to_string(), "local-1".to_string()]; + let bundle = compiled(&plan); + let local_officer = &bundle.export["contests"][1]["id"]; + assert_eq!( + bundle.export["area_contests"] + .as_array() + .unwrap() + .iter() + .filter(|link| &link["contest_id"] == local_officer) + .count(), + 1 + ); +} + +#[test] +fn a_contest_naming_an_area_nobody_defined_is_refused() { + let mut plan = districted(); + plan.elections[0].contests[1].areas = vec!["nowhere".to_string()]; + let report = validate_plan(&plan); + assert!(says(&report, "no area has the identifier 'nowhere'")); + assert!(report.has_errors()); +} + +#[test] +fn two_areas_may_not_share_a_name() { + // The voters CSV resolves by name, so voters would land in whichever the + // importer found first. + let mut plan = districted(); + plan.areas[1].name = "North Region".to_string(); + let report = validate_plan(&plan); + assert!(says(&report, "both named 'North Region'")); +} + +#[test] +fn two_areas_may_not_share_an_identifier() { + // The identifier is the whole of what `uid` hashes, so the second area mints the + // id the first already has and replaces it wherever it is referenced. The + // builder catches this as a workbook row; a wizard author never saw a workbook. + let mut plan = districted(); + plan.areas[1].external_id = "region-north".to_string(); + let report = validate_plan(&plan); + assert!( + says( + &report, + "'region-north' is already the identifier of the area at areas[0]" + ), + "{report}" + ); + assert!(report + .errors() + .any(|problem| problem.code == Code::DuplicateId)); + assert!(report + .errors() + .any(|problem| problem.path == "areas[1].external_id")); +} + +#[test] +fn two_contests_in_different_elections_may_not_share_an_identifier() { + // Scoped across the plan, not per election: `uid("contest", &[external_id])` + // takes no enclosing election, so the same identifier in two elections is one + // contest on the ballot and one of the two vanishes. + let mut plan = sound(); + let mut second = plan.elections[0].clone(); + second.external_id = "board".to_string(); + plan.elections.push(second); + + let report = validate_plan(&plan); + assert!( + says( + &report, + "'president' is already the identifier of the contest at \ + elections[0].contests[0]" + ), + "{report}" + ); + assert!(report + .errors() + .any(|problem| problem.path == "elections[1].contests[0].external_id")); +} + +#[test] +fn two_candidates_may_not_share_an_identifier() { + let mut plan = sound(); + plan.elections[0].contests[0].candidates[1].external_id = + "alice".to_string(); + let report = validate_plan(&plan); + assert!( + says( + &report, + "'alice' is already the identifier of the candidate at \ + elections[0].contests[0].candidates[0]" + ), + "{report}" + ); +} + +#[test] +fn two_elections_may_not_share_an_identifier() { + let mut plan = sound(); + let second = plan.elections[0].clone(); + plan.elections.push(second); + let report = validate_plan(&plan); + assert!( + says( + &report, + "'officers' is already the identifier of the election at elections[0]" + ), + "{report}" + ); +} + +#[test] +fn an_unset_identifier_is_a_missing_field_and_not_a_duplicate() { + // Two blank ids are two things to fill in, not two names for one thing, and + // saying "duplicate" about them would point at the wrong repair. + let mut plan = districted(); + plan.areas[0].external_id = String::new(); + plan.areas[1].external_id = " ".to_string(); + let report = validate_plan(&plan); + assert!( + !report + .problems + .iter() + .any(|problem| problem.code == Code::DuplicateId + && problem.path.starts_with("areas[") + && problem.path.ends_with(".external_id")), + "{report}" + ); + assert_eq!( + report + .errors() + .filter(|problem| problem.code == Code::MissingField + && problem.message == "an area needs an identifier") + .count(), + 2, + "{report}" + ); +} + +#[test] +fn an_area_needs_a_name_because_that_is_what_a_voter_is_matched_on() { + let mut plan = districted(); + plan.areas[0].name = " ".to_string(); + let report = validate_plan(&plan); + assert!(says(&report, "identifies a voter's area by name")); +} + +#[test] +fn an_area_cannot_be_inside_itself() { + let mut plan = districted(); + plan.areas[0].parent_external_id = Some("region-north".to_string()); + let report = validate_plan(&plan); + assert!(says(&report, "cannot be inside itself")); + assert!(codes(&report).contains(&"AreaCycle".to_string())); +} + +#[test] +fn a_parent_that_does_not_exist_is_refused() { + let mut plan = districted(); + plan.areas[1].parent_external_id = Some("region-south".to_string()); + let report = validate_plan(&plan); + assert!(says(&report, "no area has the identifier 'region-south'")); +} + +#[test] +fn a_districted_plan_survives_being_saved_and_opened() { + let plan = districted(); + let opened: Blueprint = + serde_json::from_str(&serde_json::to_string(&plan).unwrap()).unwrap(); + assert_eq!(plan, opened); +} + +#[test] +fn a_plan_saved_before_districting_existed_still_opens() { + // Everything about areas is optional, so an older plan reads as one ballot + // for everybody — which is what it was. + let older = serde_json::json!({ + "version": 1, + "external_id": "union-2027", + "elections": [{ + "external_id": "officers", + "contests": [{"external_id": "president"}], + }], + }); + let plan: Blueprint = serde_json::from_value(older).unwrap(); + assert!(plan.areas.is_empty()); + assert!(plan.elections[0].contests[0].areas.is_empty()); +} diff --git a/packages/sequent-core/src/election_config/archive.rs b/packages/sequent-core/src/election_config/archive.rs new file mode 100644 index 00000000000..a9a0ffa2149 --- /dev/null +++ b/packages/sequent-core/src/election_config/archive.rs @@ -0,0 +1,642 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! What a bundle becomes as files, and the zip the Admin Portal accepts. +//! +//! Pure, like the rest of the module: this returns named byte blobs and never +//! touches a filesystem. `step-cli` writes them to a directory; a browser offers +//! them as downloads. Which is the whole reason the split exists — a bundle with a +//! dangling reference leaves no half-written output behind either way, because +//! nothing is written until everything is built. +//! +//! Two groups, and the line between them matters. The **importable** members go +//! inside the zip. The **auxiliary** files go beside it: administrators, roles and +//! communication templates are tenant- or portal-scoped, and putting them in the +//! zip would mean importing an election event could silently create administrator +//! accounts. + +use crate::election_config::build::{ + Bundle, CommunicationTemplate, PlainTable, +}; +use crate::election_config::emit::{json_csv, member, plain_csv}; +use serde_json::{json, Map, Value}; + +/// One file a bundle becomes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Artifact { + /// Path relative to the output directory. May contain `/`. + pub name: String, + pub bytes: Vec, +} + +impl Artifact { + fn text(name: impl Into, text: String) -> Self { + Artifact { + name: name.into(), + bytes: text.into_bytes(), + } + } + + /// A JSON file, pretty-printed with a trailing newline. + /// + /// Two-space indentation throughout, which is `serde_json`'s and differs from + /// the one-space the Python used for the event document. Cosmetic — the + /// importer parses it — but it does mean the first regeneration of an existing + /// event reindents the whole file once. + fn json(name: impl Into, value: &Value) -> Self { + let mut text = serde_json::to_string_pretty(value) + .unwrap_or_else(|_| "null".to_string()); + text.push('\n'); + Artifact::text(name, text) + } + + fn csv(name: impl Into, table: &PlainTable) -> Self { + let columns: Vec<&str> = + table.columns.iter().map(String::as_str).collect(); + Artifact::text(name, plain_csv(&columns, &table.rows)) + } +} + +/// Everything a bundle is written as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Layout { + /// The members of the importable zip. + pub importable: Vec, + + /// What the zip should be called. + pub archive_name: String, + + /// Files that belong beside the zip, never inside it. + pub auxiliary: Vec, +} + +/// Turn a built bundle into the files it is written as. +pub fn layout(bundle: &Bundle) -> Layout { + let suffix = &bundle.event_id; + + let mut importable = vec![ + Artifact::json( + member::file_name(member::ELECTION_EVENT, suffix, "json"), + &bundle.export, + ), + Artifact::csv( + member::file_name(member::VOTERS, suffix, "csv"), + &bundle.voters, + ), + ]; + + // The scheduled-events member is the JSON-in-CSV shape, always written even + // when empty: the voting window lives here, so an absent file and an empty one + // should not be told apart by whether the source had a sheet. + let schedule_columns: Vec<&str> = bundle + .scheduled_events + .columns + .iter() + .map(String::as_str) + .collect(); + importable.push(Artifact::text( + member::file_name(member::SCHEDULED_EVENTS, suffix, "csv"), + json_csv(&schedule_columns, &bundle.scheduled_events.rows), + )); + + if let Some(reports) = &bundle.reports { + importable.push(Artifact::csv( + member::file_name(member::REPORTS, suffix, "csv"), + reports, + )); + } + + Layout { + importable, + archive_name: format!("{}.zip", bundle.slug), + auxiliary: auxiliary(bundle), + } +} + +/// Files the source describes that are not part of the event import. +fn auxiliary(bundle: &Bundle) -> Vec { + let mut written = Vec::new(); + + if let Some(admin_users) = &bundle.admin_users { + written.push(Artifact::csv("admin_users.csv", admin_users)); + } + + if let Some(role_permissions) = &bundle.role_permissions { + // Named after the tenant because nothing rewrites this file's name on the + // way in — which is the one place the tenant id actually matters. + written.push(Artifact::csv( + format!("export_permissions-{}.csv", bundle.tenant_id), + role_permissions, + )); + } + + if !bundle.templates.is_empty() { + let mut manifest = Vec::new(); + for template in &bundle.templates { + let file_name = template.file_name(); + written.push(Artifact::text( + format!("templates/{file_name}"), + template.document.clone(), + )); + manifest.push(template_entry(template, &file_name)); + } + written.push(Artifact::json( + "templates/templates.json", + &Value::Array(manifest), + )); + } + + if !bundle.admin_realm_patch.is_empty() { + written.push(Artifact::json( + "keycloak_admin_realm_patch.json", + &Value::Object(bundle.admin_realm_patch.clone()), + )); + } + + if !bundle.realm_patch.patch.is_empty() { + written.push(Artifact::json( + "keycloak_event_realm_patch.json", + &realm_patch_document(bundle), + )); + } + + written +} + +fn template_entry(template: &CommunicationTemplate, file_name: &str) -> Value { + json!({ + "name": template.name, + "alias": template.alias, + "file": file_name, + "type": template.template_type, + "communication_method": template.communication_method, + "selected_methods": template.selected_methods, + }) +} + +/// The realm patch as a file someone can read and apply. +/// +/// Written even when it was already applied to a base export's realm: it is the +/// readable statement of what the source asked of the realm, and it is what you +/// apply by hand when there was no realm here to apply it to. The comment says +/// which of those happened, because the two need opposite things done next. +fn realm_patch_document(bundle: &Bundle) -> Value { + let applied = bundle + .export + .get("keycloak_event_realm") + .is_some_and(|realm| !realm.is_null()); + + let comment = if applied { + match bundle.auth_preset { + Some(preset) => format!( + "Realm changes this source asks for. Already applied to the realm \ + in the event zip, via the '{preset}' preset." + ), + None => "Realm changes this source asks for. Already applied to the \ + realm in the event zip." + .to_string(), + } + } else { + "Realm changes this source asks for. NOT applied: no base export was \ + given, so the event zip carries no realm and the platform will load its \ + own default. Apply this to that realm." + .to_string() + }; + + let mut document = Map::new(); + document.insert("_comment".to_string(), json!(comment)); + document.insert("auth_preset".to_string(), json!(bundle.auth_preset)); + document.insert( + "patch".to_string(), + Value::Object(bundle.realm_patch.patch.clone()), + ); + + // The two directives are separate fields on RealmPatch rather than keys inside + // the patch, so there is nothing to strip out here — but they are worth stating + // for whoever has to apply the patch by hand, since neither is a plain merge. + if let Some((authenticator, config_alias)) = + &bundle.realm_patch.bind_authenticator_config + { + document.insert( + "bind_authenticator_config".to_string(), + json!({ + "_comment": "Not a merge: point every execution of this \ + authenticator at this config alias.", + "authenticator": authenticator, + "config_alias": config_alias, + }), + ); + } + if let Some(user_profile) = &bundle.realm_patch.user_profile { + document.insert( + "user_profile".to_string(), + json!({ + "_comment": "Not a merge: the user profile is a stringified JSON \ + blob inside the \ + org.keycloak.userprofile.UserProfileProvider \ + component. Parse it, apply these changes to the \ + matching entries of its `attributes` list, and write \ + it back as a string.", + "attributes": user_profile, + }), + ); + } + + Value::Object(document) +} + +/// Zip the members at the archive root, reproducibly. +/// +/// A fixed timestamp and a fixed mode on every entry: without them the archive's +/// bytes change on every run, and "regenerating produced no diff" stops being +/// something anyone can check. +#[cfg(feature = "election_config_archive")] +pub fn zip( + members: &[Artifact], +) -> Result, crate::election_config::Problem> { + use crate::election_config::problem::Code; + use std::io::{Cursor, Write}; + use zip::write::SimpleFileOptions; + + /// 2026-01-01T00:00:00, matching what the Python wrote. + fn fixed_time() -> zip::DateTime { + zip::DateTime::from_date_and_time(2026, 1, 1, 0, 0, 0) + .unwrap_or_default() + } + + let failed = |error: zip::result::ZipError| { + crate::election_config::Problem::error( + Code::InvalidValue, + "archive", + format!("could not be written: {error}"), + ) + }; + + let mut buffer = Vec::new(); + { + let mut writer = zip::ZipWriter::new(Cursor::new(&mut buffer)); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .last_modified_time(fixed_time()) + // 0o644, the mode a normal export has. + .unix_permissions(0o644); + + for artifact in members { + writer + .start_file(artifact.name.clone(), options) + .map_err(failed)?; + writer.write_all(&artifact.bytes).map_err(|error| { + crate::election_config::Problem::error( + Code::InvalidValue, + format!("archive.{}", artifact.name), + format!("could not be written: {error}"), + ) + })?; + } + writer.finish().map_err(failed)?; + } + Ok(buffer) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::election_config::build::{build, BuildOptions}; + use crate::election_config::paths::Cell; + use crate::election_config::render::TemplateSet; + use crate::election_config::sheet::{Sheet, Workbook}; + + fn text(value: &str) -> Cell { + Cell::text(value) + } + + /// The smallest document that builds, plus whatever a test needs. + fn bundle(extra: Vec<(&str, Vec>)>) -> Bundle { + let mut sheets = vec![ + ( + "ElectionEvent", + vec![ + vec![ + text("external_id"), + text("presentation.i18n.en.name"), + ], + vec![text("union-2027"), text("Union Election 2027")], + ], + ), + ( + "Elections", + vec![vec![text("external_id")], vec![text("statewide")]], + ), + ( + "Contests", + vec![ + vec![text("external_id"), text("election.external_id")], + vec![text("president"), text("statewide")], + ], + ), + ( + "Areas", + vec![ + vec![text("external_id"), text("name")], + vec![text("area-north"), text("North")], + ], + ), + ( + "AreaContests", + vec![ + vec![text("area.external_id"), text("contest.external_id")], + vec![text("area-north"), text("president")], + ], + ), + ]; + sheets.extend(extra); + + let workbook = Workbook::new( + sheets + .into_iter() + .map(|(name, grid)| Sheet::from_grid(name, &grid).unwrap()) + .collect(), + ) + .unwrap(); + + let templates = TemplateSet::builtin().unwrap(); + build(&workbook, &templates, &BuildOptions::default()) + .expect("a clean build") + } + + fn names(artifacts: &[Artifact]) -> Vec<&str> { + artifacts.iter().map(|a| a.name.as_str()).collect() + } + + #[test] + fn the_zip_holds_the_members_the_importer_dispatches_on() { + // Anything named otherwise is silently ignored rather than rejected. + let bundle = bundle(vec![]); + let layout = layout(&bundle); + assert_eq!( + names(&layout.importable), + [ + format!("export_election_event-{}.json", bundle.event_id), + format!("export_voters-{}.csv", bundle.event_id), + format!("export_scheduled_events-{}.csv", bundle.event_id), + ] + .iter() + .map(String::as_str) + .collect::>() + ); + assert_eq!(layout.archive_name, "union-2027.zip"); + } + + #[test] + fn the_reports_member_appears_only_when_there_are_reports() { + // An empty reports CSV is not a valid one. + assert!(!names(&layout(&bundle(vec![])).importable) + .iter() + .any(|name| name.starts_with("export_reports"))); + + let with_reports = bundle(vec![( + "Reports", + vec![vec![text("report_type")], vec![text("tally")]], + )]); + assert!(names(&layout(&with_reports).importable) + .iter() + .any(|name| name.starts_with("export_reports"))); + } + + #[test] + fn the_scheduled_events_member_is_written_even_when_empty() { + // The voting window lives in it, so whether the file exists must not + // depend on whether the source had a sheet. + let layout = layout(&bundle(vec![])); + let schedule = layout + .importable + .iter() + .find(|artifact| { + artifact.name.starts_with("export_scheduled_events") + }) + .expect("the schedule member"); + let text = String::from_utf8(schedule.bytes.clone()).unwrap(); + assert_eq!(text, "id,tenant_id,election_event_id,created_at,stopped_at,archived_at,labels,annotations,event_processor,cron_config,event_payload,task_id\n"); + } + + #[test] + fn administrators_are_written_beside_the_zip_and_never_inside_it() { + // Importing an election event must not be able to create administrator + // accounts. + let bundle = bundle(vec![( + "Admin Users", + vec![ + vec![text("username"), text("permission_labels")], + vec![text("admin1"), text("statewide-officers")], + ], + )]); + let layout = layout(&bundle); + assert!(names(&layout.auxiliary).contains(&"admin_users.csv")); + assert!(!names(&layout.importable).contains(&"admin_users.csv")); + } + + #[test] + fn the_permissions_file_is_named_after_the_tenant() { + // Nothing rewrites this file's name on the way in, which is the one place + // the tenant id actually matters. + let bundle = bundle(vec![( + "Permissions", + vec![ + vec![text("permission"), text("admin")], + vec![text("election:read"), text("x")], + ], + )]); + let layout = layout(&bundle); + assert!(names(&layout.auxiliary).contains( + &format!("export_permissions-{}.csv", bundle.tenant_id).as_str() + )); + } + + #[test] + fn each_template_becomes_a_file_and_a_manifest_entry() { + let bundle = bundle(vec![( + "Templates", + vec![ + vec![ + text("name"), + text("alias"), + text("type"), + text("template.document"), + ], + vec![ + text("Voter Credentials"), + text("voter_credentials"), + text("VOTER_CREDENTIALS"), + text("Hello {{name}}"), + ], + ], + )]); + let layout = layout(&bundle); + + let document = layout + .auxiliary + .iter() + .find(|a| a.name == "templates/voter_credentials.hbs") + .expect("the template file"); + assert_eq!(document.bytes, b"Hello {{name}}"); + + let manifest = layout + .auxiliary + .iter() + .find(|a| a.name == "templates/templates.json") + .expect("the manifest"); + let parsed: Value = + serde_json::from_slice(&manifest.bytes).expect("valid JSON"); + assert_eq!(parsed[0]["alias"], json!("voter_credentials")); + assert_eq!(parsed[0]["file"], json!("voter_credentials.hbs")); + assert_eq!(parsed[0]["type"], json!("VOTER_CREDENTIALS")); + } + + #[test] + fn the_realm_patch_says_it_was_not_applied_when_there_was_no_realm() { + // Whoever reads it has to know whether to apply it by hand. + let bundle = bundle(vec![]); + let layout = layout(&bundle); + let patch = layout + .auxiliary + .iter() + .find(|a| a.name == "keycloak_event_realm_patch.json") + .expect("the realm patch"); + let parsed: Value = serde_json::from_slice(&patch.bytes).unwrap(); + assert!(parsed["_comment"].as_str().unwrap().contains("NOT applied")); + assert_eq!( + parsed["patch"]["displayName"], + json!("Union Election 2027") + ); + } + + #[test] + fn the_realm_patch_states_the_directives_that_are_not_merges() { + // A reader applying it by hand cannot deduce either from the patch itself. + let bundle = bundle(vec![( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("voter_link_plus_dob"), + ], + ], + )]); + let layout = layout(&bundle); + let patch = layout + .auxiliary + .iter() + .find(|a| a.name == "keycloak_event_realm_patch.json") + .expect("the realm patch"); + let parsed: Value = serde_json::from_slice(&patch.bytes).unwrap(); + + assert_eq!(parsed["auth_preset"], json!("voter_link_plus_dob")); + assert_eq!( + parsed["bind_authenticator_config"]["authenticator"], + json!("message-otp-authenticator") + ); + assert!(parsed["user_profile"]["attributes"]["dateOfBirth"].is_object()); + // And nothing internal leaked into the mergeable part. + for key in parsed["patch"].as_object().unwrap().keys() { + assert!(!key.starts_with('_'), "{key} leaked into the patch"); + } + } + + #[test] + fn a_bundle_with_nothing_extra_writes_no_auxiliary_files_but_the_realm_patch( + ) { + let layout = layout(&bundle(vec![])); + assert_eq!( + names(&layout.auxiliary), + ["keycloak_event_realm_patch.json"] + ); + } + + #[test] + fn every_json_artifact_parses_and_ends_in_a_newline() { + // A file without one is a nuisance in every diff it appears in. + let bundle = bundle(vec![( + "Templates", + vec![ + vec![text("alias"), text("template.document")], + vec![text("otp"), text("hello")], + ], + )]); + let layout = layout(&bundle); + for artifact in layout.importable.iter().chain(layout.auxiliary.iter()) + { + if artifact.name.ends_with(".json") { + serde_json::from_slice::(&artifact.bytes) + .unwrap_or_else(|error| { + panic!("{}: {error}", artifact.name) + }); + assert_eq!( + artifact.bytes.last(), + Some(&b'\n'), + "{}", + artifact.name + ); + } + } + } + + #[cfg(feature = "election_config_archive")] + #[test] + fn the_archive_holds_exactly_the_importable_members_at_its_root() { + use std::io::Cursor; + + let bundle = bundle(vec![( + "Admin Users", + vec![vec![text("username")], vec![text("admin1")]], + )]); + let layout = layout(&bundle); + let bytes = zip(&layout.importable).expect("a zip"); + + let mut archive = + ::zip::ZipArchive::new(Cursor::new(bytes)).expect("readable"); + let mut inside: Vec = (0..archive.len()) + .map(|index| archive.by_index(index).unwrap().name().to_string()) + .collect(); + inside.sort(); + let mut expected: Vec = + layout.importable.iter().map(|a| a.name.clone()).collect(); + expected.sort(); + assert_eq!(inside, expected); + + // No directories, and the administrators are not in there. + assert!(inside.iter().all(|name| !name.contains('/'))); + } + + #[cfg(feature = "election_config_archive")] + #[test] + fn zipping_the_same_bundle_twice_gives_the_same_bytes() { + // The point of the fixed timestamp and mode: without them "regenerating + // produced no diff" is not something anyone can check. + let layout = layout(&bundle(vec![])); + assert_eq!( + zip(&layout.importable).unwrap(), + zip(&layout.importable).unwrap() + ); + } + + #[cfg(feature = "election_config_archive")] + #[test] + fn a_member_survives_the_round_trip_byte_for_byte() { + use std::io::Read; + + let layout = layout(&bundle(vec![])); + let bytes = zip(&layout.importable).unwrap(); + let mut archive = + ::zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + + for expected in &layout.importable { + let mut member = archive.by_name(&expected.name).unwrap(); + let mut got = Vec::new(); + member.read_to_end(&mut got).unwrap(); + assert_eq!(got, expected.bytes, "{}", expected.name); + } + } +} diff --git a/packages/sequent-core/src/election_config/branding.rs b/packages/sequent-core/src/election_config/branding.rs new file mode 100644 index 00000000000..a796d64d942 --- /dev/null +++ b/packages/sequent-core/src/election_config/branding.rs @@ -0,0 +1,442 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! What an election event already states about itself, expressed as realm +//! settings. +//! +//! The languages an event enables, its name, and its login CSS all belong on the +//! login page, and the platform copies none of them there: it syncs the default +//! locale only under a force-default detection policy, never syncs +//! `supportedLocales`, and has no path at all from an event name to a realm +//! display name. Stating them twice in a source document would be a way to get +//! them out of step, so they are derived from what the event already says. +//! +//! The language codes go through [`crate::util::locale::iso_639_2t_to_bcp47`] — +//! the platform's own table, rather than a second copy of it. The Python this was +//! ported from transcribed all 177 entries by hand, which is exactly the kind of +//! duplication this work exists to remove. + +use crate::util::locale::iso_639_2t_to_bcp47; +use serde_json::{json, Map, Value}; + +/// The realm localization key the login theme renders. +/// +/// See `sequent-theme/.../sequent.admin-portal/login/template.ftl`. +pub const LOGIN_CUSTOM_CSS_KEY: &str = "loginCustomCss"; + +/// Escape CSS for Keycloak's `${msg(...)}`, which is `java.text.MessageFormat`. +/// +/// MessageFormat treats `{` and `}` as format-element delimiters and `'` as a +/// quoting character. CSS is made of braces, and its `url('…')` is made of +/// quotes, so pasting CSS in raw produces either a parse error or silently +/// mangled output — a login page with no styling and nothing in the logs. +/// +/// The escaping MessageFormat defines is: `''` for a literal single quote, and a +/// brace is literal when quoted, so `{` becomes `'{'`. +/// +/// Quotes are escaped **first**, so the quotes added around braces are not +/// themselves doubled: +/// +/// ```text +/// a { b: url('x'); } -> a '{' b: url(''x''); '}' +/// ``` +/// +/// which MessageFormat renders back as the original. The platform's own realm +/// writes braces exactly this way. +pub fn escape_message_format(text: &str) -> String { + text.replace('\'', "''") + .replace('{', "'{'") + .replace('}', "'}'") +} + +/// Strip one enclosing pair of single quotes, if present. +/// +/// Quoting an entire string is the other way to make MessageFormat treat it +/// literally, so realm values are often passed around already wrapped that way. +/// Accepting both means a value copied out of a working realm and a value typed +/// as plain CSS both do the right thing. +pub fn unwrap_quoted(text: &str) -> &str { + let stripped = text.trim(); + if stripped.len() >= 2 + && stripped.starts_with('\'') + && stripped.ends_with('\'') + { + &stripped[1..stripped.len() - 1] + } else { + text + } +} + +/// Realm i18n settings from the event's `presentation.language_conf`. +/// +/// `supportedLocales` is what puts a language in Keycloak's login-page picker, +/// and nothing else sets it — so an event offering a language the realm does not +/// list is a ballot whose login page the voter cannot read. +pub fn language_patch(language_conf: Option<&Value>) -> Map { + let mut patch = Map::new(); + let Some(Value::Object(language_conf)) = language_conf else { + return patch; + }; + + if let Some(codes) = enabled_locales(language_conf) { + // Keycloak needs this on for supportedLocales to have any effect. + patch.insert("internationalizationEnabled".to_string(), json!(true)); + patch.insert("supportedLocales".to_string(), json!(codes)); + } + + if let Some(default) = language_conf + .get("default_language_code") + .and_then(Value::as_str) + .filter(|code| !code.is_empty()) + { + patch.insert( + "defaultLocale".to_string(), + json!(iso_639_2t_to_bcp47(default)), + ); + } + patch +} + +/// The BCP 47 locales a realm localization string should be written for. +pub fn locales_of( + language_conf: Option<&Value>, + fallback: &str, +) -> Vec { + if let Some(Value::Object(language_conf)) = language_conf { + if let Some(codes) = enabled_locales(language_conf) { + return codes; + } + if let Some(default) = language_conf + .get("default_language_code") + .and_then(Value::as_str) + .filter(|code| !code.is_empty()) + { + return vec![iso_639_2t_to_bcp47(default).to_string()]; + } + } + vec![fallback.to_string()] +} + +/// The enabled codes as sorted, deduplicated BCP 47. +fn enabled_locales(language_conf: &Map) -> Option> { + let Some(Value::Array(enabled)) = + language_conf.get("enabled_language_codes") + else { + return None; + }; + if enabled.is_empty() { + return None; + } + + let mut codes: Vec = enabled + .iter() + .filter_map(Value::as_str) + .filter(|code| !code.is_empty()) + .map(|code| iso_639_2t_to_bcp47(code).to_string()) + .collect(); + if codes.is_empty() { + return None; + } + codes.sort(); + codes.dedup(); + Some(codes) +} + +/// The realm `displayName`, taken from the event's own name. +/// +/// Keycloak shows it above the login form, so leaving it at the platform default +/// means every client's voters see "Election Event". +/// +/// `displayNameHtml` is deliberately left alone: Keycloak renders it as raw markup +/// and prefers it over `displayName` when set, so writing an event name into it +/// would turn any `&` in a client's name into a rendering bug. +pub fn title_patch( + i18n: Option<&Value>, + default_language: Option<&str>, +) -> Map { + let mut patch = Map::new(); + let Some(Value::Object(i18n)) = i18n else { + return patch; + }; + if i18n.is_empty() { + return patch; + } + + // The event's default language first, then the raw code it was written as, + // then English. + let converted = default_language.map(iso_639_2t_to_bcp47); + let preferred: Vec<&str> = converted + .into_iter() + .chain(default_language) + .chain(std::iter::once("en")) + .collect(); + + for key in preferred { + if let Some(name) = named(i18n.get(key)) { + patch.insert("displayName".to_string(), json!(name)); + return patch; + } + } + + // No default language matched, so take whichever entry has a name. Sorted so + // the choice does not depend on map iteration order. + let mut keys: Vec<&String> = i18n.keys().collect(); + keys.sort(); + for key in keys { + if let Some(name) = named(i18n.get(key)) { + patch.insert("displayName".to_string(), json!(name)); + return patch; + } + } + patch +} + +fn named(entry: Option<&Value>) -> Option<&str> { + entry? + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) +} + +/// `localizationTexts..loginCustomCss`, escaped and per language. +/// +/// Written for every enabled locale: Keycloak looks the message up in the voter's +/// language, so CSS present only under `en` disappears the moment a voter switches +/// to Spanish. +pub fn login_css_patch(css: &str, locales: &[String]) -> Map { + let escaped = escape_message_format(unwrap_quoted(css)); + let mut texts = Map::new(); + for locale in locales { + texts.insert( + locale.clone(), + json!({LOGIN_CUSTOM_CSS_KEY: escaped.clone()}), + ); + } + + let mut patch = Map::new(); + patch.insert("localizationTexts".to_string(), Value::Object(texts)); + patch +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn css_braces_and_quotes_are_escaped_the_way_message_format_reads_them() { + // Raw CSS in a ${msg(...)} is either a parse error or silently mangled + // output: a login page with no styling and nothing in the logs. + assert_eq!( + escape_message_format("a { b: url('x'); }"), + "a '{' b: url(''x''); '}'" + ); + } + + #[test] + fn quotes_are_escaped_before_braces_are_wrapped_in_them() { + // The other order doubles the quotes this function just added, and the + // result renders as a literal '{' instead of a brace. + assert_eq!(escape_message_format("{"), "'{'"); + assert_eq!(escape_message_format("'"), "''"); + assert_eq!(escape_message_format("'{'"), "'''{'''"); + } + + #[test] + fn text_with_nothing_message_format_cares_about_is_left_alone() { + // Most of a stylesheet is not braces, and mangling it would be as bad as + // not escaping the parts that are. + assert_eq!(escape_message_format("color: red;"), "color: red;"); + assert_eq!( + escape_message_format("background: #fff url(logo.png)"), + "background: #fff url(logo.png)" + ); + } + + #[test] + fn a_value_already_quoted_for_message_format_is_unwrapped_first() { + // A value copied out of a working realm arrives wrapped; one typed as + // plain CSS does not. Both have to work. + assert_eq!(unwrap_quoted("'a { b: c }'"), "a { b: c }"); + assert_eq!(unwrap_quoted("a { b: c }"), "a { b: c }"); + assert_eq!(unwrap_quoted("'"), "'"); + assert_eq!(unwrap_quoted(""), ""); + } + + #[test] + fn the_enabled_languages_become_the_login_pages_picker() { + // Nothing else in the platform sets supportedLocales. + let patch = language_patch(Some(&json!({ + "enabled_language_codes": ["eng", "spa", "cat"], + "default_language_code": "spa", + }))); + assert_eq!(patch["internationalizationEnabled"], json!(true)); + assert_eq!(patch["supportedLocales"], json!(["ca", "en", "es"])); + assert_eq!(patch["defaultLocale"], json!("es")); + } + + #[test] + fn codes_are_converted_by_the_platforms_own_table() { + // Not a second transcription of it: the Python copied all 177 entries by + // hand, which is exactly the duplication this work removes. + let patch = language_patch(Some(&json!({ + "enabled_language_codes": ["cat", "glg", "spa"], + }))); + assert_eq!(patch["supportedLocales"], json!(["ca", "es", "gl"])); + } + + #[test] + fn a_code_the_table_does_not_know_passes_through_unchanged() { + // Which is the safe answer: an unconverted code is a locale Keycloak may + // not recognise, whereas a guessed one is a locale it recognises wrongly. + // + // Basque ("eus") and Dutch ("nld") are among the codes the platform's + // table lacks. Both the Rust and the Python it replaces behave this way, + // so this is a gap in `util::locale`, not a regression here. + let patch = language_patch(Some(&json!({ + "enabled_language_codes": ["en", "eus", "nld"], + }))); + assert_eq!(patch["supportedLocales"], json!(["en", "eus", "nld"])); + } + + #[test] + fn duplicate_codes_collapse() { + // "en" and "eng" are the same locale, and a realm listing it twice is a + // picker showing it twice. + let patch = language_patch(Some(&json!({ + "enabled_language_codes": ["en", "eng"], + }))); + assert_eq!(patch["supportedLocales"], json!(["en"])); + } + + #[test] + fn no_language_configuration_means_no_language_patch() { + // Leaving the realm alone is the right answer, not writing an empty list + // that would empty its picker. + assert!(language_patch(None).is_empty()); + assert!(language_patch(Some(&json!({}))).is_empty()); + assert!(language_patch(Some(&json!("not an object"))).is_empty()); + assert!(language_patch(Some(&json!({"enabled_language_codes": []}))) + .is_empty()); + } + + #[test] + fn a_default_language_alone_still_sets_the_default_locale() { + let patch = + language_patch(Some(&json!({"default_language_code": "cat"}))); + assert_eq!(patch["defaultLocale"], json!("ca")); + assert!(patch.get("supportedLocales").is_none()); + } + + #[test] + fn the_locales_css_is_written_for_follow_the_enabled_languages() { + assert_eq!( + locales_of( + Some(&json!({"enabled_language_codes": ["eng", "spa"]})), + "en" + ), + ["en", "es"] + ); + assert_eq!( + locales_of(Some(&json!({"default_language_code": "spa"})), "en"), + ["es"] + ); + assert_eq!(locales_of(None, "en"), ["en"]); + } + + #[test] + fn the_event_name_becomes_the_realms_display_name() { + // Otherwise every client's voters see "Election Event" above the form. + let patch = title_patch( + Some(&json!({ + "en": {"name": "Union Election 2027"}, + "es": {"name": "Elección Sindical 2027"}, + })), + Some("spa"), + ); + assert_eq!(patch["displayName"], json!("Elección Sindical 2027")); + } + + #[test] + fn the_title_falls_back_to_english_then_to_whatever_has_a_name() { + assert_eq!( + title_patch(Some(&json!({"en": {"name": "English"}})), Some("spa")) + ["displayName"], + json!("English") + ); + assert_eq!( + title_patch( + Some(&json!({"fr": {"name": "Français"}})), + Some("spa") + )["displayName"], + json!("Français") + ); + } + + #[test] + fn a_default_language_written_as_bcp47_matches_too() { + // An author who writes "es" rather than "spa" means the same thing. + assert_eq!( + title_patch( + Some( + &json!({"es": {"name": "Español"}, "en": {"name": "English"}}) + ), + Some("es") + )["displayName"], + json!("Español") + ); + } + + #[test] + fn a_nameless_i18n_block_leaves_the_display_name_alone() { + assert!(title_patch(Some(&json!({"en": {}})), Some("eng")).is_empty()); + assert!( + title_patch(Some(&json!({"en": {"name": ""}})), None).is_empty() + ); + assert!(title_patch(None, None).is_empty()); + assert!(title_patch(Some(&json!({})), None).is_empty()); + } + + #[test] + fn display_name_html_is_never_written() { + // Keycloak renders it as raw markup and prefers it when set, so an "&" in + // a client's name would become a rendering bug. + let patch = title_patch( + Some(&json!({"en": {"name": "Smith & Sons Local"}})), + None, + ); + assert_eq!(patch["displayName"], json!("Smith & Sons Local")); + assert!(patch.get("displayNameHtml").is_none()); + } + + #[test] + fn login_css_is_written_for_every_enabled_language() { + // Keycloak looks the message up in the voter's language, so CSS under `en` + // alone vanishes when a voter switches to Spanish. + let patch = login_css_patch( + ".logo { display: none; }", + &["en".to_string(), "es".to_string()], + ); + let texts = &patch["localizationTexts"]; + assert_eq!( + texts["en"][LOGIN_CUSTOM_CSS_KEY], + json!(".logo '{' display: none; '}'") + ); + assert_eq!( + texts["es"][LOGIN_CUSTOM_CSS_KEY], + texts["en"][LOGIN_CUSTOM_CSS_KEY] + ); + } + + #[test] + fn login_css_copied_out_of_a_realm_is_not_escaped_twice() { + // It arrives already wrapped in quotes; escaping that as content would + // double every quote in it. + let patch = + login_css_patch("'.logo { display: none; }'", &["en".to_string()]); + assert_eq!( + patch["localizationTexts"]["en"][LOGIN_CUSTOM_CSS_KEY], + json!(".logo '{' display: none; '}'") + ); + } +} diff --git a/packages/sequent-core/src/election_config/build.rs b/packages/sequent-core/src/election_config/build.rs new file mode 100644 index 00000000000..a6fb10dc39c --- /dev/null +++ b/packages/sequent-core/src/election_config/build.rs @@ -0,0 +1,1285 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Turning a source document's rows into an import bundle. +//! +//! Each entity is a rendered template with the row's dotted-path columns +//! deep-merged over it, identified by a [`super::ids::IdFactory`] uuid5, and +//! joined to the others by `external_id`. Nothing here reaches for a clock or a +//! filesystem, so the same build runs in `step-cli` and in a browser and produces +//! the same bytes. +//! +//! Problems accumulate rather than stopping at the first one: an author fixing a +//! spreadsheet wants the whole list, not one round trip per mistake. Every +//! problem carries the sheet and row it came from, because a bundle path is no +//! use to whoever has to edit the file. +//! +//! Split across three files so each stays readable, all one unit of code: the CSV +//! members and the files that travel beside a bundle are in `build_tables.rs`, and +//! everything to do with the Keycloak realm is in `build_realm.rs`. Both are child +//! modules, so both can reach the resolved ids without any of it being public. + +use crate::election_config::ids::IdFactory; +use crate::election_config::paths::{deep_merge, set_path, split_path}; +use crate::election_config::presets::{self, AuthPreset, RealmPatch}; +use crate::election_config::problem::{Code, Problem, Report}; +use crate::election_config::render::TemplateSet; +use crate::election_config::sheet::{ + normalise_sheet_name, Origin, Row, Workbook, SHEET_AREAS, + SHEET_AREA_CONTESTS, SHEET_CANDIDATES, SHEET_CONTESTS, SHEET_ELECTIONS, + SHEET_ELECTION_EVENT, SHEET_PARAMETERS, SHEET_REPORTS, + SHEET_SCHEDULED_EVENTS, +}; +use serde_json::{json, Map, Value}; + +#[path = "build_realm.rs"] +mod realm; + +#[path = "build_tables.rs"] +mod tables; + +pub use realm::PARAM_LOGIN_CUSTOM_CSS; +pub use tables::{ + CommunicationTemplate, JsonTable, PlainTable, VOTER_LEADING_COLUMNS, +}; + +/// Timestamp on every generated entity. +/// +/// Fixed rather than "now" so that regenerating an unchanged source produces +/// byte-identical output. A real timestamp would make every regeneration a diff +/// with no information in it, and the importer overwrites these anyway. +pub const DEFAULT_CREATED_AT: &str = "2026-01-01T00:00:00.000000Z"; + +/// Bundle format version written when neither a base export nor an option says +/// otherwise. +pub const DEFAULT_VERSION: &str = "v10.0.0"; + +/// Columns the builder consumes itself. +/// +/// These are not dotted paths into the entity and must not be merged into it — +/// `election.external_id` is how a contest names its election, not a field called +/// `external_id` on an object called `election`. +pub fn control_columns(sheet_key: &str) -> &'static [&'static str] { + match sheet_key { + SHEET_CONTESTS => &["election.external_id"], + SHEET_CANDIDATES => &["contest.external_id"], + SHEET_AREAS => &["parent.external_id"], + SHEET_AREA_CONTESTS => &["area.external_id", "contest.external_id"], + SHEET_SCHEDULED_EVENTS => &[ + "event_name", + "event_type", + "election.external_id", + "scheduled_datetime", + ], + SHEET_REPORTS => &[ + "election.external_id", + "template.alias", + "report_type", + "cron_config", + "encryption_policy", + "password", + "permission_label", + ], + _ => &[], + } +} + +/// Parameters whose key opens with one of these is a dotted patch into that +/// target rather than free-form metadata. +pub const PARAMETER_PREFIXES: &[&str] = &[ + "keycloak_event_realm.", + "keycloak_admin_realm.", + "election_event.", +]; + +/// Parameters the builder acts on directly rather than carrying as metadata. +pub const HANDLED_PARAMETERS: &[&str] = + &["tenant_id", realm::PARAM_LOGIN_CUSTOM_CSS]; + +/// Fields of a base entity that describe *that* event rather than the platform's +/// defaults, and so must not leak into a new one. +/// +/// `bulletin_board_reference` and `public_key` point at the base event's own +/// board and keys; `statistics` and `status` describe a run that already +/// happened. Carrying any of them over produces an event that looks configured +/// and is not. +const SCRUB_EVENT: &[&str] = &[ + "id", + "tenant_id", + "external_id", + "bulletin_board_reference", + "public_key", + "statistics", + "status", + "created_at", + "updated_at", +]; + +const SCRUB_ELECTION: &[&str] = &[ + "id", + "tenant_id", + "election_event_id", + "external_id", + "keys_ceremony_id", + "statistics", + "status", + "created_at", + "last_updated_at", +]; + +const SCRUB_CONTEST: &[&str] = &[ + "id", + "tenant_id", + "election_event_id", + "election_id", + "external_id", + "created_at", + "last_updated_at", +]; + +const SCRUB_CANDIDATE: &[&str] = &[ + "id", + "tenant_id", + "election_event_id", + "contest_id", + "external_id", + "created_at", + "last_updated_at", +]; + +const SCRUB_AREA: &[&str] = &[ + "id", + "tenant_id", + "election_event_id", + "parent_id", + "name", + "description", + "created_at", + "last_updated_at", +]; + +/// What a caller may vary about a build. +#[derive(Debug, Clone, Default)] +pub struct BuildOptions { + /// The tenant id to write into the file. + /// + /// Import does not read this as a destination: `replace_ids` maps the file's + /// value onto the tenant of the importing request unconditionally. It still + /// matters for `export_permissions-.csv`, which is a tenant-config + /// artifact whose name nothing rewrites. + pub tenant_id: Option, + + /// An existing export to inherit platform defaults from. + /// + /// Merged *under* the templates, so it contributes fields the templates do + /// not know about — a newer platform version's additions, for instance — + /// without overriding anything the author wrote. + pub base_export: Option, + + /// Name for the generated archive. Derived from the event's `external_id` + /// when absent. + pub slug: Option, + + /// Timestamp for every entity. [`DEFAULT_CREATED_AT`] when absent. + pub created_at: Option, + + /// Which authentication preset to apply, overriding the document's + /// `auth_type`. + /// + /// [`presets::NONE`] leaves the realm alone whatever the document declares — + /// worth having while a client has not yet supplied what a preset needs. + pub auth_preset: Option, +} + +/// A built bundle: the JSON document and what was worth saying about it. +#[derive(Debug, Clone)] +pub struct Bundle { + pub slug: String, + pub tenant_id: String, + pub event_id: String, + pub event_external_id: String, + + /// The `export_election_event-.json` document. + pub export: Value, + + /// `export_voters-.csv`. + pub voters: PlainTable, + + /// `export_scheduled_events-.csv`, the JSON-in-CSV member. + /// + /// Where the voting window actually lives: `scheduled_events` in the JSON + /// document is not read by the importer. + pub scheduled_events: JsonTable, + + /// `export_reports-.csv`, or nothing when the source names no reports. + pub reports: Option, + + /// Admin users, tenant-scoped rather than part of the event import. + /// + /// A secret: it carries clear-text passwords when the source does. + pub admin_users: Option, + + /// The role/permission matrix, transposed into the platform's own shape. + pub role_permissions: Option, + + /// Communication and report templates, loaded through the Admin Portal + /// rather than imported — the event zip has no member for them. + pub templates: Vec, + + /// Everything the document asked of the event's Keycloak realm. + /// + /// Kept whether or not it could be applied here, so that an `auth_type` or a + /// `keycloak_event_realm.*` parameter is never lost just because no base + /// export was given. + pub realm_patch: RealmPatch, + + /// `keycloak_admin_realm.*` parameters. Tenant-scoped, so not part of the + /// event import. + pub admin_realm_patch: Map, + + /// The preset that was applied, if any. + pub auth_preset: Option<&'static str>, + + /// Warnings. Not errors: the bundle imports, but something about it looks + /// unintended. + pub warnings: Report, +} + +/// Build a bundle from a source document. +/// +/// Returns every problem at once on failure, so one run tells an author +/// everything they need to fix. +pub fn build( + workbook: &Workbook, + templates: &TemplateSet, + options: &BuildOptions, +) -> Result { + Builder::new(workbook, templates, options)?.build() +} + +struct Builder<'a> { + workbook: &'a Workbook, + templates: &'a TemplateSet, + base_export: Value, + created_at: String, + + report: Report, + + /// `(type, key) -> value` from the Parameters sheet, in sheet order. + parameters: Vec<(String, String, Value)>, + + auth_preset: Option<&'static AuthPreset>, + realm_patch: RealmPatch, + + event_row: Row, + event_external_id: String, + event_id: String, + tenant_id: String, + slug: String, + ids: IdFactory, + + /// `external_id` -> generated UUID, per entity kind. + election_ids: Vec<(String, String)>, + contest_ids: Vec<(String, String)>, + area_ids: Vec<(String, String)>, + area_names: Vec<(String, String)>, +} + +impl<'a> Builder<'a> { + fn new( + workbook: &'a Workbook, + templates: &'a TemplateSet, + options: &BuildOptions, + ) -> Result { + let mut report = Report::default(); + let event_row = event_row(workbook).map_err(|problem| { + let mut report = Report::default(); + report.push(problem); + report + })?; + + let event_external_id = match event_row.text("external_id") { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => { + report.push(Problem::error( + Code::MissingField, + event_row.origin(Some("external_id")).to_string(), + "the election event needs an external_id: every generated \ + identifier is derived from it", + )); + return Err(report); + } + }; + + // Unwrap is sound: the id was just checked to be non-empty. + let ids = IdFactory::new(&event_external_id) + .expect("a non-empty external_id always yields a factory"); + let event_id = ids.uid("election_event", &[&event_external_id]); + + let base_export = options.base_export.clone().unwrap_or(Value::Null); + let mut builder = Builder { + workbook, + templates, + base_export, + created_at: options + .created_at + .clone() + .unwrap_or_else(|| DEFAULT_CREATED_AT.to_string()), + report, + parameters: Vec::new(), + auth_preset: None, + realm_patch: RealmPatch::default(), + event_row, + event_external_id: event_external_id.clone(), + event_id, + tenant_id: String::new(), + slug: options + .slug + .clone() + .unwrap_or_else(|| slugify(&event_external_id)), + ids, + election_ids: Vec::new(), + contest_ids: Vec::new(), + area_ids: Vec::new(), + area_names: Vec::new(), + }; + + builder.parameters = builder.read_parameters(); + builder.tenant_id = + builder.resolve_tenant_id(options.tenant_id.as_deref()); + builder.auth_preset = + builder.resolve_auth_preset(options.auth_preset.as_deref()); + Ok(builder) + } + + fn build(mut self) -> Result { + // Built before the realm, because the realm applies it, and before the + // entities so that a preset's requirements are reported first. + self.realm_patch = self.build_realm_patch(); + self.warn_permission_labels(); + + let elections = self.build_elections(); + let contests = self.build_contests(); + let candidates = self.build_candidates(); + let areas = self.build_areas(); + let area_contests = self.build_area_contests(); + let event = self.build_election_event(); + // After the event, because a stale base event id is swapped out of the + // realm's URLs and that id comes from the base export. + let realm = self.build_realm(); + let admin_realm_patch = self.admin_realm_patch(); + + let version = self + .base_export + .get("version") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_VERSION) + .to_string(); + + let export = json!({ + "tenant_id": self.tenant_id, + "keycloak_event_realm": realm, + "election_event": event, + "elections": elections, + "contests": contests, + "candidates": candidates, + "areas": areas, + "area_contests": area_contests, + // The voting window travels in export_scheduled_events.csv, which is + // the member import_election_event.rs actually reads. + "scheduled_events": Value::Null, + // Reports likewise come from export_reports.csv: insert_reports is + // only ever reached from process_reports_file, so a populated array + // here would be silently dropped. + "reports": [], + "keys_ceremonies": [], + "applications": [], + "version": version, + }); + + // Built after the entities, because every one of these resolves an + // external_id against them. + let voters = self.build_voters(); + let scheduled_events = self.build_scheduled_events(); + let reports = self.build_reports(); + let admin_users = self.build_admin_users(); + let role_permissions = self.build_role_permissions(); + let templates = self.build_templates(); + + if self.report.has_errors() { + return Err(self.report); + } + + // Only warnings are left, and they travel with the bundle. + Ok(Bundle { + slug: self.slug, + tenant_id: self.tenant_id, + event_id: self.event_id, + event_external_id: self.event_external_id, + export, + voters, + scheduled_events, + reports, + admin_users, + role_permissions, + templates, + realm_patch: self.realm_patch, + admin_realm_patch, + auth_preset: self.auth_preset.map(|preset| preset.name), + warnings: self.report, + }) + } + + // -- problems --------------------------------------------------------- + + /// Record a problem and keep going, so one run reports every issue. + pub(super) fn problem( + &mut self, + origin: Origin, + code: Code, + message: impl Into, + ) { + self.report + .push(Problem::error(code, origin.to_string(), message)); + } + + pub(super) fn warn( + &mut self, + path: impl Into, + message: impl Into, + ) { + self.report + .push(Problem::warning(Code::InvalidValue, path, message)); + } + + // -- parameters ------------------------------------------------------- + + fn read_parameters(&mut self) -> Vec<(String, String, Value)> { + let mut parameters = Vec::new(); + let mut warnings = Vec::new(); + + for row in self.workbook.rows(SHEET_PARAMETERS) { + let Some(key) = row.get("key") else { + // A row with a comment but no key is a note to the author. + continue; + }; + let key = value_as_text(key).trim().to_string(); + if key.is_empty() { + continue; + } + + match row.get("value") { + Some(value) => { + let kind = row + .get("type") + .map(|kind| value_as_text(kind).trim().to_string()) + .unwrap_or_default(); + parameters.push((kind, key, value.clone())); + } + None => { + // A key with no value is a placeholder the author left + // blank, e.g. an IdP metadata URL pending the client. + warnings.push(( + row.origin(Some("value")).to_string(), + format!( + "parameter '{key}' has no value and is ignored" + ), + )); + } + } + } + + for (path, message) in warnings { + self.warn(path, message); + } + parameters + } + + /// Look a parameter up by key, whatever its `type` column says. + /// + /// The type column is documentation for the author; nothing downstream + /// distinguishes an `event` parameter from a `settings` one. + fn parameter(&self, key: &str) -> Option<&Value> { + self.parameters + .iter() + .find(|(_, name, _)| name == key) + .map(|(_, _, value)| value) + } + + /// Parameters whose key is a dotted path under `prefix`. + fn parameter_patches(&self, prefix: &str) -> Vec<(String, Value)> { + self.parameters + .iter() + .filter_map(|(_, key, value)| { + key.strip_prefix(prefix) + .map(|path| (path.to_string(), value.clone())) + }) + .collect() + } + + /// Parameters nothing acts on, to be carried in the event's annotations. + /// + /// Recorded rather than dropped: a row someone put in the spreadsheet meant + /// something to them, and silently ignoring it is how a setting goes missing + /// on election day. + fn uninterpreted_parameters(&self) -> Vec<(String, Value)> { + // A key a preset takes is not uninterpreted, whether or not a preset was + // selected: reporting it as ignored while a preset would have acted on it + // contradicts itself. + let consumed = presets::all_preset_parameters(); + + let mut carried: Vec<(String, Value)> = self + .parameters + .iter() + .filter(|(_, key, _)| { + !HANDLED_PARAMETERS.contains(&key.as_str()) + && !consumed.contains(&key.as_str()) + && !PARAMETER_PREFIXES + .iter() + .any(|prefix| key.starts_with(prefix)) + }) + .map(|(kind, key, value)| { + let kind = if kind.is_empty() { "event" } else { kind }; + // The prefix is what the SEIU1000 bundle already carries; keeping + // it means a regenerated event does not move its annotations. + ( + format!("janitor.param.{kind}.{key}"), + match value { + Value::String(_) => value.clone(), + other => Value::String(other.to_string()), + }, + ) + }) + .collect(); + carried.sort_by(|left, right| left.0.cmp(&right.0)); + carried + } + + fn resolve_tenant_id(&self, explicit: Option<&str>) -> String { + if let Some(explicit) = explicit.filter(|id| !id.is_empty()) { + return explicit.to_string(); + } + if let Some(from_parameters) = self.parameter("tenant_id") { + let text = value_as_text(from_parameters); + if !text.is_empty() { + return text; + } + } + if let Some(from_base) = + self.base_export.get("tenant_id").and_then(Value::as_str) + { + if !from_base.is_empty() { + return from_base.to_string(); + } + } + self.ids.tenant_id() + } + + // -- entities --------------------------------------------------------- + + /// Render a template, then merge the row's dotted paths over it. + pub(super) fn render( + &mut self, + template: &str, + row: Option<&Row>, + context: Value, + ) -> Map { + let rendered = match self.templates.render_json(template, &context) { + Ok(rendered) => rendered, + Err(problem) => { + // A template that does not render is a bug in the template, not + // in the document, so it is reported as-is. + self.report.push(problem); + return Map::new(); + } + }; + + let Some(row) = row else { + return rendered; + }; + + let excluded = control_columns(&normalise_sheet_name(&row.sheet)); + match row.overrides(excluded) { + Ok(overrides) => { + match deep_merge( + Value::Object(rendered), + Value::Object(overrides), + ) { + Value::Object(merged) => merged, + // deep_merge of two objects is an object. + _ => unreachable!("merging two objects yields an object"), + } + } + Err(problem) => { + self.report.push(problem); + rendered + } + } + } + + /// The first entity of `key` in the base export, scrubbed, if there is one. + fn base(&self, key: &str, scrub: &[&str]) -> Option> { + let value = self.base_export.get(key)?; + let object = match value { + Value::Object(object) => object.clone(), + Value::Array(items) => match items.first() { + Some(Value::Object(object)) => object.clone(), + _ => return None, + }, + _ => return None, + }; + if object.is_empty() { + return None; + } + + let mut scrubbed = object; + for field in scrub { + scrubbed.remove(*field); + } + Some(scrubbed) + } + + /// Merge a scrubbed base entity under `entity`, then reassert identity. + /// + /// Identity always comes from this build, never from the base: a base export + /// naming its own ids is the whole reason the fields are scrubbed, and + /// reasserting them makes that impossible to get wrong by adding a field. + fn under_base( + &self, + entity: Map, + key: &str, + scrub: &[&str], + identity: &[(&str, &str)], + ) -> Map { + let Some(base) = self.base(key, scrub) else { + return entity; + }; + let merged = deep_merge(Value::Object(base), Value::Object(entity)); + let mut merged = match merged { + Value::Object(object) => object, + _ => unreachable!("merging two objects yields an object"), + }; + for (field, value) in identity { + merged.insert((*field).to_string(), json!(value)); + } + merged + } + + fn build_election_event(&mut self) -> Value { + let context = json!({ + "id": self.event_id, + "tenant_id": self.tenant_id, + "created_at": self.created_at, + }); + let row = self.event_row.clone(); + let event = self.render("election_event", Some(&row), context); + + let event_id = self.event_id.clone(); + let tenant_id = self.tenant_id.clone(); + let base = self.base("election_event", SCRUB_EVENT); + let mut event = self.under_base( + event, + "election_event", + SCRUB_EVENT, + &[("id", &event_id), ("tenant_id", &tenant_id)], + ); + if let Some(base) = base { + // Say out loud what the base contributed to the voter's screen. + let merged = event.clone(); + self.warn_inherited_branding(&base, &merged); + } + + event.insert("external_id".to_string(), json!(self.event_external_id)); + + for (path, value) in self.parameter_patches("election_event.") { + if let Err(problem) = + set_path(&mut event, &split_path(&path), value) + { + self.report.push(problem); + } + } + + let carried = self.uninterpreted_parameters(); + if !carried.is_empty() { + let mut annotations = match event.get("annotations") { + Some(Value::Object(existing)) => existing.clone(), + _ => Map::new(), + }; + let mut names: Vec = Vec::new(); + for (name, value) in carried { + names + .push(name.rsplit('.').next().unwrap_or(&name).to_string()); + annotations.insert(name, value); + } + event.insert("annotations".to_string(), Value::Object(annotations)); + + names.sort(); + names.dedup(); + self.warn( + "election_event.annotations", + format!( + "these Parameters rows are recorded in \ + election_event.annotations but not interpreted: {}", + names.join(", ") + ), + ); + } + + Value::Object(event) + } + + fn build_elections(&mut self) -> Value { + let rows: Vec = self.workbook.rows(SHEET_ELECTIONS).to_vec(); + let mut elections = Vec::new(); + let mut seen: Vec<(String, usize)> = Vec::new(); + + for row in &rows { + let Some(external_id) = + self.require_external_id(row, "an election", &mut seen) + else { + continue; + }; + + let election_id = self.ids.uid("election", &[&external_id]); + self.election_ids + .push((external_id.clone(), election_id.clone())); + + let context = json!({ + "id": election_id, + "tenant_id": self.tenant_id, + "election_event_id": self.event_id, + "created_at": self.created_at, + }); + let election = self.render("election", Some(row), context); + + let event_id = self.event_id.clone(); + let tenant_id = self.tenant_id.clone(); + let mut election = self.under_base( + election, + "elections", + SCRUB_ELECTION, + &[ + ("id", &election_id), + ("tenant_id", &tenant_id), + ("election_event_id", &event_id), + ], + ); + election.insert("external_id".to_string(), json!(external_id)); + elections.push(Value::Object(election)); + } + + if elections.is_empty() { + self.problem( + sheet_origin("Elections"), + Code::MissingField, + "an election event needs at least one election", + ); + } + Value::Array(elections) + } + + fn build_contests(&mut self) -> Value { + let rows: Vec = self.workbook.rows(SHEET_CONTESTS).to_vec(); + let mut contests = Vec::new(); + let mut seen: Vec<(String, usize)> = Vec::new(); + + for row in &rows { + let Some(external_id) = + self.require_external_id(row, "a contest", &mut seen) + else { + continue; + }; + + let elections = self.election_ids.clone(); + let Some(election_id) = self.resolve( + row, + "election.external_id", + &elections, + "election", + ) else { + continue; + }; + + let contest_id = self.ids.uid("contest", &[&external_id]); + self.contest_ids + .push((external_id.clone(), contest_id.clone())); + + let context = json!({ + "id": contest_id, + "tenant_id": self.tenant_id, + "election_event_id": self.event_id, + "election_id": election_id, + "created_at": self.created_at, + }); + let contest = self.render("contest", Some(row), context); + + self.say_what_the_template_stood_in_for( + row, + &external_id, + &contest, + ); + + let event_id = self.event_id.clone(); + let tenant_id = self.tenant_id.clone(); + let mut contest = self.under_base( + contest, + "contests", + SCRUB_CONTEST, + &[ + ("id", &contest_id), + ("tenant_id", &tenant_id), + ("election_event_id", &event_id), + ("election_id", &election_id), + ], + ); + contest.insert("external_id".to_string(), json!(external_id)); + contests.push(Value::Object(contest)); + } + + if contests.is_empty() { + self.problem( + sheet_origin("Contests"), + Code::MissingField, + "an election event needs at least one contest", + ); + } + Value::Array(contests) + } + + /// Warn for each ballot-shaping column a contest row left to `contest.hbs`. + /// + /// The template carries a value for all five, so `validate`'s `MissingField` + /// rule for them can never fire on a built bundle: an omitted `max_votes` + /// becomes "choose one" and nothing says so. The template's values stay — + /// `contests_sheet` writes the two the wizard does not ask about, and janitor's + /// workbooks, which #2983 matches byte for byte, leave the rest out — so which + /// columns become mandatory is a product decision. Being told what was + /// substituted is not, and that is this. + /// + /// Read out of `rendered` rather than listed here, so the message names the + /// value the bundle actually carries and a change to the template cannot make + /// this lie. + fn say_what_the_template_stood_in_for( + &mut self, + row: &Row, + external_id: &str, + rendered: &Map, + ) { + const BALLOT_SHAPE: [&str; 5] = [ + "min_votes", + "max_votes", + "winning_candidates_num", + "voting_type", + "counting_algorithm", + ]; + + for column in BALLOT_SHAPE { + let given = row + .get(column) + .map(|value| value_as_text(value).trim().to_string()) + .unwrap_or_default(); + if !given.is_empty() { + continue; + } + let substituted = + rendered.get(column).map(value_as_text).unwrap_or_default(); + self.warn( + row.origin(Some(column)).to_string(), + format!( + "contest '{external_id}' has no {column}, so the base template's \ + '{substituted}' stands in. Say it in the workbook if that is not \ + what this contest does." + ), + ); + } + } + + fn build_candidates(&mut self) -> Value { + let rows: Vec = self.workbook.rows(SHEET_CANDIDATES).to_vec(); + let mut candidates = Vec::new(); + let mut seen: Vec<(String, usize)> = Vec::new(); + + for row in &rows { + let Some(external_id) = + self.require_external_id(row, "a candidate", &mut seen) + else { + continue; + }; + + let contests = self.contest_ids.clone(); + let Some(contest_id) = + self.resolve(row, "contest.external_id", &contests, "contest") + else { + continue; + }; + + let candidate_id = self.ids.uid("candidate", &[&external_id]); + let context = json!({ + "id": candidate_id, + "tenant_id": self.tenant_id, + "election_event_id": self.event_id, + "contest_id": contest_id, + "created_at": self.created_at, + }); + let candidate = self.render("candidate", Some(row), context); + + let event_id = self.event_id.clone(); + let tenant_id = self.tenant_id.clone(); + let mut candidate = self.under_base( + candidate, + "candidates", + SCRUB_CANDIDATE, + &[ + ("id", &candidate_id), + ("tenant_id", &tenant_id), + ("election_event_id", &event_id), + ("contest_id", &contest_id), + ], + ); + candidate.insert("external_id".to_string(), json!(external_id)); + candidates.push(Value::Object(candidate)); + } + + Value::Array(candidates) + } + + fn build_areas(&mut self) -> Value { + let rows: Vec = self.workbook.rows(SHEET_AREAS).to_vec(); + + // Two passes over the sheet: ids first, so a parent may appear below its + // own child. Authors do not sort their spreadsheets topologically. + let mut seen: Vec<(String, usize)> = Vec::new(); + for row in &rows { + let Some(external_id) = + self.require_external_id(row, "an area", &mut seen) + else { + continue; + }; + let area_id = self.ids.uid("area", &[&external_id]); + self.area_ids.push((external_id.clone(), area_id)); + + match row.get("name").map(value_as_text) { + Some(name) if !name.is_empty() => { + self.area_names.push((external_id, name)); + } + _ => self.problem( + row.origin(Some("name")), + Code::MissingField, + "an area needs a name: the voters CSV identifies a \ + voter's area by name, not by id", + ), + } + } + + // Names have to be unique for the same reason. + let names = self.area_names.clone(); + for (index, (external_id, name)) in names.iter().enumerate() { + if let Some((earlier, _)) = names[..index] + .iter() + .find(|(_, seen_name)| seen_name == name) + { + self.problem( + Origin { + sheet: "Areas".to_string(), + row: 0, + column: Some("name".to_string()), + }, + Code::DuplicateId, + format!( + "two areas are both named '{name}' ('{earlier}' and \ + '{external_id}'); the voters CSV resolves an area by \ + name, so names must be unique" + ), + ); + } + } + + let mut areas = Vec::new(); + for row in &rows { + // Read the way `require_external_id` registered it: `row.text` answers + // only for a string cell and does not trim, so a numeric or space-padded + // id resolved in the first pass and not in this one. + let Some(external_id) = row + .get("external_id") + .map(|value| value_as_text(value).trim().to_string()) + else { + continue; + }; + let Some(area_id) = lookup(&self.area_ids, &external_id) else { + continue; + }; + + let mut parent_id = None; + if row.get("parent.external_id").is_some() { + let areas_so_far = self.area_ids.clone(); + let Some(resolved) = self.resolve( + row, + "parent.external_id", + &areas_so_far, + "area", + ) else { + continue; + }; + if resolved == area_id { + self.problem( + row.origin(Some("parent.external_id")), + Code::AreaCycle, + "an area cannot be its own parent", + ); + continue; + } + parent_id = Some(resolved); + } + + let context = json!({ + "id": area_id, + "tenant_id": self.tenant_id, + "election_event_id": self.event_id, + "created_at": self.created_at, + }); + let area = self.render("area", Some(row), context); + + let event_id = self.event_id.clone(); + let tenant_id = self.tenant_id.clone(); + let mut area = self.under_base( + area, + "areas", + SCRUB_AREA, + &[ + ("id", &area_id), + ("tenant_id", &tenant_id), + ("election_event_id", &event_id), + ], + ); + if let Some(parent_id) = parent_id { + area.insert("parent_id".to_string(), json!(parent_id)); + } + areas.push(Value::Object(area)); + } + + if areas.is_empty() { + self.problem( + sheet_origin("Areas"), + Code::MissingField, + "an election event needs at least one area; every voter \ + belongs to one", + ); + } + Value::Array(areas) + } + + fn build_area_contests(&mut self) -> Value { + let rows: Vec = self.workbook.rows(SHEET_AREA_CONTESTS).to_vec(); + let mut links = Vec::new(); + let mut seen: Vec<((String, String), usize)> = Vec::new(); + + for row in &rows { + let areas = self.area_ids.clone(); + let contests = self.contest_ids.clone(); + // Through `value_as_text`, like `resolve` below: `row.text` answers only + // for a string cell, so a numeric id read as nothing here and two + // different numeric pairs both keyed the duplicate check as ("", ""). + let area_external = row + .get("area.external_id") + .map(|value| value_as_text(value).trim().to_string()) + .unwrap_or_default(); + let contest_external = row + .get("contest.external_id") + .map(|value| value_as_text(value).trim().to_string()) + .unwrap_or_default(); + + let area_id = self.resolve(row, "area.external_id", &areas, "area"); + let contest_id = + self.resolve(row, "contest.external_id", &contests, "contest"); + let (Some(area_id), Some(contest_id)) = (area_id, contest_id) + else { + continue; + }; + + let key = (area_external, contest_external); + if let Some((_, earlier)) = + seen.iter().find(|(seen_key, _)| seen_key == &key) + { + let message = format!( + "area '{}' is already linked to contest '{}' on row {earlier}", + key.0, key.1 + ); + self.problem(row.origin(None), Code::DuplicateId, message); + continue; + } + seen.push((key.clone(), row.number)); + + let context = json!({ + "id": self.ids.uid("area_contest", &[&key.0, &key.1]), + "area_id": area_id, + "contest_id": contest_id, + }); + let link = self.render("area_contest", Some(row), context); + links.push(Value::Object(link)); + } + + if links.is_empty() { + self.problem( + sheet_origin("AreaContests"), + Code::BallotCoverage, + "no area is linked to any contest, so no voter would see a \ + ballot", + ); + } + Value::Array(links) + } + + // -- shared row handling ---------------------------------------------- + + /// The row's `external_id`, or a problem naming what is wrong with it. + fn require_external_id( + &mut self, + row: &Row, + what: &str, + seen: &mut Vec<(String, usize)>, + ) -> Option { + let external_id = match row.get("external_id").map(value_as_text) { + Some(id) if !id.trim().is_empty() => id.trim().to_string(), + _ => { + self.problem( + row.origin(Some("external_id")), + Code::MissingField, + format!("{what} needs an external_id"), + ); + return None; + } + }; + + if let Some((_, earlier)) = + seen.iter().find(|(id, _)| id == &external_id) + { + let message = format!( + "external_id '{external_id}' is already used by row {earlier}" + ); + self.problem( + row.origin(Some("external_id")), + Code::DuplicateId, + message, + ); + return None; + } + seen.push((external_id.clone(), row.number)); + Some(external_id) + } + + /// `external_id` -> UUID, recording a problem when it does not resolve. + pub(super) fn resolve( + &mut self, + row: &Row, + column: &str, + table: &[(String, String)], + kind: &str, + ) -> Option { + let Some(raw) = row.get(column).map(value_as_text) else { + self.problem( + row.origin(Some(column)), + Code::MissingField, + // Phrased without an article on purpose: "a election" is what + // the obvious formatting gives, and this message is read by + // whoever has to fix the file. + format!( + "'{column}' is required: it names the {kind} this row belongs to" + ), + ); + return None; + }; + let key = raw.trim(); + match lookup(table, key) { + Some(resolved) => Some(resolved), + None => { + self.problem( + row.origin(Some(column)), + Code::DanglingReference, + format!("no {kind} has external_id '{key}'"), + ); + None + } + } + } +} + +/// The one row of the ElectionEvent sheet. +fn event_row(workbook: &Workbook) -> Result { + let rows = workbook.rows(SHEET_ELECTION_EVENT); + match rows.len() { + 0 => Err(Problem::error( + Code::MissingField, + "sheet 'ElectionEvent'", + "this sheet is empty; it must hold exactly one row, with at least \ + an 'external_id' column", + )), + 1 => Ok(rows[0].clone()), + count => Err(Problem::error( + Code::InvalidValue, + "sheet 'ElectionEvent'", + format!( + "this sheet holds {count} rows; an import describes exactly \ + one election event" + ), + )), + } +} + +fn sheet_origin(sheet: &str) -> Origin { + Origin { + sheet: sheet.to_string(), + row: 0, + column: None, + } +} + +fn lookup(table: &[(String, String)], key: &str) -> Option { + table + .iter() + .find(|(external_id, _)| external_id == key) + .map(|(_, id)| id.clone()) +} + +/// A JSON value as the text a reference column means. +/// +/// A number typed into an id column is an id, not a number: `1001` as an +/// `external_id` has to match `1001` written in a reference column, whichever way +/// each cell happened to be formatted. +fn value_as_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// A filesystem-safe name derived from the event's `external_id`. +fn slugify(value: &str) -> String { + let mut slug = String::with_capacity(value.len()); + for character in value.trim().chars() { + if character.is_ascii_alphanumeric() { + slug.extend(character.to_lowercase()); + } else if !slug.ends_with('-') { + slug.push('-'); + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "election-event".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +#[path = "build_tests.rs"] +mod build_tests; + +/// Whether a report holds an error whose message contains `needle`. +#[cfg(test)] +pub(crate) fn has_error_saying(report: &Report, needle: &str) -> bool { + report + .errors() + .any(|problem| problem.message.contains(needle)) +} diff --git a/packages/sequent-core/src/election_config/build_realm.rs b/packages/sequent-core/src/election_config/build_realm.rs new file mode 100644 index 00000000000..9e5abf2bc9d --- /dev/null +++ b/packages/sequent-core/src/election_config/build_realm.rs @@ -0,0 +1,938 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The Keycloak realm: what a source document may ask of it, and how. +//! +//! A realm is ~165 kB of interdependent Keycloak configuration — authentication +//! flows, clients, a user profile carried as a stringified JSON blob — none of +//! which can be authored from a spreadsheet, and whose client URLs belong to the +//! environment it came from. So a realm someone exported from a working event is +//! patched, or none is emitted and the platform loads its own provisioned default. +//! +//! Emitting an invented realm would be worse than emitting none: the importer +//! takes `keycloak_event_realm` **wholesale**, so a present realm *replaces* the +//! environment's default rather than merging into it. When there is no base export +//! the patch is kept as its own artifact instead, so nothing the document asked +//! for is lost. + +use super::{value_as_text, Builder}; +use crate::election_config::branding; +use crate::election_config::paths::{deep_merge, set_path, split_path}; +use crate::election_config::presets::{ + self, AuthPreset, PresetInput, RealmPatch, RequirementKind, PARAM_AUTH_TYPE, +}; +use crate::election_config::problem::Code; +use crate::election_config::sheet::{ + Origin, SHEET_ADMIN_USERS, SHEET_ELECTIONS, SHEET_REPORTS, +}; +use serde_json::{Map, Value}; + +/// The parameter carrying the login page's stylesheet. +pub const PARAM_LOGIN_CUSTOM_CSS: &str = "login_custom_css"; + +impl Builder<'_> { + /// The preset named by the caller, else by the document's `auth_type`. + /// + /// `None` leaves the realm entirely alone — no preset, no patch, and + /// `keycloak_event_realm` stays whatever a base export gave, or null. + pub(super) fn resolve_auth_preset( + &mut self, + explicit: Option<&str>, + ) -> Option<&'static AuthPreset> { + if explicit + .map(|name| name.trim().eq_ignore_ascii_case(presets::NONE)) + .unwrap_or(false) + { + // Explicitly ignore whatever the document declares. + return None; + } + + let declared = self.parameter(PARAM_AUTH_TYPE).map(value_as_text); + let name = match explicit { + Some(explicit) if !explicit.trim().is_empty() => { + explicit.trim().to_string() + } + _ => match declared { + Some(declared) if !declared.trim().is_empty() => declared, + _ => return None, + }, + }; + + let Some(preset) = presets::get(&name) else { + // Whichever said it wrong is where the author has to look. + let origin = if explicit.is_some() { + Origin { + sheet: "auth preset option".to_string(), + row: 0, + column: None, + } + } else { + Origin { + sheet: "Parameters".to_string(), + row: 0, + column: Some("value".to_string()), + } + }; + let message = format!( + "'{name}' is not an authentication preset; expected one of {}", + presets::names().join(", ") + ); + self.problem(origin, Code::InvalidValue, message); + return None; + }; + + let missing: Vec<&str> = preset + .required_parameters + .iter() + .filter(|key| { + self.parameter(key) + .map(value_as_text) + .filter(|value| !value.trim().is_empty()) + .is_none() + }) + .copied() + .collect(); + + if !missing.is_empty() { + let wanted: Vec = missing + .iter() + .map(|key| format!("a '{key}' parameter")) + .collect(); + let message = format!( + "the '{}' preset needs {}. Add it to the Parameters sheet, or \ + select the 'none' preset to build without configuring \ + authentication.", + preset.name, + wanted.join(", ") + ); + self.problem( + Origin { + sheet: "Parameters".to_string(), + row: 0, + column: None, + }, + Code::MissingField, + message, + ); + return None; + } + Some(preset) + } + + /// Everything the document asks of the realm, as one patch. + /// + /// Kept as its own artifact whether or not a base export was given, so a + /// `keycloak_event_realm.*` parameter or an `auth_type` is never silently + /// dropped just because there was no realm to apply it to. + pub(super) fn build_realm_patch(&mut self) -> RealmPatch { + let mut result = RealmPatch::default(); + + if let Some(preset) = self.auth_preset { + let values: Vec<(String, Value)> = preset + .consumes() + .iter() + .filter_map(|key| { + self.parameter(key) + .map(|value| ((*key).to_string(), value.clone())) + }) + .collect(); + result = preset.build(&PresetInput::new(values)); + } + + result.patch = + merge_maps(result.patch, self.event_derived_realm_patch()); + + // Explicit parameters last, so they can override anything derived. + let mut problems = Vec::new(); + for (path, value) in self.parameter_patches("keycloak_event_realm.") { + if let Err(problem) = + set_path(&mut result.patch, &split_path(&path), value) + { + problems.push(problem); + } + } + for problem in problems { + self.report.push(problem); + } + + result + } + + /// Realm settings the event already states, which nothing else carries over. + /// + /// The platform syncs the default locale only under a force-default detection + /// policy, never syncs `supportedLocales`, and has no path at all from an event + /// name to a realm display name. Stating them twice in a document would be a + /// way to get them out of step, so they are derived from what the event says. + fn event_derived_realm_patch(&mut self) -> Map { + let presentation = self + .event_row + .overrides(&[]) + .ok() + .and_then(|overrides| overrides.get("presentation").cloned()) + .unwrap_or(Value::Null); + + let language_conf = presentation.get("language_conf"); + let default_language = language_conf + .and_then(|conf| conf.get("default_language_code")) + .and_then(Value::as_str); + + let mut patch = branding::language_patch(language_conf); + for (key, value) in + branding::title_patch(presentation.get("i18n"), default_language) + { + patch.insert(key, value); + } + + if let Some(css) = self + .parameter(PARAM_LOGIN_CUSTOM_CSS) + .map(value_as_text) + .filter(|css| !css.trim().is_empty()) + { + let locales = branding::locales_of(language_conf, "en"); + patch = + merge_maps(patch, branding::login_css_patch(&css, &locales)); + } + patch + } + + /// `keycloak_admin_realm.*` parameters, as a nested object. + /// + /// Its own artifact: the admin realm is tenant-scoped, so it is not part of an + /// election event import. + pub(super) fn admin_realm_patch(&mut self) -> Map { + let patches = self.parameter_patches("keycloak_admin_realm."); + if patches.is_empty() { + return Map::new(); + } + match crate::election_config::paths::expand(&patches) { + Ok(expanded) => expanded, + Err(problem) => { + self.report.push(problem); + Map::new() + } + } + } + + /// Patch the base export's realm, or emit none. + pub(super) fn build_realm(&mut self) -> Value { + let Some(Value::Object(realm)) = + self.base_export.get("keycloak_event_realm").cloned() + else { + if !self.realm_patch.patch.is_empty() { + let mut keys: Vec<&str> = + self.realm_patch.patch.keys().map(String::as_str).collect(); + keys.sort_unstable(); + let message = format!( + "no base export, so nothing here configures the login page: \ + {} are kept as a realm patch instead of being applied. Until \ + that patch reaches the realm, voters see the platform's \ + default login page.", + keys.join(", ") + ); + self.warn("keycloak_event_realm", message); + } + return Value::Null; + }; + + let mut realm = realm; + + // The realm name is structural: the voting portal and the smart-link URLs + // derive it from tenant + event, so it is not a free choice. + realm.insert( + "realm".to_string(), + Value::String(format!( + "tenant-{}-event-{}", + self.tenant_id, self.event_id + )), + ); + realm.insert("id".to_string(), Value::String(self.event_id.clone())); + + let realm = self.apply_realm_patch(realm); + + // Hosts in rootUrl/redirectUris belong to the environment and stay, but a + // base export also embeds its OWN event id in those URLs. Import remaps + // every UUID it finds, so a stale id would be remapped to something + // unrelated; swapping it first makes the remap land right. + let base_event_id = self + .base_export + .get("election_event") + .and_then(|event| event.get("id")) + .and_then(Value::as_str) + .map(str::to_string); + + match base_event_id { + Some(base_event_id) if base_event_id != self.event_id => { + let encoded = Value::Object(realm.clone()).to_string(); + let swapped = encoded.replace(&base_event_id, &self.event_id); + // The un-swapped realm, not `Value::String(swapped)`: a string here + // means `keycloak_event_realm` holds text where the importer expects + // an object, and it takes it wholesale. Keeping the base event's ids + // is the lesser fault, and it is said out loud. + match serde_json::from_str(&swapped) { + Ok(reparsed) => reparsed, + Err(error) => { + self.warn( + "keycloak_event_realm", + format!( + "the base export's realm could not be re-read after \ + swapping the event id ({error}), so it is carried \ + over unchanged" + ), + ); + Value::Object(realm) + } + } + } + _ => Value::Object(realm), + } + } + + /// Apply the patch to a real realm, checking what the preset assumes. + /// + /// Alias-keyed collections are merged by alias rather than replaced: a realm's + /// `identityProviders` and `authenticatorConfig` are referenced by alias from + /// elsewhere, so replacing either wholesale would strip providers the + /// environment configured on purpose. + fn apply_realm_patch( + &mut self, + realm: Map, + ) -> Map { + let mut realm = realm; + let mut patch = self.realm_patch.patch.clone(); + + if self.auth_preset.is_some() { + self.check_realm_requirements(&realm); + } + + for key in ["identityProviders", "authenticatorConfig"] { + if let Some(Value::Array(additions)) = patch.remove(key) { + if additions.is_empty() { + continue; + } + let existing = match realm.get(key) { + Some(Value::Array(existing)) => existing.clone(), + _ => Vec::new(), + }; + realm.insert( + key.to_string(), + Value::Array(merge_by_alias(existing, additions)), + ); + } + } + + let mut realm = merge_maps(realm, patch); + + if let Some((authenticator, config_alias)) = + self.realm_patch.bind_authenticator_config.clone() + { + bind_authenticator_config( + &mut realm, + &authenticator, + &config_alias, + ); + } + if let Some(profile) = self.realm_patch.user_profile.clone() { + self.patch_user_profile(&mut realm, &profile); + } + realm + } + + /// Report anything the preset needs that the target realm lacks. + fn check_realm_requirements(&mut self, realm: &Map) { + let Some(preset) = self.auth_preset else { + return; + }; + + let flows = aliases_of(realm.get("authenticationFlows"), "alias"); + let configs = aliases_of(realm.get("authenticatorConfig"), "alias"); + + let authenticators: Vec = realm + .get("authenticationFlows") + .and_then(Value::as_array) + .map(|flows| { + flows + .iter() + .filter_map(|flow| { + flow.get("authenticationExecutions")?.as_array() + }) + .flatten() + .filter_map(|execution| { + execution.get("authenticator")?.as_str() + }) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + + let mut warnings = Vec::new(); + for requirement in preset.requires { + // Exhaustive: the `_ =>` arm this replaces meant a misspelled kind was + // checked against the authenticators and reported nothing. + let present = match requirement.kind { + RequirementKind::Flow => &flows, + RequirementKind::Authenticator => &authenticators, + RequirementKind::AuthenticatorConfig => &configs, + }; + if !present.iter().any(|name| name == requirement.name) { + warnings.push(format!( + "the base export's realm has no {} '{}', which the '{}' \ + preset needs: {}. The patch is still written out on its own.", + requirement.kind, + requirement.name, + preset.name, + requirement.why + )); + } + } + for warning in warnings { + self.warn("keycloak_event_realm", warning); + } + } + + /// Patch the user profile, which travels as a stringified JSON blob. + /// + /// It lives inside a Keycloak component's config as a single JSON string, so it + /// has to be parsed, patched and re-serialised rather than merged. + fn patch_user_profile( + &mut self, + realm: &mut Map, + profile_patch: &Map, + ) { + let preset_name = + self.auth_preset.map_or("selected", |preset| preset.name); + + let component = realm + .get_mut("components") + .and_then(|components| { + components + .get_mut("org.keycloak.userprofile.UserProfileProvider") + }) + .and_then(Value::as_array_mut) + .and_then(|components| components.first_mut()); + + let Some(component) = component else { + let message = format!( + "the base export's realm has no user profile component, so the \ + '{preset_name}' preset could not set which login fields are \ + typeable" + ); + self.warn("keycloak_event_realm", message); + return; + }; + + // The config value is a one-element list of JSON text. + let raw = component + .get("config") + .and_then(|config| config.get("kc.user.profile.config")) + .map(|value| match value { + Value::Array(items) => items + .first() + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Value::String(text) => text.clone(), + _ => String::new(), + }) + .unwrap_or_default(); + + if raw.is_empty() { + self.warn( + "keycloak_event_realm", + "the base export's realm user profile component is empty", + ); + return; + } + + let mut profile: Value = match serde_json::from_str(&raw) { + Ok(profile) => profile, + Err(error) => { + let message = format!( + "the realm's user profile is not readable JSON: {error}" + ); + self.problem( + Origin { + sheet: "base export".to_string(), + row: 0, + column: None, + }, + Code::InvalidValue, + message, + ); + return; + } + }; + + let mut missing = Vec::new(); + { + let attributes = + profile.get_mut("attributes").and_then(Value::as_array_mut); + let Some(attributes) = attributes else { + self.warn( + "keycloak_event_realm", + "the base export's realm user profile lists no attributes", + ); + return; + }; + + for (name, changes) in profile_patch { + let attribute = attributes.iter_mut().find(|attribute| { + attribute.get("name").and_then(Value::as_str) == Some(name) + }); + match attribute { + Some(attribute) => { + if let (Some(target), Some(changes)) = + (attribute.as_object_mut(), changes.as_object()) + { + for (key, value) in changes { + target.insert(key.clone(), value.clone()); + } + } + } + None => missing.push(name.clone()), + } + } + } + + for name in missing { + let message = format!( + "the realm's user profile has no '{name}' attribute, so the \ + '{preset_name}' preset could not configure it" + ); + self.warn("keycloak_event_realm", message); + } + + let encoded = profile.to_string(); + // Reported rather than asserted: the array element can be any JSON value, and + // a base export holding a string there would otherwise abort the whole build. + // Same shape as the missing-attributes warning above. + let Some(component) = component.as_object_mut() else { + self.warn( + "keycloak_event_realm", + "the base export's user profile component is not an object, so the \ + census columns were left undeclared", + ); + return; + }; + let config = component + .entry("config") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(config) = config.as_object_mut() { + config.insert( + "kc.user.profile.config".to_string(), + Value::Array(vec![Value::String(encoded)]), + ); + } + } + + /// Check every permission label against the administrators who hold one. + /// + /// A permission label scopes an entity to administrators carrying that label. + /// Hasura filters `election` and `report` on `permission_label IS NULL OR + /// permission_label IN X-Hasura-Permission-Labels`, where the claim comes from + /// the `permission_labels` attribute on the Keycloak administrator. + /// + /// The failure this guards against is quiet and expensive: an election whose + /// label nobody holds imports cleanly, reports no error, and then does not + /// appear in the Elections list at all. It happened on the first real import, + /// where a document labelled an election `dlc-officers-dburs` while its own + /// administrators carried `dlc-officers`. Nothing anywhere said so. + /// + /// Warnings rather than errors, because the document is not the whole picture: + /// administrators may already exist in the target tenant carrying labels this + /// file knows nothing about. + pub(super) fn warn_permission_labels(&mut self) { + // Insertion-ordered so the message does not depend on a hash. + let mut used: Vec<(String, Vec)> = Vec::new(); + let note = |used: &mut Vec<(String, Vec)>, + label: String, + entity: String| { + match used.iter_mut().find(|(seen, _)| seen == &label) { + Some((_, entities)) => entities.push(entity), + None => used.push((label, vec![entity])), + } + }; + + for row in self.workbook.rows(SHEET_ELECTIONS) { + if let Some(label) = row.get("permission_label").map(value_as_text) + { + let label = label.trim().to_string(); + if label.is_empty() { + continue; + } + let entity = format!( + "election '{}'", + row.text("external_id") + .map(str::to_string) + .unwrap_or_else(|| row.number.to_string()) + ); + note(&mut used, label, entity); + } + } + + for row in self.workbook.rows(SHEET_REPORTS) { + for label in labels_of(row.get("permission_label")) { + note(&mut used, label, format!("report on row {}", row.number)); + } + } + + if used.is_empty() { + return; + } + + let mut granted: Vec = Vec::new(); + for row in self.workbook.rows(SHEET_ADMIN_USERS) { + for label in labels_of(row.get("permission_labels")) { + if !granted.contains(&label) { + granted.push(label); + } + } + } + + let mut unmatched: Vec<&(String, Vec)> = used + .iter() + .filter(|(label, _)| !granted.contains(label)) + .collect(); + unmatched.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut warnings = Vec::new(); + for (label, entities) in unmatched { + let listed: Vec<&str> = + entities.iter().take(4).map(String::as_str).collect(); + let more = if entities.len() > 4 { + format!(" and {} more", entities.len() - 4) + } else { + String::new() + }; + let nobody = if granted.is_empty() { + ", and this document grants no permission labels to anyone" + } else { + ", but no administrator in the Admin Users sheet carries it" + }; + warnings.push(format!( + "permission label '{label}' is used by {}{more}{nobody}. \ + Anything carrying a label is hidden from every administrator \ + without it — the event imports cleanly and then lists nothing.", + listed.join(", ") + )); + } + + let mut all: Vec<&str> = + used.iter().map(|(label, _)| label.as_str()).collect(); + all.sort_unstable(); + warnings.push(format!( + "permission labels in use: {}. Whoever imports this event needs one \ + of them on their own 'permission_labels' attribute, or the Admin \ + Portal will show them an empty list.", + all.join(", ") + )); + + for warning in warnings { + self.warn("permission_label", warning); + } + } + + /// Say out loud what a base export contributed to the voter's screen. + /// + /// Entity fields the template does not set are inherited from the base, and + /// `presentation.i18n` merges key by key. That is useful when the base is a + /// reference event and wrong when it is another client's: their login title and + /// instruction copy would come along silently. A base export should be a + /// generic reference event, and if it is not, this is where you find out. + pub(super) fn warn_inherited_branding( + &mut self, + base: &Map, + event: &Map, + ) { + let base_presentation = base.get("presentation"); + let event_presentation = event.get("presentation"); + + let mut inherited: Vec<&String> = Vec::new(); + if let Some(Value::Object(base_presentation)) = base_presentation { + for (key, value) in base_presentation { + if ["i18n", "css", "logo_url"].contains(&key.as_str()) { + continue; + } + if value.is_null() + || value == &Value::String(String::new()) + || value == &Value::Object(Map::new()) + || value == &Value::Array(Vec::new()) + { + continue; + } + if event_presentation.and_then(|event| event.get(key)) + == Some(value) + { + inherited.push(key); + } + } + } + inherited.sort(); + + let base_copy: Vec<&String> = base_presentation + .and_then(|presentation| presentation.get("i18n")) + .and_then(|i18n| i18n.get("en")) + .and_then(Value::as_object) + .map(|english| english.keys().collect()) + .unwrap_or_default(); + + let own_copy: Vec = self + .event_row + .overrides(&[]) + .ok() + .and_then(|overrides| { + overrides + .get("presentation")? + .get("i18n")? + .get("en")? + .as_object() + .map(|english| english.keys().cloned().collect()) + }) + .unwrap_or_default(); + + let mut inherited_copy: Vec<&String> = base_copy + .into_iter() + .filter(|key| !own_copy.contains(key)) + .collect(); + inherited_copy.sort(); + + let mut warnings = Vec::new(); + if !inherited_copy.is_empty() { + let listed: Vec = inherited_copy + .iter() + .take(6) + .map(|key| format!("presentation.i18n.en.{key}")) + .collect(); + let more = if inherited_copy.len() > 6 { + format!(" and {} more", inherited_copy.len() - 6) + } else { + String::new() + }; + warnings.push(format!( + "the base export's voter-facing copy is inherited: {}{more}. Use \ + a reference event as the base, not another client's, or set these \ + in the ElectionEvent sheet.", + listed.join(", ") + )); + } + if !inherited.is_empty() { + let listed: Vec<&str> = + inherited.iter().take(8).map(|key| key.as_str()).collect(); + let more = if inherited.len() > 8 { + format!(" and {} more", inherited.len() - 8) + } else { + String::new() + }; + warnings.push(format!( + "presentation settings inherited from the base export: {}{more}", + listed.join(", ") + )); + } + + for warning in warnings { + self.warn("election_event.presentation", warning); + } + } +} + +/// Merge alias-keyed realm collections, replacing by alias and appending the rest. +fn merge_by_alias(existing: Vec, additions: Vec) -> Vec { + let mut merged = existing; + for addition in additions { + let alias = addition + .get("alias") + .and_then(Value::as_str) + .map(str::to_string); + let at = alias.as_ref().and_then(|alias| { + merged.iter().position(|item| { + item.get("alias").and_then(Value::as_str) == Some(alias) + }) + }); + match at { + Some(index) => { + let existing = merged[index].clone(); + merged[index] = deep_merge(existing, addition); + } + None => merged.push(addition), + } + } + merged +} + +/// Point every execution of an authenticator at a config alias. +fn bind_authenticator_config( + realm: &mut Map, + authenticator: &str, + config_alias: &str, +) { + let Some(flows) = realm + .get_mut("authenticationFlows") + .and_then(Value::as_array_mut) + else { + return; + }; + + for flow in flows { + let Some(executions) = flow + .get_mut("authenticationExecutions") + .and_then(Value::as_array_mut) + else { + continue; + }; + for execution in executions { + if execution.get("authenticator").and_then(Value::as_str) + == Some(authenticator) + { + if let Some(execution) = execution.as_object_mut() { + execution.insert( + "authenticatorConfig".to_string(), + Value::String(config_alias.to_string()), + ); + } + } + } + } +} + +/// The values of `key` across a list of objects. +fn aliases_of(value: Option<&Value>, key: &str) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get(key)?.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// A cell that may hold one label or a list of them. +fn labels_of(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(items)) => items + .iter() + .map(value_as_text) + .map(|label| label.trim().to_string()) + .filter(|label| !label.is_empty()) + .collect(), + Some(Value::Null) | None => Vec::new(), + Some(other) => { + let label = value_as_text(other).trim().to_string(); + if label.is_empty() { + Vec::new() + } else { + vec![label] + } + } + } +} + +/// Deep-merge one object over another. +fn merge_maps( + base: Map, + over: Map, +) -> Map { + match deep_merge(Value::Object(base), Value::Object(over)) { + Value::Object(merged) => merged, + _ => unreachable!("merging two objects yields an object"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_provider_with_the_same_alias_is_merged_not_appended() { + // identityProviders is referenced by alias from elsewhere in the realm, so + // two entries under one alias is a realm nothing can resolve. + let merged = merge_by_alias( + vec![json!({"alias": "a", "enabled": false, "keep": 1})], + vec![json!({"alias": "a", "enabled": true})], + ); + assert_eq!(merged.len(), 1); + assert_eq!( + merged[0], + json!({"alias": "a", "enabled": true, "keep": 1}) + ); + } + + #[test] + fn a_provider_the_realm_does_not_have_is_appended() { + // Replacing the list wholesale would strip providers the environment + // configured on purpose. + let merged = merge_by_alias( + vec![json!({"alias": "environment-idp"})], + vec![json!({"alias": "client-saml-idp"})], + ); + assert_eq!(merged.len(), 2); + assert_eq!(merged[0]["alias"], json!("environment-idp")); + assert_eq!(merged[1]["alias"], json!("client-saml-idp")); + } + + #[test] + fn an_addition_with_no_alias_is_appended_rather_than_dropped() { + let merged = + merge_by_alias(vec![json!({"alias": "a"})], vec![json!({"x": 1})]); + assert_eq!(merged.len(), 2); + } + + #[test] + fn every_execution_of_the_authenticator_gets_the_config() { + // A realm may run the same authenticator in more than one flow, and a step + // left unbound is a step with no configuration. + let mut realm = match json!({ + "authenticationFlows": [ + {"authenticationExecutions": [ + {"authenticator": "message-otp-authenticator"}, + {"authenticator": "something-else"}, + ]}, + {"authenticationExecutions": [ + {"authenticator": "message-otp-authenticator"}, + ]}, + ] + }) { + Value::Object(realm) => realm, + _ => unreachable!(), + }; + + bind_authenticator_config( + &mut realm, + "message-otp-authenticator", + "janitor-otp-by-availability", + ); + + let flows = realm["authenticationFlows"].as_array().unwrap(); + assert_eq!( + flows[0]["authenticationExecutions"][0]["authenticatorConfig"], + json!("janitor-otp-by-availability") + ); + assert!(flows[0]["authenticationExecutions"][1] + .get("authenticatorConfig") + .is_none()); + assert_eq!( + flows[1]["authenticationExecutions"][0]["authenticatorConfig"], + json!("janitor-otp-by-availability") + ); + } + + #[test] + fn binding_a_realm_with_no_flows_does_nothing_rather_than_panicking() { + let mut realm = Map::new(); + bind_authenticator_config(&mut realm, "a", "b"); + assert!(realm.is_empty()); + } + + #[test] + fn a_label_cell_reads_as_one_label_or_a_list() { + assert_eq!(labels_of(Some(&json!("one"))), ["one"]); + assert_eq!(labels_of(Some(&json!(["a", "b"]))), ["a", "b"]); + assert_eq!(labels_of(Some(&json!(["a", " ", "b"]))), ["a", "b"]); + assert!(labels_of(Some(&json!(" "))).is_empty()); + assert!(labels_of(None).is_empty()); + } +} diff --git a/packages/sequent-core/src/election_config/build_tables.rs b/packages/sequent-core/src/election_config/build_tables.rs new file mode 100644 index 00000000000..b54eb7711dc --- /dev/null +++ b/packages/sequent-core/src/election_config/build_tables.rs @@ -0,0 +1,1215 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The CSV members of a bundle, and the files that travel beside it. +//! +//! Four of a bundle's parts are not in the JSON document at all. Voters and +//! scheduled events are always CSVs, reports are a CSV or nothing, and admin +//! users, role permissions and communication templates are tenant- or +//! portal-scoped rather than part of an event import. +//! +//! A child module of [`super`] so it can reach the builder's resolved ids while +//! keeping [`super`] readable; the two are one unit of code split across two +//! files. + +use super::{value_as_text, Builder}; +use crate::election_config::emit::{ + JsonField, MULTI_VALUE_SEPARATOR, REPORT_COLUMNS, SCHEDULED_EVENT_COLUMNS, +}; +use crate::election_config::problem::Code; +use crate::election_config::sheet::{ + Origin, Row, SHEET_ADMIN_USERS, SHEET_PERMISSIONS, SHEET_REPORTS, + SHEET_SCHEDULED_EVENTS, SHEET_TEMPLATES, SHEET_VOTERS, +}; +use crate::types::scheduled_event::{ + generate_manage_date_task_name, EventProcessors, +}; +use serde_json::{json, Value}; +use std::str::FromStr; +use strum::IntoEnumIterator; + +/// Voter columns the builder derives or reorders. +/// +/// Everything else on the sheet is passed through as a Keycloak user attribute, +/// which is how a client adds a reporting breakout column without a code change. +pub const VOTER_LEADING_COLUMNS: &[&str] = &[ + "id", + "email", + "email_verified", + "enabled", + "first_name", + "last_name", + "username", + "area_name", + "authorized-election-ids", +]; + +/// A CSV to be written: header plus already-stringified rows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PlainTable { + pub columns: Vec, + pub rows: Vec>, +} + +impl PlainTable { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + /// The index of a column, for a caller that needs to read one back. + pub fn column(&self, name: &str) -> Option { + self.columns.iter().position(|column| column == name) + } +} + +/// A CSV whose fields hold JSON literals — the scheduled-events shape. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct JsonTable { + pub columns: Vec, + pub rows: Vec>, +} + +impl JsonTable { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn len(&self) -> usize { + self.rows.len() + } +} + +/// A voter-communication or report template from the Templates sheet. +/// +/// Emitted as a file beside the bundle rather than imported: the event zip has no +/// member for communication templates, so these are handed to whoever loads them +/// through the Admin Portal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunicationTemplate { + pub name: String, + pub alias: String, + pub document: String, + pub communication_method: Option, + pub template_type: Option, + pub selected_methods: Option, +} + +impl CommunicationTemplate { + /// A filesystem-safe name for the template's own file. + pub fn file_name(&self) -> String { + let source = if self.alias.trim().is_empty() { + &self.name + } else { + &self.alias + }; + let mut safe = String::with_capacity(source.len()); + for character in source.trim().chars() { + if character.is_alphanumeric() + || character == '-' + || character == '_' + { + safe.extend(character.to_lowercase()); + } else { + safe.push('-'); + } + } + let trimmed = safe.trim_matches('-'); + if trimmed.is_empty() { + "template.hbs".to_string() + } else { + format!("{trimmed}.hbs") + } + } +} + +impl Builder<'_> { + // -- voters ----------------------------------------------------------- + + pub(super) fn build_voters(&mut self) -> PlainTable { + let Some(sheet) = self.workbook.sheet(SHEET_VOTERS).cloned() else { + return PlainTable::default(); + }; + + // Anything the builder does not derive is carried through as a Keycloak + // user attribute. `area.external_id` is a reference, not an attribute. + let passthrough: Vec = sheet + .headers + .iter() + .filter(|header| { + !header.is_empty() + && !VOTER_LEADING_COLUMNS.contains(&header.as_str()) + && *header != "area.external_id" + }) + .cloned() + .collect(); + + let mut columns: Vec = VOTER_LEADING_COLUMNS + .iter() + .map(|column| (*column).to_string()) + .collect(); + columns.extend(passthrough.iter().cloned()); + self.check_csv_headers(&columns, "Voters"); + + let mut rows: Vec> = Vec::new(); + let mut seen: Vec<(String, usize)> = Vec::new(); + + for row in &sheet.rows { + let Some(username) = self.require_unique( + row, + "username", + "a voter needs a username", + &mut seen, + ) else { + continue; + }; + + let Some(area_name) = self.voter_area_name(row) else { + continue; + }; + + let email = row.get("email").map(value_as_text).unwrap_or_default(); + + // An unverified address blocks delivery of the one-time code, and a + // census address is one the client asserts is correct. So the default + // follows whether there is an address at all. + let email_verified = match row.get("email_verified") { + Some(value) => csv_bool(value), + None => bool_text(!email.is_empty()), + }; + let enabled = match row.get("enabled") { + Some(value) => csv_bool(value), + None => bool_text(true), + }; + + let mut values: Vec<(&str, String)> = vec![ + ("id", self.ids.uid("voter", &[&username])), + ("email", email), + ("email_verified", email_verified), + ("enabled", enabled), + ( + "first_name", + row.get("first_name") + .map(value_as_text) + .unwrap_or_default(), + ), + ( + "last_name", + row.get("last_name").map(value_as_text).unwrap_or_default(), + ), + ("username", username), + ("area_name", area_name), + ("authorized-election-ids", self.voter_elections(row)), + ]; + + let carried: Vec<(&str, String)> = passthrough + .iter() + .map(|header| (header.as_str(), csv_scalar(row.get(header)))) + .collect(); + values.extend(carried); + + rows.push( + columns + .iter() + .map(|column| { + values + .iter() + .find(|(name, _)| name == column) + .map(|(_, value)| value.clone()) + .unwrap_or_default() + }) + .collect(), + ); + } + + let table = self.drop_empty_voter_columns(PlainTable { columns, rows }); + self.check_voter_reachability(&table); + table + } + + /// The area's *name*, which is what the voters CSV identifies an area by. + fn voter_area_name(&mut self, row: &Row) -> Option { + let Some(external_id) = row.get("area.external_id").map(value_as_text) + else { + self.problem( + row.origin(Some("area.external_id")), + Code::MissingField, + "a voter needs an area", + ); + return None; + }; + let key = external_id.trim().to_string(); + + if let Some((_, name)) = + self.area_names.iter().find(|(id, _)| id == &key) + { + return Some(name.clone()); + } + + // A known area with no name has already been reported by the area + // builder; reporting it again per voter would bury the real message + // under one line per row. + if !self.area_ids.iter().any(|(id, _)| id == &key) { + self.problem( + row.origin(Some("area.external_id")), + Code::DanglingReference, + format!("no area has external_id '{key}'"), + ); + } + None + } + + /// Resolve `authorized-election-ids` from external ids to UUIDs. + /// + /// Blank means every election in the event: a voter with no restriction is + /// eligible for all of them, and writing an empty attribute would deny access + /// to all of them instead. + fn voter_elections(&mut self, row: &Row) -> String { + let every_election = |ids: &[(String, String)]| -> String { + ids.iter() + .map(|(_, id)| id.as_str()) + .collect::>() + .join(MULTI_VALUE_SEPARATOR) + }; + + let Some(raw) = row.get("authorized-election-ids").cloned() else { + return every_election(&self.election_ids); + }; + + // A cell holding only spaces means the same as an empty one. It is *present*, + // so without this every entry falls to the `is_empty` guard below, `resolved` + // stays empty, and an empty attribute denies the voter every election — + // silently, because nothing about that is reported. + if value_as_text(&raw).trim().is_empty() { + return every_election(&self.election_ids); + } + + let requested: Vec = match raw { + Value::Array(items) => items, + single => vec![single], + }; + + let mut resolved: Vec = Vec::new(); + for item in requested { + let key = value_as_text(&item).trim().to_string(); + if key.is_empty() { + continue; + } + match self.election_ids.iter().find(|(id, _)| id == &key) { + Some((_, election_id)) => { + let election_id = election_id.clone(); + if !resolved.contains(&election_id) { + resolved.push(election_id); + } + } + None => self.problem( + row.origin(Some("authorized-election-ids")), + Code::DanglingReference, + format!("no election has external_id '{key}'"), + ), + } + } + resolved.join(MULTI_VALUE_SEPARATOR) + } + + /// Drop passthrough columns that are empty for every voter. + /// + /// An all-blank column carries nothing, and one of them is actively harmful: + /// `get_copy_from_query` treats the mere presence of a `password` header as + /// "hash a password for each of these voters", so a blank password column + /// would give every voter an empty credential. + fn drop_empty_voter_columns(&mut self, table: PlainTable) -> PlainTable { + let keep: Vec = (0..table.columns.len()) + .filter(|index| { + VOTER_LEADING_COLUMNS.contains(&table.columns[*index].as_str()) + || table.rows.iter().any(|row| { + row.get(*index).is_some_and(|value| !value.is_empty()) + }) + }) + .collect(); + + if keep.len() == table.columns.len() { + return table; + } + + let dropped: Vec<&str> = (0..table.columns.len()) + .filter(|index| !keep.contains(index)) + .map(|index| table.columns[index].as_str()) + .collect(); + let message = format!( + "dropped voter columns that are blank for every voter: {}", + dropped.join(", ") + ); + self.warn("voters", message); + + PlainTable { + columns: keep + .iter() + .map(|index| table.columns[*index].clone()) + .collect(), + rows: table + .rows + .iter() + .map(|row| { + keep.iter().map(|index| row[*index].clone()).collect() + }) + .collect(), + } + } + + /// Warn when voters have no channel to receive a one-time code on. + /// + /// Not an error: credentials are sometimes distributed on paper. And under an + /// identity provider that authenticates the voter itself there is nothing to + /// send, which is why the presets level gates this — with no preset named, + /// checking is the safer default. + fn check_voter_reachability(&mut self, table: &PlainTable) { + if table.rows.is_empty() { + return; + } + + // Under an identity provider that authenticates the voter itself there is + // no code to send, so the whole question is noise. With no preset named, + // checking is the safer default. + if self.auth_preset.is_some_and(|preset| !preset.uses_otp) { + return; + } + + // There is always at least one contact column: `email` is derived, so it + // is present whether or not the source has it. The Python this was ported + // from also carried a "no contact column at all" warning, which for the + // same reason could never fire — a census with no email column at all + // reaches the per-voter count below with every voter unreachable, which + // is the more useful message anyway. + let contact: Vec = table + .columns + .iter() + .enumerate() + .filter(|(_, column)| { + *column == "email" + || column.contains("mobile") + || column.contains("phone") + }) + .map(|(index, _)| index) + .collect(); + + let unreachable = table + .rows + .iter() + .filter(|row| { + !contact.iter().any(|index| { + row.get(*index).is_some_and(|value| !value.is_empty()) + }) + }) + .count(); + + if unreachable > 0 { + let message = format!( + "{unreachable} of {} voters have neither an email address nor \ + a mobile number and cannot be sent a one-time code", + table.rows.len() + ); + self.warn("voters", message); + } + } + + // -- scheduled events ------------------------------------------------- + + pub(super) fn build_scheduled_events(&mut self) -> JsonTable { + let rows_in: Vec = + self.workbook.rows(SHEET_SCHEDULED_EVENTS).to_vec(); + let mut rows: Vec> = Vec::new(); + + // Kept alongside so the window check does not have to read the payload + // back out of the JSON it just wrote. + let mut scheduled: Vec<(EventProcessors, Option)> = Vec::new(); + // The same, with the row it came from, for the duplicate check below. + let mut identities: Vec<(EventProcessors, Option, usize)> = + Vec::new(); + + for row in &rows_in { + let Some(processor) = self.event_processor(row) else { + continue; + }; + + let Some(when) = row.get("scheduled_datetime").map(value_as_text) + else { + self.problem( + row.origin(Some("scheduled_datetime")), + Code::MissingField, + format!("{processor} needs a scheduled_datetime"), + ); + continue; + }; + + let mut election_id = None; + if row.get("election.external_id").is_some() { + let elections = self.election_ids.clone(); + let Some(resolved) = self.resolve( + row, + "election.external_id", + &elections, + "election", + ) else { + continue; + }; + election_id = Some(resolved); + } + + // The row's identity, and it has to be unique: both the uuid5 and the + // task id derive from the processor and the election alone, so two rows + // naming the same pair emit the same id twice. The importer keeps one and + // the other scheduled time is lost with no message. Rejected the way + // `require_unique` rejects a duplicate voter. + if let Some((.., earlier)) = + identities + .iter() + .find(|(seen_processor, seen_election, _)| { + seen_processor == &processor + && seen_election == &election_id + }) + { + let message = format!( + "{processor} is already scheduled for this election by row \ + {earlier}, and both rows would import as one" + ); + self.problem( + row.origin(Some("event_type")), + Code::DuplicateId, + message, + ); + continue; + } + identities.push(( + processor.clone(), + election_id.clone(), + row.number, + )); + + // Not a schema field, but it is how the author labels the row, and + // keeping it makes the emitted CSV readable next to the source. + let annotations = match row.get("event_name").map(value_as_text) { + Some(name) if !name.is_empty() => { + JsonField::Value(json!({"janitor.event_name": name})) + } + _ => JsonField::Null, + }; + + let task_id = generate_manage_date_task_name( + &self.tenant_id, + &self.event_id, + election_id.as_deref(), + &processor, + ); + + rows.push(vec![ + JsonField::string(self.ids.uid( + "scheduled_event", + &[ + processor.to_string().as_str(), + election_id.as_deref().unwrap_or(""), + ], + )), + JsonField::string(self.tenant_id.clone()), + JsonField::string(self.event_id.clone()), + JsonField::string(self.created_at.clone()), + JsonField::Null, // stopped_at + JsonField::Null, // archived_at + JsonField::Null, // labels + annotations, + JsonField::string(processor.to_string()), + JsonField::Value(json!({ + "cron": Value::Null, + "scheduled_date": when, + })), + JsonField::Value(json!({"election_id": election_id})), + JsonField::string(task_id), + ]); + scheduled.push((processor, election_id)); + } + + if rows.is_empty() { + self.warn( + "scheduled_events", + "no scheduled events: the voting period will have to be opened \ + and closed by hand in the Admin Portal", + ); + } else { + self.check_voting_windows(&scheduled); + } + + JsonTable { + columns: SCHEDULED_EVENT_COLUMNS + .iter() + .map(|column| (*column).to_string()) + .collect(), + rows, + } + } + + /// The row's `event_type` as a processor name the platform knows. + fn event_processor(&mut self, row: &Row) -> Option { + let Some(raw) = row.get("event_type").map(value_as_text) else { + self.problem( + row.origin(Some("event_type")), + Code::MissingField, + "a scheduled event needs an event_type", + ); + return None; + }; + + // Authors write "start voting period" and "start-voting-period" as often + // as the constant. + let processor = raw.trim().to_uppercase().replace(['-', ' '], "_"); + + // Parsed through the platform's own enum rather than matched against a copy + // of its variants: the task name the scheduler looks a job up by is built + // from this value, so a second list of processors is a second chance for a + // task that never fires. + let Ok(parsed) = EventProcessors::from_str(&processor) else { + let mut expected: Vec = + ::iter() + .map(|each| each.to_string()) + .collect(); + expected.sort_unstable(); + let message = format!( + "'{}' is not an event processor; expected one of {}", + raw.trim(), + expected.join(", ") + ); + self.problem( + row.origin(Some("event_type")), + Code::InvalidValue, + message, + ); + return None; + }; + Some(parsed) + } + + /// Warn about an election whose voting period never opens or never closes. + /// + /// A scheduled event with no election applies to the whole event, so an + /// election is covered either by its own row or by an event-wide one. An + /// uncovered election imports fine and then quietly never opens. + fn check_voting_windows( + &mut self, + scheduled: &[(EventProcessors, Option)], + ) { + let elections = self.election_ids.clone(); + let mut warnings: Vec = Vec::new(); + + for (external_id, election_id) in &elections { + let covered: Vec = scheduled + .iter() + .filter(|(_, scoped)| { + scoped.is_none() || scoped.as_deref() == Some(election_id) + }) + .map(|(processor, _)| processor.clone()) + .collect(); + + let missing: Vec = [ + EventProcessors::START_VOTING_PERIOD, + EventProcessors::END_VOTING_PERIOD, + ] + .into_iter() + .filter(|processor| !covered.contains(processor)) + .collect(); + + if missing.is_empty() { + continue; + } + + let effects: Vec<&str> = missing + .iter() + .map(|processor| { + if *processor == EventProcessors::START_VOTING_PERIOD { + "open" + } else { + "close" + } + }) + .collect(); + let named: Vec = + missing.iter().map(|each| each.to_string()).collect(); + warnings.push(format!( + "election '{external_id}' has no {} scheduled event; its \ + voting period will not {} on its own", + named.join(" and no "), + effects.join(" or ") + )); + } + + for warning in warnings { + self.warn("scheduled_events", warning); + } + } + + // -- reports ---------------------------------------------------------- + + pub(super) fn build_reports(&mut self) -> Option { + let rows_in: Vec = self.workbook.rows(SHEET_REPORTS).to_vec(); + if rows_in.is_empty() { + return None; + } + + let aliases: Vec = self + .workbook + .rows(SHEET_TEMPLATES) + .iter() + .filter_map(|row| row.get("alias")) + .map(|alias| value_as_text(alias).trim().to_string()) + .collect(); + + let mut rows: Vec> = Vec::new(); + for (index, row) in rows_in.iter().enumerate() { + let Some(report_type) = row.get("report_type").map(value_as_text) + else { + self.problem( + row.origin(Some("report_type")), + Code::MissingField, + "a report needs a report_type", + ); + continue; + }; + + let mut election_id = String::new(); + if row.get("election.external_id").is_some() { + let elections = self.election_ids.clone(); + let Some(resolved) = self.resolve( + row, + "election.external_id", + &elections, + "election", + ) else { + continue; + }; + election_id = resolved; + } + + let alias = row.get("template.alias").map(value_as_text); + if let Some(alias) = &alias { + if !aliases.contains(&alias.trim().to_string()) { + let message = + format!("no Templates row has alias '{alias}'"); + self.problem( + row.origin(Some("template.alias")), + Code::DanglingReference, + message, + ); + continue; + } + } + + let context = json!({ + "id": self.ids.uid( + "report", + &[&report_type, &election_id, &(index + 1).to_string()], + ), + "tenant_id": self.tenant_id, + "election_event_id": self.event_id, + "created_at": self.created_at, + "report_type": report_type, + }); + let rendered = self.render("report", Some(row), context); + + if row.get("password").is_some() { + let path = row.origin(Some("password")).to_string(); + self.warn( + path, + "the report password is written to the reports CSV in clear \ + text; treat the output as a secret", + ); + } + + // These come from the sheet's own columns rather than from + // dotted-path overrides: they are control columns, so the row was + // excluded from the merge and the rendered value is only the + // template's default. Reading the template instead is how + // `configured_password` silently became `unencrypted` once. + let cron_config = row + .get("cron_config") + .cloned() + .or_else(|| rendered.get("cron_config").cloned()); + let policy = row + .get("encryption_policy") + .map(value_as_text) + .filter(|policy| !policy.is_empty()) + .or_else(|| { + rendered + .get("encryption_policy") + .map(value_as_text) + .filter(|policy| !policy.is_empty()) + }) + .unwrap_or_else(|| "unencrypted".to_string()); + + rows.push(vec![ + rendered.get("id").map(value_as_text).unwrap_or_default(), + election_id, + report_type, + alias.unwrap_or_default(), + csv_json(cron_config.as_ref()), + policy, + csv_scalar(row.get("password")), + // Option>, split on "|" by process_reports_file. + join_multi(row.get("permission_label")), + ]); + } + + if rows.is_empty() { + return None; + } + Some(PlainTable { + columns: REPORT_COLUMNS + .iter() + .map(|column| (*column).to_string()) + .collect(), + rows, + }) + } + + // -- admin users, permissions, templates ------------------------------- + + pub(super) fn build_admin_users(&mut self) -> Option { + let sheet = self.workbook.sheet(SHEET_ADMIN_USERS).cloned()?; + if sheet.rows.is_empty() { + return None; + } + + let columns: Vec = sheet + .headers + .iter() + .filter(|header| !header.is_empty()) + .cloned() + .collect(); + self.check_csv_headers(&columns, "Admin Users"); + + let mut rows: Vec> = Vec::new(); + let mut warned_password = false; + + for row in &sheet.rows { + if row.get("username").is_none() { + self.problem( + row.origin(Some("username")), + Code::MissingField, + "an admin user needs a username", + ); + continue; + } + if row.get("password").is_some() && !warned_password { + warned_password = true; + self.warn( + "admin_users", + "Admin Users carries clear-text passwords; the emitted \ + admin_users CSV is a secret, not a deliverable", + ); + } + + rows.push( + columns + .iter() + .map(|header| match row.get(header) { + // permission_labels arrives "||"-separated and leaves + // "|"-separated, like every multi-valued attribute. + Some(Value::Array(_)) => join_multi(row.get(header)), + value => csv_scalar(value), + }) + .collect(), + ); + } + + Some(PlainTable { columns, rows }) + } + + /// Transpose the permission matrix into the platform's own shape. + /// + /// `export_tenant_config.rs` writes `role,permissions` with permissions + /// joined by `|`; the source holds the transpose, one row per permission and + /// one column per role, marked with any non-empty cell. A matrix is what a + /// human can check at a glance, which is why the conversion lives here. + pub(super) fn build_role_permissions(&mut self) -> Option { + let sheet = self.workbook.sheet(SHEET_PERMISSIONS).cloned()?; + if sheet.rows.is_empty() { + return None; + } + + let roles: Vec = sheet + .headers + .iter() + .skip(1) + .filter(|header| !header.is_empty()) + .cloned() + .collect(); + + if roles.is_empty() { + self.problem( + Origin { + sheet: sheet.name.clone(), + row: 1, + column: None, + }, + Code::MissingField, + "the Permissions matrix has no role columns; expected the first \ + column to hold permissions and one further column per role", + ); + return None; + } + + let permission_column = sheet.headers[0].clone(); + let mut granted: Vec<(String, Vec)> = roles + .iter() + .map(|role| (role.clone(), Vec::new())) + .collect(); + + for row in &sheet.rows { + let Some(permission) = + row.get(&permission_column).map(value_as_text) + else { + continue; + }; + for (role, permissions) in granted.iter_mut() { + if row.get(role).is_some() { + permissions.push(permission.clone()); + } + } + } + + Some(PlainTable { + columns: vec!["role".to_string(), "permissions".to_string()], + rows: granted + .into_iter() + .map(|(role, permissions)| { + vec![role, permissions.join(MULTI_VALUE_SEPARATOR)] + }) + .collect(), + }) + } + + pub(super) fn build_templates(&mut self) -> Vec { + let rows: Vec = self.workbook.rows(SHEET_TEMPLATES).to_vec(); + let mut templates = Vec::new(); + let mut seen: Vec<(String, usize)> = Vec::new(); + + for row in &rows { + let alias = row + .get("alias") + .or_else(|| row.get("name")) + .map(value_as_text) + .filter(|alias| !alias.is_empty()); + + let Some(alias) = alias else { + self.problem( + row.origin(Some("alias")), + Code::MissingField, + "a template needs a name or an alias", + ); + continue; + }; + + if let Some((_, earlier)) = seen.iter().find(|(id, _)| id == &alias) + { + let message = + format!("alias '{alias}' is already used by row {earlier}"); + self.problem( + row.origin(Some("alias")), + Code::DuplicateId, + message, + ); + continue; + } + seen.push((alias.clone(), row.number)); + + let Some(document) = + row.get("template.document").map(value_as_text) + else { + self.problem( + row.origin(Some("template.document")), + Code::MissingField, + "a template needs a document", + ); + continue; + }; + + templates.push(CommunicationTemplate { + name: row + .get("name") + .map(value_as_text) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| alias.clone()), + alias, + document: unescape_document(&document), + communication_method: row + .get("communication_method") + .map(value_as_text), + template_type: row.get("type").map(value_as_text), + selected_methods: row.get("template.selected_methods").cloned(), + }); + } + templates + } + + // -- shared ----------------------------------------------------------- + + /// A required, unique value from one column. + fn require_unique( + &mut self, + row: &Row, + column: &str, + missing: &str, + seen: &mut Vec<(String, usize)>, + ) -> Option { + let value = match row.get(column).map(value_as_text) { + Some(value) if !value.is_empty() => value, + _ => { + self.problem( + row.origin(Some(column)), + Code::MissingField, + missing, + ); + return None; + } + }; + + if let Some((_, earlier)) = seen.iter().find(|(id, _)| id == &value) { + let message = + format!("{column} '{value}' is already used by row {earlier}"); + self.problem(row.origin(Some(column)), Code::DuplicateId, message); + return None; + } + seen.push((value.clone(), row.number)); + Some(value) + } + + /// Both CSV importers reject headers outside their `HEADER_RE`. + /// + /// Catching it here turns an opaque mid-import failure into a message naming + /// the column. + fn check_csv_headers(&mut self, columns: &[String], sheet: &str) { + let offenders: Vec = columns + .iter() + .filter(|column| !is_importable_header(column)) + .cloned() + .collect(); + + for column in offenders { + self.problem( + Origin { + sheet: sheet.to_string(), + row: 1, + column: Some(column), + }, + Code::InvalidValue, + "the importer rejects this column name; only letters, digits, \ + '.', '_' and '-' are allowed", + ); + } + } +} + +/// `^[a-zA-Z0-9._-]+$`, the pattern both CSV importers enforce. +fn is_importable_header(column: &str) -> bool { + !column.is_empty() + && column.chars().all(|character| { + character.is_ascii_alphanumeric() + || character == '.' + || character == '_' + || character == '-' + }) +} + +fn bool_text(value: bool) -> String { + if value { + "true".to_string() + } else { + "false".to_string() + } +} + +/// A cell meant as a flag. +/// +/// Spreadsheets carry every spelling of yes: an author ticking a column with an +/// `x` means the same as one typing `TRUE`. +fn csv_bool(value: &Value) -> String { + match value { + Value::Bool(value) => bool_text(*value), + Value::String(text) => bool_text(matches!( + text.trim().to_lowercase().as_str(), + "true" | "1" | "yes" | "x" + )), + Value::Null => bool_text(false), + Value::Number(number) => bool_text(number.as_f64() != Some(0.0)), + _ => bool_text(true), + } +} + +/// One cell of a plain CSV. Absent becomes empty, never the word "null". +fn csv_scalar(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::Bool(flag)) => bool_text(*flag), + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + } +} + +/// A cell holding JSON: text passes through, anything else is encoded. +fn csv_json(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(text)) => text.clone(), + Some(other) => other.to_string(), + } +} + +/// A multi-valued cell, joined the way the importer splits it. +fn join_multi(value: Option<&Value>) -> String { + match value { + Some(Value::Array(items)) => items + .iter() + .map(value_as_text) + .collect::>() + .join(MULTI_VALUE_SEPARATOR), + other => csv_scalar(other), + } +} + +/// Un-escape a document pasted into a spreadsheet cell. +/// +/// The Templates sheet holds documents with literal `\n` and `\"`, because that +/// is what survives a copy-paste out of a JSON export. Writing those through +/// verbatim emits a template full of backslash-n instead of newlines. +fn unescape_document(document: &str) -> String { + if !document.contains("\\n") && !document.contains("\\\"") { + return document.to_string(); + } + + // One pass, so that an escaped backslash before an n is not then read as a + // newline: "\\n" is a backslash followed by an n, not a line break. + let mut out = String::with_capacity(document.len()); + let mut characters = document.chars(); + while let Some(character) = characters.next() { + if character != '\\' { + out.push(character); + continue; + } + match characters.next() { + Some('n') => out.push('\n'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_header_the_importer_would_reject_is_caught_here() { + assert!(is_importable_header("permission_labels")); + assert!(is_importable_header("area.external_id")); + assert!(is_importable_header("mobile-number")); + // The ones that would fail mid-import, opaquely. + assert!(!is_importable_header("home address")); + assert!(!is_importable_header("años")); + assert!(!is_importable_header("")); + } + + #[test] + fn every_spelling_of_yes_is_a_yes() { + // An author ticking a column with an x means what someone typing TRUE + // means. + for yes in ["true", "TRUE", "Yes", "x", "1"] { + assert_eq!(csv_bool(&json!(yes)), "true", "{yes}"); + } + for no in ["false", "no", "", "0"] { + assert_eq!(csv_bool(&json!(no)), "false", "{no}"); + } + assert_eq!(csv_bool(&json!(true)), "true"); + assert_eq!(csv_bool(&json!(0)), "false"); + } + + #[test] + fn an_absent_cell_is_empty_and_never_the_word_null() { + // "null" in a plain CSV is a voter attribute holding the text null. + assert_eq!(csv_scalar(None), ""); + assert_eq!(csv_scalar(Some(&Value::Null)), ""); + assert_eq!(csv_scalar(Some(&json!("kept"))), "kept"); + assert_eq!(csv_scalar(Some(&json!(7))), "7"); + assert_eq!(csv_scalar(Some(&json!({"a": 1}))), r#"{"a":1}"#); + } + + #[test] + fn a_multi_valued_cell_leaves_single_pipe_separated() { + // "||" going in, "|" coming out: the source's separator is not the + // importer's. + assert_eq!(join_multi(Some(&json!(["a", "b"]))), "a|b"); + assert_eq!(join_multi(Some(&json!(["only"]))), "only"); + assert_eq!(join_multi(Some(&json!([]))), ""); + assert_eq!(join_multi(Some(&json!("plain"))), "plain"); + assert_eq!(join_multi(None), ""); + } + + #[test] + fn a_pasted_document_gets_its_newlines_back() { + // What survives a copy-paste out of a JSON export. + assert_eq!( + unescape_document(r#"Dear {{name}},\n\nYour code is {{code}}."#), + "Dear {{name}},\n\nYour code is {{code}}." + ); + assert_eq!(unescape_document(r#"say \"hi\""#), r#"say "hi""#); + } + + #[test] + fn a_document_with_no_escapes_is_left_exactly_alone() { + let literal = "already\nreal\nnewlines and a \\ backslash"; + assert_eq!(unescape_document(literal), literal); + } + + #[test] + fn an_escaped_backslash_before_an_n_is_not_a_newline() { + // The bug a two-pass replace would have: "\\n" is a backslash and an n. + assert_eq!(unescape_document(r"a\\nb"), r"a\nb"); + } + + #[test] + fn a_template_file_name_is_safe_to_write() { + let template = |alias: &str, name: &str| CommunicationTemplate { + name: name.to_string(), + alias: alias.to_string(), + document: String::new(), + communication_method: None, + template_type: None, + selected_methods: None, + }; + assert_eq!( + template("Voter Credentials", "").file_name(), + "voter-credentials.hbs" + ); + assert_eq!(template("otp_email", "").file_name(), "otp_email.hbs"); + // Falls back to the name, then to something writable. + assert_eq!( + template("", "Fallback Name").file_name(), + "fallback-name.hbs" + ); + assert_eq!(template("///", "").file_name(), "template.hbs"); + } + + #[test] + fn the_scheduled_events_columns_are_the_ones_the_importer_reads() { + // Positional: a reordering here is a payload read as a task id. + assert_eq!(SCHEDULED_EVENT_COLUMNS[10], "event_payload"); + assert_eq!(REPORT_COLUMNS[1], "election_id"); + assert_eq!(REPORT_COLUMNS[7], "permission_label"); + } +} diff --git a/packages/sequent-core/src/election_config/build_tests.rs b/packages/sequent-core/src/election_config/build_tests.rs new file mode 100644 index 00000000000..46c03c698b3 --- /dev/null +++ b/packages/sequent-core/src/election_config/build_tests.rs @@ -0,0 +1,2479 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Tests for [`super`], in their own file because there are more of them than +//! there is builder. + +use super::*; +use crate::election_config::emit::JsonField; +use crate::election_config::paths::Cell; +use crate::election_config::sheet::Sheet; + +fn text(value: &str) -> Cell { + Cell::text(value) +} + +/// A document that builds cleanly, for a test to break one thing about. +/// +/// Deliberately minimal: one election, one contest with two candidates, two areas +/// and the ballot links between them. Everything a test needs is either here or +/// the point of the test. +fn sound() -> Workbook { + workbook(vec![ + ( + "ElectionEvent", + vec![ + vec![text("external_id"), text("presentation.i18n.en.name")], + vec![text("union-2027"), text("Union Election 2027")], + ], + ), + ( + "Elections", + vec![ + vec![text("external_id"), text("presentation.i18n.en.name")], + vec![text("statewide"), text("Statewide Officers")], + ], + ), + ( + "Contests", + vec![ + vec![ + text("external_id"), + text("election.external_id"), + text("presentation.i18n.en.name"), + text("max_votes"), + ], + vec![ + text("president"), + text("statewide"), + text("President"), + Cell::Int(1), + ], + ], + ), + ( + "Candidates", + vec![ + vec![ + text("external_id"), + text("contest.external_id"), + text("presentation.i18n.en.name"), + ], + vec![text("alice"), text("president"), text("Alice")], + vec![text("bob"), text("president"), text("Bob")], + ], + ), + ( + "Areas", + vec![ + vec![text("external_id"), text("name")], + vec![text("area-north"), text("North")], + vec![text("area-south"), text("South")], + ], + ), + ( + "AreaContests", + vec![ + vec![text("area.external_id"), text("contest.external_id")], + vec![text("area-north"), text("president")], + vec![text("area-south"), text("president")], + ], + ), + ]) +} + +fn workbook(sheets: Vec<(&str, Vec>)>) -> Workbook { + Workbook::new( + sheets + .into_iter() + .map(|(name, grid)| Sheet::from_grid(name, &grid).unwrap()) + .collect(), + ) + .unwrap() +} + +/// The sound document with one sheet replaced. +fn with_sheet(name: &str, grid: Vec>) -> Workbook { + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != name) + .cloned() + .collect(); + sheets.push(Sheet::from_grid(name, &grid).unwrap()); + Workbook::new(sheets).unwrap() +} + +fn built(workbook: &Workbook) -> Bundle { + let templates = TemplateSet::builtin().unwrap(); + match build(workbook, &templates, &BuildOptions::default()) { + Ok(bundle) => bundle, + Err(report) => panic!("expected a clean build, got:\n{report}"), + } +} + +fn refused(workbook: &Workbook) -> Report { + let templates = TemplateSet::builtin().unwrap(); + match build(workbook, &templates, &BuildOptions::default()) { + Ok(_) => panic!("expected a refusal"), + Err(report) => report, + } +} + +// -- the happy path ------------------------------------------------------- + +#[test] +fn a_sound_document_builds() { + let bundle = built(&sound()); + assert_eq!(bundle.event_external_id, "union-2027"); + assert_eq!(bundle.export["elections"].as_array().unwrap().len(), 1); + assert_eq!(bundle.export["contests"].as_array().unwrap().len(), 1); + assert_eq!(bundle.export["candidates"].as_array().unwrap().len(), 2); + assert_eq!(bundle.export["areas"].as_array().unwrap().len(), 2); + assert_eq!(bundle.export["area_contests"].as_array().unwrap().len(), 2); + assert!(!bundle.warnings.has_errors()); +} + +#[test] +fn what_it_builds_is_a_bundle_the_platform_accepts() { + // The property that makes sharing this code worth anything: the document the + // builder produces deserializes into the importer's own struct and passes the + // importer's own validation. Two implementations that merely look similar + // would not. + let bundle = built(&sound()); + let schema: crate::election_config::ImportElectionEventSchema = + serde_json::from_value(bundle.export.clone()) + .expect("the built export must deserialize into the import schema"); + + let report = crate::election_config::validate(&schema); + assert!( + !report.has_errors(), + "a document that builds cleanly must also validate:\n{report}" + ); +} + +#[test] +fn the_same_document_builds_the_same_bytes_twice() { + // Fixed timestamps and derived ids exist for this: a regenerated bundle must + // diff only where the author changed something. + let first = serde_json::to_string(&built(&sound()).export).unwrap(); + let second = serde_json::to_string(&built(&sound()).export).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn the_references_resolve_to_the_ids_the_entities_carry() { + let bundle = built(&sound()); + let election_id = bundle.export["elections"][0]["id"].as_str().unwrap(); + let contest = &bundle.export["contests"][0]; + let candidate = &bundle.export["candidates"][0]; + + assert_eq!(contest["election_id"].as_str().unwrap(), election_id); + assert_eq!( + candidate["contest_id"].as_str().unwrap(), + contest["id"].as_str().unwrap() + ); + assert_eq!( + bundle.export["area_contests"][0]["contest_id"] + .as_str() + .unwrap(), + contest["id"].as_str().unwrap() + ); +} + +#[test] +fn every_entity_belongs_to_the_event_and_the_tenant() { + let bundle = built(&sound()); + for key in ["elections", "contests", "candidates", "areas"] { + for entity in bundle.export[key].as_array().unwrap() { + assert_eq!( + entity["election_event_id"].as_str().unwrap(), + bundle.event_id, + "{key}" + ); + assert_eq!( + entity["tenant_id"].as_str().unwrap(), + bundle.tenant_id, + "{key}" + ); + } + } +} + +#[test] +fn a_column_becomes_a_nested_field() { + // The dotted-header mapping, end to end: a new column lands in the output + // with no code change, which is what keeps the builder client-agnostic. + let bundle = built(&sound()); + assert_eq!( + bundle.export["contests"][0]["presentation"]["i18n"]["en"]["name"], + json!("President") + ); + assert_eq!(bundle.export["contests"][0]["max_votes"], json!(1)); +} + +#[test] +fn a_control_column_is_consumed_rather_than_merged() { + // `election.external_id` names a reference; it must not become a field called + // `external_id` on an object called `election`. + let bundle = built(&sound()); + assert!(bundle.export["contests"][0].get("election").is_none()); + assert!(bundle.export["areas"][0].get("parent").is_none()); + assert!(bundle.export["area_contests"][0].get("area").is_none()); +} + +#[test] +fn the_reports_and_scheduled_events_the_importer_ignores_are_left_alone() { + // Both travel in their own CSV. A populated array here is silently dropped, + // which is how a report goes missing without an error. + let bundle = built(&sound()); + assert_eq!(bundle.export["reports"], json!([])); + assert_eq!(bundle.export["scheduled_events"], Value::Null); +} + +// -- identity ------------------------------------------------------------- + +#[test] +fn an_event_with_no_external_id_stops_the_build_immediately() { + // Every generated id is derived from it, so there is nothing to build. + let report = refused(&with_sheet( + "ElectionEvent", + vec![ + vec![text("external_id"), text("presentation.i18n.en.name")], + vec![Cell::Blank, text("Nameless")], + ], + )); + assert!(has_error_saying(&report, "needs an external_id")); +} + +#[test] +fn an_empty_event_sheet_says_what_it_wanted() { + let report = refused(&with_sheet( + "ElectionEvent", + vec![vec![text("external_id")]], + )); + assert!(has_error_saying(&report, "exactly one row")); +} + +#[test] +fn two_event_rows_are_refused_rather_than_the_first_one_winning() { + // Picking one silently would import half of what someone meant. + let report = refused(&with_sheet( + "ElectionEvent", + vec![ + vec![text("external_id")], + vec![text("one")], + vec![text("two")], + ], + )); + assert!(has_error_saying(&report, "exactly one election event")); +} + +#[test] +fn a_duplicated_external_id_names_the_row_that_used_it_first() { + let report = refused(&with_sheet( + "Elections", + vec![ + vec![text("external_id")], + vec![text("statewide")], + vec![text("statewide")], + ], + )); + assert!(has_error_saying(&report, "already used by row 2")); +} + +#[test] +fn every_problem_is_reported_in_one_run() { + // An author fixing a spreadsheet wants the whole list, not one round trip per + // mistake. + let report = refused(&with_sheet( + "Candidates", + vec![ + vec![text("external_id"), text("contest.external_id")], + vec![Cell::Blank, text("president")], + vec![text("alice"), text("no-such-contest")], + vec![text("bob"), Cell::Blank], + ], + )); + assert_eq!(report.errors().count(), 3, "{report}"); +} + +#[test] +fn a_problem_names_the_sheet_and_row_to_look_at() { + let report = refused(&with_sheet( + "Contests", + vec![ + vec![text("external_id"), text("election.external_id")], + vec![text("president"), text("no-such-election")], + ], + )); + let problem = report.errors().next().unwrap(); + assert_eq!( + problem.path, + "sheet 'Contests' row 2 column 'election.external_id'" + ); + assert_eq!(problem.code, Code::DanglingReference); +} + +#[test] +fn a_contest_is_told_what_the_template_stood_in_for() { + // The template carries all five, so `MissingField` cannot fire for them on a + // built bundle. Being told which values were not the workbook's is the part + // that does not need a product decision. + let bundle = built(&with_sheet( + "Contests", + vec![ + vec![ + text("external_id"), + text("election.external_id"), + text("presentation.i18n.en.name"), + text("max_votes"), + ], + vec![ + text("president"), + text("statewide"), + text("President"), + Cell::Int(3), + ], + ], + )); + + let said: Vec<&str> = bundle + .warnings + .problems + .iter() + .map(|problem| problem.message.as_str()) + .collect(); + // The value named is read back out of the built contest rather than listed in + // the builder, so the message cannot drift from what `contest.hbs` carries. + for (column, substituted) in [ + ("min_votes", "0"), + ("winning_candidates_num", "1"), + ("voting_type", "non-preferential"), + ("counting_algorithm", "plurality-at-large"), + ] { + let wanted = format!( + "has no {column}, so the base template's '{substituted}' stands in" + ); + assert!( + said.iter().any(|message| message.contains(&wanted)), + "nothing said about {column}:\n{said:#?}" + ); + } + // The one the workbook did give is not reported, and it is the one that wins. + assert!(!said + .iter() + .any(|message| message.contains("has no max_votes"))); + assert_eq!(bundle.export["contests"][0]["max_votes"], 3); +} + +// -- references ----------------------------------------------------------- + +#[test] +fn a_contest_pointing_at_no_election_is_refused() { + let report = refused(&with_sheet( + "Contests", + vec![ + vec![text("external_id"), text("election.external_id")], + vec![text("president"), text("nowhere")], + ], + )); + assert!(has_error_saying( + &report, + "no election has external_id 'nowhere'" + )); +} + +#[test] +fn a_missing_reference_column_is_refused_too() { + let report = refused(&with_sheet( + "Contests", + vec![vec![text("external_id")], vec![text("president")]], + )); + assert!(has_error_saying( + &report, + "'election.external_id' is required: it names the election this row \ + belongs to" + )); +} + +#[test] +fn a_numeric_id_matches_the_same_number_written_as_text() { + // Whether a cell was formatted as a number is not something an author + // controls per column, and an id is an id. + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "Elections" && sheet.name != "Contests") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "Elections", + &[vec![text("external_id")], vec![Cell::Int(1001)]], + ) + .unwrap(), + ); + sheets.push( + Sheet::from_grid( + "Contests", + &[ + vec![text("external_id"), text("election.external_id")], + vec![text("president"), text("1001")], + ], + ) + .unwrap(), + ); + let bundle = built(&Workbook::new(sheets).unwrap()); + assert_eq!( + bundle.export["contests"][0]["election_id"], + bundle.export["elections"][0]["id"] + ); +} + +#[test] +fn a_numeric_id_matches_in_the_areas_and_area_contests_sheets_too() { + // The Areas and AreaContests builders read their reference cells through a + // different accessor from the Elections one, so a numeric id registered in one + // pass and vanished in the next — and in AreaContests two different numeric pairs + // both keyed the duplicate check as ("", ""). + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "Areas" && sheet.name != "AreaContests") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "Areas", + &[ + vec![text("external_id"), text("name")], + vec![Cell::Int(2001), text("North")], + vec![Cell::Int(2002), text("South")], + ], + ) + .unwrap(), + ); + sheets.push( + Sheet::from_grid( + "AreaContests", + &[ + vec![text("area.external_id"), text("contest.external_id")], + vec![Cell::Int(2001), text("president")], + vec![Cell::Int(2002), text("president")], + ], + ) + .unwrap(), + ); + + let bundle = built(&Workbook::new(sheets).unwrap()); + assert_eq!(bundle.export["areas"].as_array().unwrap().len(), 2); + // Two links, not one collapsed pair and not a spurious duplicate refusal. + assert_eq!(bundle.export["area_contests"].as_array().unwrap().len(), 2); +} + +// -- areas ---------------------------------------------------------------- + +#[test] +fn a_parent_may_appear_below_its_own_child() { + // Authors do not sort their spreadsheets topologically, so ids are collected + // in a first pass. + let bundle = built(&with_sheet( + "Areas", + vec![ + vec![ + text("external_id"), + text("name"), + text("parent.external_id"), + ], + vec![text("area-north"), text("North"), text("area-state")], + vec![text("area-south"), text("South"), text("area-state")], + vec![text("area-state"), text("Statewide"), Cell::Blank], + ], + )); + let north = &bundle.export["areas"][0]; + let state = &bundle.export["areas"][2]; + assert_eq!(north["parent_id"], state["id"]); + assert_eq!(state["parent_id"], Value::Null); +} + +#[test] +fn an_area_cannot_be_its_own_parent() { + let report = refused(&with_sheet( + "Areas", + vec![ + vec![ + text("external_id"), + text("name"), + text("parent.external_id"), + ], + vec![text("area-north"), text("North"), text("area-north")], + ], + )); + assert!(has_error_saying(&report, "cannot be its own parent")); + assert_eq!(report.errors().next().unwrap().code, Code::AreaCycle); +} + +#[test] +fn an_area_needs_a_name_because_the_voters_csv_resolves_by_name() { + let report = refused(&with_sheet( + "Areas", + vec![ + vec![text("external_id"), text("name")], + vec![text("area-north"), Cell::Blank], + ], + )); + assert!(has_error_saying( + &report, + "identifies a voter's area by name" + )); +} + +#[test] +fn two_areas_may_not_share_a_name() { + // The voters CSV resolves an area by name, so a duplicate silently assigns + // voters to whichever one the importer happens to find. + let report = refused(&with_sheet( + "Areas", + vec![ + vec![text("external_id"), text("name")], + vec![text("area-north"), text("North")], + vec![text("area-south"), text("North")], + ], + )); + assert!(has_error_saying(&report, "both named 'North'")); +} + +// -- ballot coverage ------------------------------------------------------ + +#[test] +fn a_document_with_no_ballot_links_is_refused() { + let report = refused(&with_sheet( + "AreaContests", + vec![vec![text("area.external_id"), text("contest.external_id")]], + )); + assert!(has_error_saying(&report, "no voter would see a ballot")); +} + +#[test] +fn the_same_area_and_contest_may_not_be_linked_twice() { + // Both rows would mint the same id, and one would silently overwrite the + // other. + let report = refused(&with_sheet( + "AreaContests", + vec![ + vec![text("area.external_id"), text("contest.external_id")], + vec![text("area-north"), text("president")], + vec![text("area-north"), text("president")], + ], + )); + assert!(has_error_saying(&report, "already linked")); +} + +#[test] +fn an_event_with_no_elections_contests_or_areas_says_so_about_each() { + let mut sheets: Vec = vec![Sheet::from_grid( + "ElectionEvent", + &[vec![text("external_id")], vec![text("empty-event")]], + ) + .unwrap()]; + for name in ["Elections", "Contests", "Areas", "AreaContests"] { + sheets.push( + Sheet::from_grid(name, &[vec![text("external_id")]]).unwrap(), + ); + } + let report = refused(&Workbook::new(sheets).unwrap()); + assert!(has_error_saying(&report, "at least one election")); + assert!(has_error_saying(&report, "at least one contest")); + assert!(has_error_saying(&report, "at least one area")); +} + +// -- options -------------------------------------------------------------- + +#[test] +fn a_tenant_id_may_be_supplied() { + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + tenant_id: Some("11111111-1111-4111-8111-111111111111".to_string()), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!(bundle.tenant_id, "11111111-1111-4111-8111-111111111111"); + assert_eq!( + bundle.export["tenant_id"], + json!("11111111-1111-4111-8111-111111111111") + ); +} + +#[test] +fn a_parameter_supplies_the_tenant_id_when_no_option_does() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("tenant_id"), + text("22222222-2222-4222-8222-222222222222"), + ], + ], + )); + assert_eq!(bundle.tenant_id, "22222222-2222-4222-8222-222222222222"); +} + +#[test] +fn without_one_the_tenant_id_is_derived_and_stable() { + // Only a fallback, but it must not change between runs or every regeneration + // is a diff. + assert_eq!(built(&sound()).tenant_id, built(&sound()).tenant_id); +} + +#[test] +fn the_slug_comes_from_the_event_or_the_caller() { + assert_eq!(built(&sound()).slug, "union-2027"); + + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + slug: Some("chosen".to_string()), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!(bundle.slug, "chosen"); +} + +#[test] +fn a_slug_is_filesystem_safe_whatever_the_external_id_looks_like() { + assert_eq!( + slugify("SEIU 1000 / Leadership 2027"), + "seiu-1000-leadership-2027" + ); + assert_eq!(slugify(" --already-- "), "already"); + assert_eq!(slugify("!!!"), "election-event"); + assert_eq!(slugify(""), "election-event"); +} + +#[test] +fn a_created_at_may_be_supplied_and_reaches_every_entity() { + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + created_at: Some("2030-06-01T00:00:00.000000Z".to_string()), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!( + bundle.export["contests"][0]["created_at"], + json!("2030-06-01T00:00:00.000000Z") + ); +} + +// -- parameters ----------------------------------------------------------- + +#[test] +fn a_dotted_parameter_patches_the_event() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("event"), + text("election_event.presentation.theme"), + text("dark"), + ], + ], + )); + assert_eq!( + bundle.export["election_event"]["presentation"]["theme"], + json!("dark") + ); +} + +#[test] +fn a_parameter_nothing_interprets_is_recorded_and_said_out_loud() { + // Dropping it silently is how a setting goes missing on election day. + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![text("client"), text("helpdesk_phone"), text("555-0100")], + ], + )); + // Namespaced the way the SEIU1000 bundle already carries them, so a + // regenerated event does not move its annotations. + assert_eq!( + bundle.export["election_event"]["annotations"] + ["janitor.param.client.helpdesk_phone"], + json!("555-0100") + ); + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("helpdesk_phone"))); +} + +#[test] +fn a_parameter_with_no_value_is_ignored_with_a_warning() { + // A placeholder an author left blank, pending something from the client. + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![text("settings"), text("saml_idp_metadata_url"), Cell::Blank], + ], + )); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("has no value and is ignored"))); + assert!(bundle.export["election_event"] + .get("annotations") + .and_then(|annotations| annotations + .get("janitor.param.settings.saml_idp_metadata_url")) + .is_none()); +} + +#[test] +fn a_row_with_no_key_is_a_note_to_the_author_and_is_skipped() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value"), text("comment")], + vec![ + Cell::Blank, + Cell::Blank, + Cell::Blank, + text("ask the client"), + ], + ], + )); + assert!( + !bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("parameter")), + "{}", + bundle.warnings + ); +} + +// -- base export ---------------------------------------------------------- + +#[test] +fn a_base_export_contributes_fields_the_templates_do_not_know() { + // What a base export is for: a newer platform version's additions arrive + // without a template change. + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + base_export: Some(json!({ + "election_event": {"a_new_field": "from the base"}, + "version": "v11.0.0", + })), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!( + bundle.export["election_event"]["a_new_field"], + json!("from the base") + ); + assert_eq!(bundle.export["version"], json!("v11.0.0")); +} + +#[test] +fn a_base_export_never_supplies_identity() { + // The base names its own ids, its own board and its own keys. Carrying any of + // them over produces an event that looks configured and is not. + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + base_export: Some(json!({ + "election_event": { + "id": "99999999-9999-4999-8999-999999999999", + "tenant_id": "88888888-8888-4888-8888-888888888888", + "external_id": "someone-elses-event", + "bulletin_board_reference": "board-of-the-other-event", + "public_key": "not our key", + "statistics": {"votes": 4213}, + "status": "finished", + }, + })), + ..BuildOptions::default() + }, + ) + .unwrap(); + + let event = &bundle.export["election_event"]; + assert_eq!(event["id"].as_str().unwrap(), bundle.event_id); + assert_eq!(event["tenant_id"].as_str().unwrap(), bundle.tenant_id); + assert_eq!(event["external_id"], json!("union-2027")); + + // The templates declare all four with platform defaults, so what scrubbing + // guarantees is that the base's own values are gone — a fresh board, no key, + // and a status describing an event that has not run. + assert_eq!(event["bulletin_board_reference"], Value::Null); + assert_eq!(event["public_key"], Value::Null); + assert_ne!(event["statistics"], json!({"votes": 4213})); + assert_ne!(event["status"], json!("finished")); + assert_eq!(event["status"]["voting_status"], json!("NOT_STARTED")); +} + +#[test] +fn a_base_export_does_not_override_what_the_author_wrote() { + // Merged under the templates, not over them. + let templates = TemplateSet::builtin().unwrap(); + let bundle = build( + &sound(), + &templates, + &BuildOptions { + base_export: Some(json!({ + "elections": [{"presentation": {"i18n": {"en": {"name": "Base name"}}}}], + })), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!( + bundle.export["elections"][0]["presentation"]["i18n"]["en"]["name"], + json!("Statewide Officers") + ); +} + +#[test] +fn a_base_export_with_nothing_useful_in_it_changes_nothing() { + let templates = TemplateSet::builtin().unwrap(); + let with_base = build( + &sound(), + &templates, + &BuildOptions { + base_export: Some(json!({"elections": [], "election_event": {}})), + ..BuildOptions::default() + }, + ) + .unwrap(); + assert_eq!( + serde_json::to_string(&with_base.export).unwrap(), + serde_json::to_string(&built(&sound()).export).unwrap() + ); +} + +// -- templates ------------------------------------------------------------ + +#[test] +fn an_overridden_template_is_what_gets_built() { + // The split that keeps client configuration out of the code. + let templates = TemplateSet::with_overrides(&[( + "area", + r#"{"id": "{{id}}", "name": "", "description": "", "presentation": {"allow_early_voting": "early_voting_allowed"}}"#, + )]) + .unwrap(); + let bundle = build(&sound(), &templates, &BuildOptions::default()).unwrap(); + assert_eq!( + bundle.export["areas"][0]["presentation"]["allow_early_voting"], + json!("early_voting_allowed") + ); + // And the row's own columns still win over the override. + assert_eq!(bundle.export["areas"][0]["name"], json!("North")); +} + +#[test] +fn a_template_that_renders_broken_json_is_reported_not_panicked() { + let templates = + TemplateSet::with_overrides(&[("area", "{\"oops\": }")]).unwrap(); + let report = build(&sound(), &templates, &BuildOptions::default()) + .expect_err("a broken template must refuse the build"); + assert!(has_error_saying(&report, "did not render valid JSON")); +} + +// -- shape conflicts ------------------------------------------------------ + +#[test] +fn columns_that_disagree_about_a_shape_are_reported_against_the_cell() { + let report = refused(&with_sheet( + "Elections", + vec![ + vec![ + text("external_id"), + text("presentation"), + text("presentation.i18n"), + ], + vec![text("statewide"), text("plain"), text("{}")], + ], + )); + let problem = report.errors().next().unwrap(); + assert_eq!(problem.code, Code::ConflictingColumns); + assert!(problem.path.contains("sheet 'Elections' row 2")); +} + +// -- voters --------------------------------------------------------------- + +/// The sound document with a Voters sheet added. +fn with_voters(grid: Vec>) -> Workbook { + with_sheet("Voters", grid) +} + +#[test] +fn a_voter_row_becomes_a_csv_row_the_importer_understands() { + let bundle = built(&with_voters(vec![ + vec![ + text("username"), + text("email"), + text("first_name"), + text("last_name"), + text("area.external_id"), + ], + vec![ + text("m-1001"), + text("alice@example.org"), + text("Alice"), + text("Adams"), + text("area-north"), + ], + ])); + + let voters = &bundle.voters; + let column = |name: &str| voters.column(name).expect(name); + let row = &voters.rows[0]; + + assert_eq!(row[column("username")], "m-1001"); + assert_eq!(row[column("email")], "alice@example.org"); + // The area travels as a name, because that is what the importer resolves by. + assert_eq!(row[column("area_name")], "North"); + assert!(!row[column("id")].is_empty()); +} + +#[test] +fn a_voter_with_an_address_is_treated_as_verified() { + // An unverified address blocks delivery of the one-time code, and a census + // address is one the client asserts is correct. + let bundle = built(&with_voters(vec![ + vec![text("username"), text("email"), text("area.external_id")], + vec![text("with"), text("a@example.org"), text("area-north")], + vec![text("without"), Cell::Blank, text("area-north")], + ])); + let verified = bundle.voters.column("email_verified").unwrap(); + assert_eq!(bundle.voters.rows[0][verified], "true"); + assert_eq!(bundle.voters.rows[1][verified], "false"); +} + +#[test] +fn a_voter_is_enabled_unless_the_source_says_otherwise() { + let bundle = built(&with_voters(vec![ + vec![text("username"), text("enabled"), text("area.external_id")], + vec![text("default"), Cell::Blank, text("area-north")], + vec![text("ticked"), text("x"), text("area-north")], + vec![text("off"), text("no"), text("area-north")], + ])); + let enabled = bundle.voters.column("enabled").unwrap(); + assert_eq!(bundle.voters.rows[0][enabled], "true"); + assert_eq!(bundle.voters.rows[1][enabled], "true"); + assert_eq!(bundle.voters.rows[2][enabled], "false"); +} + +#[test] +fn a_voter_with_no_election_restriction_is_authorized_for_all_of_them() { + // Writing an empty attribute would deny access to every election instead. + let bundle = built(&with_voters(vec![ + vec![text("username"), text("area.external_id")], + vec![text("m-1001"), text("area-north")], + ])); + let column = bundle.voters.column("authorized-election-ids").unwrap(); + assert_eq!( + bundle.voters.rows[0][column], + bundle.export["elections"][0]["id"].as_str().unwrap() + ); +} + +#[test] +fn a_restricted_voter_gets_the_elections_named_resolved_to_ids() { + let bundle = built(&with_voters(vec![ + vec![ + text("username"), + text("authorized-election-ids"), + text("area.external_id"), + ], + vec![text("m-1001"), text("statewide"), text("area-north")], + ])); + let column = bundle.voters.column("authorized-election-ids").unwrap(); + assert_eq!( + bundle.voters.rows[0][column], + bundle.export["elections"][0]["id"].as_str().unwrap() + ); +} + +#[test] +fn a_voter_naming_an_election_nobody_configured_is_refused() { + let report = refused(&with_voters(vec![ + vec![ + text("username"), + text("authorized-election-ids"), + text("area.external_id"), + ], + vec![text("m-1001"), text("no-such-election"), text("area-north")], + ])); + assert!(has_error_saying( + &report, + "no election has external_id 'no-such-election'" + )); +} + +#[test] +fn a_voter_needs_a_username_and_an_area() { + let report = refused(&with_voters(vec![ + vec![text("username"), text("area.external_id")], + vec![Cell::Blank, text("area-north")], + vec![text("m-1002"), Cell::Blank], + vec![text("m-1003"), text("nowhere")], + ])); + assert!(has_error_saying(&report, "a voter needs a username")); + assert!(has_error_saying(&report, "a voter needs an area")); + assert!(has_error_saying( + &report, + "no area has external_id 'nowhere'" + )); +} + +#[test] +fn two_voters_may_not_share_a_username() { + let report = refused(&with_voters(vec![ + vec![text("username"), text("area.external_id")], + vec![text("m-1001"), text("area-north")], + vec![text("m-1001"), text("area-south")], + ])); + assert!(has_error_saying(&report, "already used by row 2")); +} + +#[test] +fn an_unknown_column_is_carried_through_as_a_voter_attribute() { + // How a client adds a reporting breakout column with no code change. + let bundle = built(&with_voters(vec![ + vec![ + text("username"), + text("area.external_id"), + text("local-number"), + ], + vec![text("m-1001"), text("area-north"), text("1000")], + ])); + let column = bundle.voters.column("local-number").expect("local-number"); + assert_eq!(bundle.voters.rows[0][column], "1000"); + // And it lands after the derived columns, not among them. + assert!(column >= VOTER_LEADING_COLUMNS.len()); +} + +#[test] +fn a_passthrough_column_blank_for_every_voter_is_dropped() { + // Not cosmetic: get_copy_from_query treats the mere presence of a `password` + // header as "hash a password for each of these voters", so a blank one would + // give every voter an empty credential. + let bundle = built(&with_voters(vec![ + vec![ + text("username"), + text("area.external_id"), + text("password"), + text("local-number"), + ], + vec![ + text("m-1001"), + text("area-north"), + Cell::Blank, + text("1000"), + ], + ])); + assert!(bundle.voters.column("password").is_none()); + assert!(bundle.voters.column("local-number").is_some()); + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("password"))); +} + +#[test] +fn a_derived_column_is_kept_even_when_every_voter_leaves_it_blank() { + // The importer expects them, and an absent `email` header is not the same as + // an empty one. + let bundle = built(&with_voters(vec![ + vec![text("username"), text("area.external_id")], + vec![text("m-1001"), text("area-north")], + ])); + for column in VOTER_LEADING_COLUMNS { + assert!(bundle.voters.column(column).is_some(), "{column}"); + } +} + +#[test] +fn voters_with_no_way_to_receive_a_code_are_warned_about() { + // Not an error: credentials are sometimes distributed on paper. + let bundle = built(&with_voters(vec![ + vec![text("username"), text("email"), text("area.external_id")], + vec![text("reachable"), text("a@example.org"), text("area-north")], + vec![text("not"), Cell::Blank, text("area-north")], + ])); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("1 of 2 voters have neither an email address"))); +} + +#[test] +fn a_census_with_no_contact_column_at_all_makes_every_voter_unreachable() { + // `email` is a derived column, so it is in the table whether or not the + // source has it — which is why there is no separate "no contact column" + // case. Every voter simply has an empty one. + let bundle = built(&with_voters(vec![ + vec![text("username"), text("area.external_id")], + vec![text("m-1001"), text("area-north")], + vec![text("m-1002"), text("area-south")], + ])); + assert!(bundle.voters.column("email").is_some()); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("2 of 2 voters have neither an email address"))); +} + +#[test] +fn a_column_name_the_importer_would_reject_is_caught_before_the_upload() { + // Otherwise it fails mid-import with nothing naming the column. + let report = refused(&with_voters(vec![ + vec![ + text("username"), + text("area.external_id"), + text("home address"), + ], + vec![text("m-1001"), text("area-north"), text("1 Main St")], + ])); + assert!(has_error_saying( + &report, + "the importer rejects this column name" + )); +} + +// -- scheduled events ----------------------------------------------------- + +fn with_schedule(grid: Vec>) -> Workbook { + with_sheet("ScheduledEvents", grid) +} + +#[test] +fn two_rows_scheduling_one_processor_for_one_election_are_rejected() { + // Both the uuid5 and the task id derive from the processor and the election + // alone, so the second row would import as the first and its time would be lost. + let report = refused(&with_schedule(vec![ + vec![ + text("event_type"), + text("scheduled_datetime"), + text("election.external_id"), + ], + vec![ + text("START_VOTING_PERIOD"), + text("2027-03-01T16:00:00Z"), + text("statewide"), + ], + vec![ + text("START_VOTING_PERIOD"), + text("2027-03-02T16:00:00Z"), + text("statewide"), + ], + ])); + + assert!( + report + .errors() + .any(|problem| problem.code == Code::DuplicateId), + "expected a duplicate identity to be reported, got:\n{report}" + ); +} + +#[test] +fn a_voting_window_becomes_two_rows_of_the_scheduled_events_csv() { + let bundle = built(&with_schedule(vec![ + vec![ + text("event_type"), + text("scheduled_datetime"), + text("election.external_id"), + ], + vec![ + text("START_VOTING_PERIOD"), + text("2027-03-01T16:00:00Z"), + text("statewide"), + ], + vec![ + text("END_VOTING_PERIOD"), + text("2027-03-15T23:59:00Z"), + text("statewide"), + ], + ])); + assert_eq!(bundle.scheduled_events.len(), 2); + + let row = &bundle.scheduled_events.rows[0]; + assert_eq!(row.len(), 12); + assert_eq!(row[8], JsonField::string("START_VOTING_PERIOD")); + assert_eq!( + row[9], + JsonField::Value(json!({ + "cron": Value::Null, + "scheduled_date": "2027-03-01T16:00:00Z", + })) + ); + // A SQL NULL, written bare rather than as a quoted JSON null. + assert_eq!(row[4], JsonField::Null); +} + +#[test] +fn the_payload_names_the_election_and_the_task_id_matches_the_platform() { + // The platform looks its task up by this name; a different shape means a task + // that never fires. + let bundle = built(&with_schedule(vec![ + vec![ + text("event_type"), + text("scheduled_datetime"), + text("election.external_id"), + ], + vec![ + text("START_VOTING_PERIOD"), + text("2027-03-01T16:00:00Z"), + text("statewide"), + ], + ])); + let election_id = bundle.export["elections"][0]["id"].as_str().unwrap(); + let row = &bundle.scheduled_events.rows[0]; + + assert_eq!( + row[10], + JsonField::Value(json!({"election_id": election_id})) + ); + assert_eq!( + row[11], + JsonField::string(format!( + "tenant_{}_event_{}_election_{election_id}_START_VOTING_PERIOD", + bundle.tenant_id, bundle.event_id + )) + ); +} + +#[test] +fn an_event_wide_schedule_leaves_the_election_out_of_both() { + let bundle = built(&with_schedule(vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("START_VOTING_PERIOD"), text("2027-03-01T16:00:00Z")], + ])); + let row = &bundle.scheduled_events.rows[0]; + assert_eq!(row[10], JsonField::Value(json!({"election_id": null}))); + assert_eq!( + row[11], + JsonField::string(format!( + "tenant_{}_event_{}_START_VOTING_PERIOD", + bundle.tenant_id, bundle.event_id + )) + ); +} + +#[test] +fn an_author_may_write_an_event_type_the_way_they_speak_it() { + let bundle = built(&with_schedule(vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("start voting period"), text("2027-03-01T16:00:00Z")], + vec![text("end-voting-period"), text("2027-03-15T23:59:00Z")], + ])); + assert_eq!( + bundle.scheduled_events.rows[0][8], + JsonField::string("START_VOTING_PERIOD") + ); + assert_eq!( + bundle.scheduled_events.rows[1][8], + JsonField::string("END_VOTING_PERIOD") + ); +} + +#[test] +fn an_event_type_nothing_processes_is_refused_with_the_list() { + let report = refused(&with_schedule(vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("OPEN_THE_POLLS"), text("2027-03-01T16:00:00Z")], + ])); + assert!(has_error_saying(&report, "is not an event processor")); + assert!(has_error_saying(&report, "START_VOTING_PERIOD")); +} + +#[test] +fn a_scheduled_event_with_no_time_is_refused() { + let report = refused(&with_schedule(vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("START_VOTING_PERIOD"), Cell::Blank], + ])); + assert!(has_error_saying(&report, "needs a scheduled_datetime")); +} + +#[test] +fn an_event_name_is_kept_as_an_annotation_so_the_csv_reads_like_the_source() { + let bundle = built(&with_schedule(vec![ + vec![ + text("event_name"), + text("event_type"), + text("scheduled_datetime"), + ], + vec![ + text("Polls open"), + text("START_VOTING_PERIOD"), + text("2027-03-01T16:00:00Z"), + ], + ])); + assert_eq!( + bundle.scheduled_events.rows[0][7], + JsonField::Value(json!({"janitor.event_name": "Polls open"})) + ); +} + +#[test] +fn an_election_whose_window_never_opens_is_warned_about() { + // It imports fine and then quietly never opens. + let bundle = built(&with_schedule(vec![ + vec![ + text("event_type"), + text("scheduled_datetime"), + text("election.external_id"), + ], + vec![ + text("START_VOTING_PERIOD"), + text("2027-03-01T16:00:00Z"), + text("statewide"), + ], + ])); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("no END_VOTING_PERIOD scheduled event"))); +} + +#[test] +fn an_event_wide_window_covers_every_election() { + let bundle = built(&with_schedule(vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("START_VOTING_PERIOD"), text("2027-03-01T16:00:00Z")], + vec![text("END_VOTING_PERIOD"), text("2027-03-15T23:59:00Z")], + ])); + assert!( + !bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("voting period will not")), + "{}", + bundle.warnings + ); +} + +#[test] +fn a_document_with_no_schedule_at_all_says_it_will_need_hands() { + let bundle = built(&sound()); + assert!(bundle.scheduled_events.is_empty()); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("by hand in the Admin Portal"))); +} + +// -- reports -------------------------------------------------------------- + +#[test] +fn no_reports_sheet_means_no_reports_member() { + // Absent and empty are the same thing, and an empty CSV is not a valid one. + assert!(built(&sound()).reports.is_none()); +} + +#[test] +fn a_report_row_becomes_a_positional_csv_row() { + let bundle = built(&with_sheet( + "Reports", + vec![ + vec![ + text("report_type"), + text("election.external_id"), + text("encryption_policy"), + text("permission_label"), + ], + vec![ + text("tally"), + text("statewide"), + text("configured_password"), + text("statewide-officers || auditors"), + ], + ], + )); + let reports = bundle.reports.expect("a reports table"); + let row = &reports.rows[0]; + + assert_eq!(row.len(), 8); + assert_eq!( + row[1], + bundle.export["elections"][0]["id"].as_str().unwrap() + ); + assert_eq!(row[2], "tally"); + // Read from the row, not from the template's default: this is the field that + // silently became `unencrypted` once. + assert_eq!(row[5], "configured_password"); + // Option>, split on "|" by process_reports_file. + assert_eq!(row[7], "statewide-officers|auditors"); +} + +#[test] +fn a_report_with_no_policy_falls_back_to_unencrypted() { + let bundle = built(&with_sheet( + "Reports", + vec![vec![text("report_type")], vec![text("tally")]], + )); + let reports = bundle.reports.expect("a reports table"); + assert_eq!(reports.rows[0][5], "unencrypted"); +} + +#[test] +fn a_report_needs_a_type() { + let report = refused(&with_sheet( + "Reports", + vec![ + vec![text("report_type"), text("election.external_id")], + vec![Cell::Blank, text("statewide")], + ], + )); + assert!(has_error_saying(&report, "a report needs a report_type")); +} + +#[test] +fn a_report_naming_a_template_nobody_defined_is_refused() { + let report = refused(&with_sheet( + "Reports", + vec![ + vec![text("report_type"), text("template.alias")], + vec![text("tally"), text("no-such-template")], + ], + )); + assert!(has_error_saying( + &report, + "no Templates row has alias 'no-such-template'" + )); +} + +#[test] +fn a_report_password_is_flagged_as_a_secret() { + let mut sheets: Vec = sound().sheets().to_vec(); + sheets.push( + Sheet::from_grid( + "Reports", + &[ + vec![text("report_type"), text("password")], + vec![text("tally"), text("s3cret")], + ], + ) + .unwrap(), + ); + let bundle = built(&Workbook::new(sheets).unwrap()); + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("clear text"))); +} + +// -- admin users, permissions, templates ---------------------------------- + +#[test] +fn admin_users_keep_their_own_columns() { + // Not an event-import member: the sheet is the shape, whatever it holds. + let bundle = built(&with_sheet( + "Admin Users", + vec![ + vec![text("username"), text("email"), text("permission_labels")], + vec![ + text("admin1"), + text("admin@example.org"), + text("statewide-officers || auditors"), + ], + ], + )); + let admins = bundle.admin_users.expect("an admin users table"); + assert_eq!(admins.columns, ["username", "email", "permission_labels"]); + // "||" in, "|" out. + assert_eq!(admins.rows[0][2], "statewide-officers|auditors"); +} + +#[test] +fn an_admin_user_needs_a_username() { + let report = refused(&with_sheet( + "Admin Users", + vec![ + vec![text("username"), text("email")], + vec![Cell::Blank, text("a@b.c")], + ], + )); + assert!(has_error_saying(&report, "an admin user needs a username")); +} + +#[test] +fn admin_passwords_are_flagged_once_however_many_rows_carry_them() { + let bundle = built(&with_sheet( + "Admin Users", + vec![ + vec![text("username"), text("password")], + vec![text("admin1"), text("s3cret")], + vec![text("admin2"), text("s3cret2")], + ], + )); + assert_eq!( + bundle + .warnings + .warnings() + .filter(|problem| problem.message.contains("clear-text passwords")) + .count(), + 1 + ); +} + +#[test] +fn the_permission_matrix_is_transposed_into_the_platform_shape() { + // A matrix is what a human can check at a glance; role,permissions is what + // export_tenant_config.rs writes. + let bundle = built(&with_sheet( + "Permissions", + vec![ + vec![text("permission"), text("admin"), text("auditor")], + vec![text("election:read"), text("x"), text("x")], + vec![text("election:write"), text("x"), Cell::Blank], + ], + )); + let permissions = bundle.role_permissions.expect("a permissions table"); + assert_eq!(permissions.columns, ["role", "permissions"]); + assert_eq!( + permissions.rows, + vec![ + vec![ + "admin".to_string(), + "election:read|election:write".to_string() + ], + vec!["auditor".to_string(), "election:read".to_string()], + ] + ); +} + +#[test] +fn a_permission_matrix_with_no_roles_says_what_it_expected() { + let report = refused(&with_sheet( + "Permissions", + vec![vec![text("permission")], vec![text("election:read")]], + )); + assert!(has_error_saying(&report, "has no role columns")); +} + +#[test] +fn a_template_becomes_a_file_beside_the_bundle() { + // The event zip has no member for communication templates. + let bundle = built(&with_sheet( + "Templates", + vec![ + vec![ + text("name"), + text("alias"), + text("type"), + text("communication_method"), + text("template.document"), + ], + vec![ + text("Voter Credentials"), + text("voter_credentials"), + text("VOTER_CREDENTIALS"), + text("EMAIL"), + text(r"Dear {{name}},\n\nYour code is {{code}}."), + ], + ], + )); + assert_eq!(bundle.templates.len(), 1); + let template = &bundle.templates[0]; + assert_eq!(template.alias, "voter_credentials"); + assert_eq!(template.name, "Voter Credentials"); + assert_eq!(template.template_type.as_deref(), Some("VOTER_CREDENTIALS")); + assert_eq!(template.communication_method.as_deref(), Some("EMAIL")); + // The literal \n a copy-paste out of a JSON export leaves behind. + assert_eq!( + template.document, + "Dear {{name}},\n\nYour code is {{code}}." + ); + assert_eq!(template.file_name(), "voter_credentials.hbs"); +} + +#[test] +fn a_template_falls_back_to_its_name_when_it_has_no_alias() { + let bundle = built(&with_sheet( + "Templates", + vec![ + vec![text("name"), text("template.document")], + vec![text("Reminder"), text("hello")], + ], + )); + assert_eq!(bundle.templates[0].alias, "Reminder"); + assert_eq!(bundle.templates[0].name, "Reminder"); +} + +#[test] +fn a_template_needs_a_name_and_a_document() { + let report = refused(&with_sheet( + "Templates", + vec![ + vec![text("name"), text("alias"), text("template.document")], + vec![Cell::Blank, Cell::Blank, text("orphan")], + vec![text("No document"), text("nodoc"), Cell::Blank], + ], + )); + assert!(has_error_saying(&report, "needs a name or an alias")); + assert!(has_error_saying(&report, "a template needs a document")); +} + +#[test] +fn two_templates_may_not_share_an_alias() { + let report = refused(&with_sheet( + "Templates", + vec![ + vec![text("alias"), text("template.document")], + vec![text("otp"), text("one")], + vec![text("otp"), text("two")], + ], + )); + assert!(has_error_saying(&report, "already used by row 2")); +} + +// -- everything together -------------------------------------------------- + +#[test] +fn a_full_document_builds_every_member_and_still_validates() { + // The whole surface at once, because the members share resolved ids and a + // mistake in one shows up in another. + let mut sheets: Vec = sound().sheets().to_vec(); + for (name, grid) in [ + ( + "Voters", + vec![ + vec![ + text("username"), + text("email"), + text("area.external_id"), + text("local-number"), + ], + vec![ + text("m-1001"), + text("alice@example.org"), + text("area-north"), + text("1000"), + ], + vec![ + text("m-1002"), + text("bob@example.org"), + text("area-south"), + text("2000"), + ], + ], + ), + ( + "ScheduledEvents", + vec![ + vec![text("event_type"), text("scheduled_datetime")], + vec![text("START_VOTING_PERIOD"), text("2027-03-01T16:00:00Z")], + vec![text("END_VOTING_PERIOD"), text("2027-03-15T23:59:00Z")], + ], + ), + ( + "Templates", + vec![ + vec![text("alias"), text("template.document")], + vec![text("tally_report"), text("Results for {{election}}")], + ], + ), + ( + "Reports", + vec![ + vec![text("report_type"), text("template.alias")], + vec![text("tally"), text("tally_report")], + ], + ), + ( + "Admin Users", + vec![ + vec![text("username"), text("permission_labels")], + vec![text("admin1"), text("statewide-officers")], + ], + ), + ( + "Permissions", + vec![ + vec![text("permission"), text("admin")], + vec![text("election:read"), text("x")], + ], + ), + ] { + sheets.push(Sheet::from_grid(name, &grid).unwrap()); + } + + let bundle = built(&Workbook::new(sheets).unwrap()); + + assert_eq!(bundle.voters.len(), 2); + assert_eq!(bundle.scheduled_events.len(), 2); + assert_eq!(bundle.reports.as_ref().map(PlainTable::len), Some(1)); + assert_eq!(bundle.admin_users.as_ref().map(PlainTable::len), Some(1)); + assert_eq!( + bundle.role_permissions.as_ref().map(PlainTable::len), + Some(1) + ); + assert_eq!(bundle.templates.len(), 1); + + // And the JSON document is still one the platform accepts. + let schema: crate::election_config::ImportElectionEventSchema = + serde_json::from_value(bundle.export.clone()).unwrap(); + let report = crate::election_config::validate(&schema); + assert!(!report.has_errors(), "{report}"); +} + +#[test] +fn the_csv_members_render_through_the_shared_writers() { + // The tables exist to be written by emit, so the join has to hold. + use crate::election_config::emit::{json_csv, plain_csv}; + + let bundle = built(&with_voters(vec![ + vec![text("username"), text("email"), text("area.external_id")], + vec![ + text("m-1001"), + text("alice@example.org"), + text("area-north"), + ], + ])); + + let columns: Vec<&str> = + bundle.voters.columns.iter().map(String::as_str).collect(); + let rendered = plain_csv(&columns, &bundle.voters.rows); + assert!(rendered.starts_with("id,email,email_verified")); + assert!(rendered.contains("alice@example.org")); + assert!(rendered.ends_with('\n')); + + let schedule_columns: Vec<&str> = bundle + .scheduled_events + .columns + .iter() + .map(String::as_str) + .collect(); + let schedule = json_csv(&schedule_columns, &bundle.scheduled_events.rows); + assert!(schedule.starts_with("id,tenant_id,election_event_id")); +} + +// -- the realm ------------------------------------------------------------ + +/// A realm with the pieces the presets expect, small enough to read. +fn base_realm() -> Value { + json!({ + "realm": "some-other-realm", + "id": "99999999-9999-4999-8999-999999999999", + "identityProviders": [{"alias": "environment-idp", "enabled": true}], + "authenticationFlows": [{ + "alias": "browser", + "authenticationExecutions": [ + {"authenticator": "message-otp-authenticator"}, + ], + }, { + "alias": "saml-first-broker-flow", + "authenticationExecutions": [], + }], + "authenticatorConfig": [{"alias": "deferred", "config": {}}], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [{ + "config": {"kc.user.profile.config": [ + r#"{"attributes":[{"name":"username"},{"name":"dateOfBirth"}]}"# + ]}, + }], + }, + "clients": [{ + "clientId": "voting-portal", + "rootUrl": "https://vote.example.org/99999999-9999-4999-8999-999999999999", + }], + }) +} + +fn with_options(workbook: &Workbook, options: BuildOptions) -> Bundle { + let templates = TemplateSet::builtin().unwrap(); + match build(workbook, &templates, &options) { + Ok(bundle) => bundle, + Err(report) => panic!("expected a clean build, got:\n{report}"), + } +} + +#[test] +fn no_base_export_means_no_realm_at_all() { + // The importer takes keycloak_event_realm wholesale, so a realm invented here + // would replace the environment's provisioned default rather than merge into + // it. Emitting none is the safe answer. + let bundle = built(&sound()); + assert_eq!(bundle.export["keycloak_event_realm"], Value::Null); +} + +#[test] +fn what_the_document_asked_of_the_realm_is_kept_even_with_no_realm_to_apply_it_to( +) { + // Otherwise an auth_type or a login stylesheet is silently lost. + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("otp_email_or_sms"), + ], + ], + )); + assert_eq!(bundle.auth_preset, Some("otp_email_or_sms")); + assert!(!bundle.realm_patch.patch.is_empty()); + assert!(bundle.realm_patch.bind_authenticator_config.is_some()); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains( + "no base export, so nothing here configures the login page" + ))); +} + +#[test] +fn the_realms_name_is_derived_from_the_tenant_and_the_event() { + // Structural: the voting portal and the smart-link URLs derive it the same + // way, so it is not a free choice. + let bundle = with_options( + &sound(), + BuildOptions { + base_export: Some(json!({"keycloak_event_realm": base_realm()})), + ..BuildOptions::default() + }, + ); + let realm = &bundle.export["keycloak_event_realm"]; + assert_eq!( + realm["realm"], + json!(format!( + "tenant-{}-event-{}", + bundle.tenant_id, bundle.event_id + )) + ); + assert_eq!(realm["id"], json!(bundle.event_id)); +} + +#[test] +fn a_stale_event_id_in_the_realms_urls_is_swapped_for_this_events() { + // Hosts belong to the environment and stay, but the base export embeds its own + // event id in those URLs, and import remaps every UUID it finds — so a stale + // one would be remapped to something unrelated. + let bundle = with_options( + &sound(), + BuildOptions { + base_export: Some(json!({ + "keycloak_event_realm": base_realm(), + "election_event": {"id": "99999999-9999-4999-8999-999999999999"}, + })), + ..BuildOptions::default() + }, + ); + let root = bundle.export["keycloak_event_realm"]["clients"][0]["rootUrl"] + .as_str() + .unwrap(); + assert!(root.starts_with("https://vote.example.org/"), "{root}"); + assert!(root.ends_with(&bundle.event_id), "{root}"); + assert!(!root.contains("99999999-9999-4999-8999-999999999999")); +} + +#[test] +fn a_preset_adds_its_provider_without_removing_the_environments() { + // identityProviders is referenced by alias from elsewhere in the realm; + // replacing the list would strip what the environment configured on purpose. + let workbook = with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("saml_sso_idp_initiated"), + ], + vec![ + text("settings"), + text("saml_idp_metadata_url"), + text("https://idp.example.org/metadata"), + ], + ], + ); + let bundle = with_options( + &workbook, + BuildOptions { + base_export: Some(json!({"keycloak_event_realm": base_realm()})), + ..BuildOptions::default() + }, + ); + + let providers = bundle.export["keycloak_event_realm"]["identityProviders"] + .as_array() + .unwrap(); + let aliases: Vec<&str> = providers + .iter() + .map(|provider| provider["alias"].as_str().unwrap()) + .collect(); + assert_eq!(aliases, ["environment-idp", "client-saml-idp"]); +} + +#[test] +fn the_otp_preset_binds_its_config_to_the_authenticator_in_the_realm() { + // Registering the config without binding it leaves the step unconfigured, and + // nothing about the realm would say so. + let workbook = with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("otp_email_or_sms"), + ], + ], + ); + let bundle = with_options( + &workbook, + BuildOptions { + base_export: Some(json!({"keycloak_event_realm": base_realm()})), + ..BuildOptions::default() + }, + ); + + let realm = &bundle.export["keycloak_event_realm"]; + assert_eq!( + realm["authenticationFlows"][0]["authenticationExecutions"][0] + ["authenticatorConfig"], + json!("janitor-otp-by-availability") + ); + // And the config it points at is present alongside the realm's own. + let aliases: Vec<&str> = realm["authenticatorConfig"] + .as_array() + .unwrap() + .iter() + .map(|config| config["alias"].as_str().unwrap()) + .collect(); + assert!(aliases.contains(&"deferred")); + assert!(aliases.contains(&"janitor-otp-by-availability")); +} + +#[test] +fn the_link_preset_patches_the_user_profile_inside_its_stringified_blob() { + // It lives inside a Keycloak component as a single JSON string, so it has to + // be parsed, patched and re-serialised rather than merged. + let workbook = with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("voter_link_plus_dob"), + ], + ], + ); + let bundle = with_options( + &workbook, + BuildOptions { + base_export: Some(json!({"keycloak_event_realm": base_realm()})), + ..BuildOptions::default() + }, + ); + + let raw = bundle.export["keycloak_event_realm"]["components"] + ["org.keycloak.userprofile.UserProfileProvider"][0]["config"] + ["kc.user.profile.config"][0] + .as_str() + .unwrap(); + let profile: Value = serde_json::from_str(raw).unwrap(); + let attributes = profile["attributes"].as_array().unwrap(); + + let date_of_birth = attributes + .iter() + .find(|attribute| attribute["name"] == json!("dateOfBirth")) + .unwrap(); + assert_eq!( + date_of_birth["annotations"]["loginHintPrefillPolicy"], + json!("IGNORE") + ); + let username = attributes + .iter() + .find(|attribute| attribute["name"] == json!("username")) + .unwrap(); + assert_eq!( + username["annotations"]["loginHintPrefillPolicy"], + json!("READ_ONLY") + ); +} + +#[test] +fn a_preset_whose_flow_the_realm_lacks_is_warned_about_rather_than_applied_blindly( +) { + let workbook = with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("digital_certificates"), + ], + ], + ); + let bundle = with_options( + &workbook, + BuildOptions { + base_export: Some(json!({"keycloak_event_realm": base_realm()})), + ..BuildOptions::default() + }, + ); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("has no flow 'certificate-first-login-flow'"))); +} + +#[test] +fn a_preset_missing_a_required_parameter_refuses_the_build() { + // The SEIU document's own case: it declares SAML and leaves the IdP metadata + // URL blank pending the client's identity provider. + let report = refused(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("saml_sso_idp_initiated"), + ], + ], + )); + assert!(has_error_saying( + &report, + "needs a 'saml_idp_metadata_url' parameter" + )); +} + +#[test] +fn selecting_the_none_preset_ignores_what_the_document_declares() { + // Which is how a document declaring SAML still builds while the client has not + // supplied their metadata URL. + let workbook = with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("saml_sso_idp_initiated"), + ], + ], + ); + let bundle = with_options( + &workbook, + BuildOptions { + auth_preset: Some("none".to_string()), + ..BuildOptions::default() + }, + ); + assert_eq!(bundle.auth_preset, None); + assert!(bundle.realm_patch.bind_authenticator_config.is_none()); +} + +#[test] +fn a_preset_nobody_wrote_is_refused_with_the_list() { + let report = refused(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![text("settings"), text("auth_type"), text("magic_link")], + ], + )); + assert!(has_error_saying(&report, "is not an authentication preset")); + assert!(has_error_saying(&report, "otp_email_or_sms")); +} + +#[test] +fn a_preset_that_authenticates_the_voter_elsewhere_stops_asking_for_contacts() { + // Under SAML the client's IdP authenticates the voter, so "no voter can be + // sent a code" is noise rather than a finding. + let mut sheets: Vec = sound().sheets().to_vec(); + sheets.push( + Sheet::from_grid( + "Voters", + &[ + vec![text("username"), text("area.external_id")], + vec![text("m-1001"), text("area-north")], + ], + ) + .unwrap(), + ); + sheets.push( + Sheet::from_grid( + "Parameters", + &[ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("saml_sso_idp_initiated"), + ], + vec![ + text("settings"), + text("saml_idp_metadata_url"), + text("https://idp.example.org/metadata"), + ], + ], + ) + .unwrap(), + ); + let bundle = built(&Workbook::new(sheets).unwrap()); + assert!( + !bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("one-time code")), + "{}", + bundle.warnings + ); +} + +#[test] +fn the_event_languages_become_the_login_pages() { + // The platform never syncs supportedLocales, so this is the only thing that + // puts a language in Keycloak's picker. + let bundle = built(&with_sheet( + "ElectionEvent", + vec![ + vec![ + text("external_id"), + text("presentation.i18n.en.name"), + text("presentation.language_conf.enabled_language_codes"), + text("presentation.language_conf.default_language_code"), + ], + vec![ + text("union-2027"), + text("Union Election 2027"), + text(r#"["eng", "spa"]"#), + text("spa"), + ], + ], + )); + assert_eq!( + bundle.realm_patch.patch["supportedLocales"], + json!(["en", "es"]) + ); + assert_eq!(bundle.realm_patch.patch["defaultLocale"], json!("es")); + assert_eq!( + bundle.realm_patch.patch["internationalizationEnabled"], + json!(true) + ); +} + +#[test] +fn the_event_title_becomes_the_realms_display_name() { + // Otherwise every client's voters see "Election Event" above the login form. + let bundle = built(&sound()); + assert_eq!( + bundle.realm_patch.patch["displayName"], + json!("Union Election 2027") + ); +} + +#[test] +fn login_css_reaches_every_enabled_language_escaped_for_message_format() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("login_custom_css"), + text(".logo { display: none; }"), + ], + ], + )); + let texts = &bundle.realm_patch.patch["localizationTexts"]; + assert_eq!( + texts["en"]["loginCustomCss"], + json!(".logo '{' display: none; '}'") + ); +} + +#[test] +fn an_explicit_realm_parameter_wins_over_anything_derived() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("keycloak_event_realm.displayName"), + text("Chosen By Hand"), + ], + ], + )); + assert_eq!( + bundle.realm_patch.patch["displayName"], + json!("Chosen By Hand") + ); +} + +#[test] +fn admin_realm_parameters_travel_separately_because_they_are_tenant_scoped() { + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("keycloak_admin_realm.smtpServer.host"), + text("smtp.example.org"), + ], + ], + )); + assert_eq!( + bundle.admin_realm_patch["smtpServer"]["host"], + json!("smtp.example.org") + ); + // And not into the event's realm patch. + assert!(bundle.realm_patch.patch.get("smtpServer").is_none()); +} + +#[test] +fn a_parameter_a_preset_consumes_is_not_also_reported_as_uninterpreted() { + // Reporting it as ignored while a preset acts on it contradicts itself. + let bundle = built(&with_sheet( + "Parameters", + vec![ + vec![text("type"), text("key"), text("value")], + vec![ + text("settings"), + text("auth_type"), + text("otp_email_or_sms"), + ], + vec![text("settings"), text("otp_length"), Cell::Int(8)], + ], + )); + // Nothing was carried, so annotations stays whatever the template said — + // null, not an object with the preset's own parameters in it. + let annotations = &bundle.export["election_event"]["annotations"]; + let carried: Vec<&String> = annotations + .as_object() + .map(|annotations| annotations.keys().collect()) + .unwrap_or_default(); + assert!( + carried.is_empty(), + "a preset's own parameters were carried as uninterpreted: {carried:?}" + ); +} + +// -- permission labels ---------------------------------------------------- + +#[test] +fn a_permission_label_no_administrator_holds_is_warned_about() { + // The failure this guards against is quiet and expensive: the event imports + // cleanly and the Elections list is empty. It happened on the first real + // import, where a document labelled an election 'dlc-officers-dburs' while its + // own administrators carried 'dlc-officers'. + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "Elections") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "Elections", + &[ + vec![text("external_id"), text("permission_label")], + vec![text("statewide"), text("dlc-officers-dburs")], + ], + ) + .unwrap(), + ); + sheets.push( + Sheet::from_grid( + "Admin Users", + &[ + vec![text("username"), text("permission_labels")], + vec![text("admin1"), text("dlc-officers")], + ], + ) + .unwrap(), + ); + + let bundle = built(&Workbook::new(sheets).unwrap()); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("no administrator in the Admin Users sheet carries it"))); + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("permission labels in use"))); +} + +#[test] +fn a_label_an_administrator_does_hold_is_only_noted_not_flagged() { + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "Elections") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "Elections", + &[ + vec![text("external_id"), text("permission_label")], + vec![text("statewide"), text("statewide-officers")], + ], + ) + .unwrap(), + ); + sheets.push( + Sheet::from_grid( + "Admin Users", + &[ + vec![text("username"), text("permission_labels")], + vec![text("admin1"), text("statewide-officers")], + ], + ) + .unwrap(), + ); + + let bundle = built(&Workbook::new(sheets).unwrap()); + assert!(!bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("carries it"))); + // Whoever imports still needs the label on their own attribute. + assert!(bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("permission labels in use"))); +} + +#[test] +fn a_document_that_grants_no_labels_at_all_says_so_differently() { + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "Elections") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "Elections", + &[ + vec![text("external_id"), text("permission_label")], + vec![text("statewide"), text("statewide-officers")], + ], + ) + .unwrap(), + ); + let bundle = built(&Workbook::new(sheets).unwrap()); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("grants no permission labels to anyone"))); +} + +#[test] +fn a_document_with_no_labels_says_nothing_about_them() { + let bundle = built(&sound()); + assert!(!bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("permission label"))); +} + +// -- inherited branding --------------------------------------------------- + +#[test] +fn voter_facing_copy_inherited_from_a_base_export_is_named() { + // Useful when the base is a reference event and wrong when it is another + // client's: their login title and instruction copy would come along silently. + let bundle = with_options( + &sound(), + BuildOptions { + base_export: Some(json!({ + "election_event": { + "presentation": { + "i18n": {"en": { + "login_instructions": "Ring the other client's helpdesk", + }}, + "theme": "other-client", + }, + }, + })), + ..BuildOptions::default() + }, + ); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("presentation.i18n.en.login_instructions"))); + assert!(bundle.warnings.warnings().any(|problem| problem + .message + .contains("presentation settings inherited from the base export"))); +} + +#[test] +fn copy_the_document_sets_itself_is_not_reported_as_inherited() { + let mut sheets: Vec = sound() + .sheets() + .iter() + .filter(|sheet| sheet.name != "ElectionEvent") + .cloned() + .collect(); + sheets.push( + Sheet::from_grid( + "ElectionEvent", + &[ + vec![ + text("external_id"), + text("presentation.i18n.en.name"), + text("presentation.i18n.en.login_instructions"), + ], + vec![ + text("union-2027"), + text("Union Election 2027"), + text("Ring our helpdesk"), + ], + ], + ) + .unwrap(), + ); + + let bundle = with_options( + &Workbook::new(sheets).unwrap(), + BuildOptions { + base_export: Some(json!({ + "election_event": {"presentation": {"i18n": {"en": { + "login_instructions": "Ring the other client's helpdesk", + }}}}, + })), + ..BuildOptions::default() + }, + ); + assert!( + !bundle + .warnings + .warnings() + .any(|problem| problem.message.contains("login_instructions")), + "{}", + bundle.warnings + ); + assert_eq!( + bundle.export["election_event"]["presentation"]["i18n"]["en"] + ["login_instructions"], + json!("Ring our helpdesk") + ); +} diff --git a/packages/sequent-core/src/election_config/emit.rs b/packages/sequent-core/src/election_config/emit.rs new file mode 100644 index 00000000000..ff44a010f4c --- /dev/null +++ b/packages/sequent-core/src/election_config/emit.rs @@ -0,0 +1,478 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Writing the files an election event import is made of. +//! +//! The zip the Admin Portal accepts holds one JSON document and up to three +//! CSVs, and two of those CSVs are read **positionally** by the importer, with a +//! byte shape that is easy to get subtly wrong. Before this module that shape was +//! implemented three times — in windmill's exporter, in janitor's Python, and in +//! the Election Architect's TypeScript — and the copies had drifted. +//! +//! Pure, like the rest of this module: these functions return strings and byte +//! vectors. Nothing here touches the filesystem, so the same code writes a file +//! from `step-cli` and offers a download from a browser. + +use serde::Serialize; +use serde_json::Value; + +/// Separator for multi-valued Keycloak user attributes. +/// +/// A single pipe, matching +/// `crate::services::keycloak::user::MULTIVALUE_USER_ATTRIBUTE_SEPARATOR`. The +/// import workbooks use `||`, so whatever reads one has to convert; a `||` +/// reaching a CSV is read as one value containing an empty one. +pub const MULTI_VALUE_SEPARATOR: &str = "|"; + +/// Columns of `export_scheduled_events-.csv`, in order. +/// +/// `import_scheduled_events.rs` reads this file by index — `record.get(10)` for +/// the payload — so the order is part of the format, not a presentation choice. +pub const SCHEDULED_EVENT_COLUMNS: &[&str] = &[ + "id", + "tenant_id", + "election_event_id", + "created_at", + "stopped_at", + "archived_at", + "labels", + "annotations", + "event_processor", + "cron_config", + "event_payload", + "task_id", +]; + +/// Columns of `export_reports-.csv`, in order. +/// +/// Likewise positional: `process_reports_file` reads `election_id` at index 1 and +/// `permission_label` at index 7. +pub const REPORT_COLUMNS: &[&str] = &[ + "id", + "election_id", + "report_type", + "template_alias", + "cron_config", + "encryption_policy", + "password", + "permission_label", +]; + +/// One field of a JSON-in-CSV file. +/// +/// The distinction between the two variants is the whole reason this type exists: +/// a column with no value at all is written bare, while a column holding the JSON +/// value `null` is written quoted. Collapsing them would make an empty column +/// indistinguishable from one containing null. +#[derive(Debug, Clone, PartialEq)] +pub enum JsonField { + /// A SQL NULL: the column has no value. Written as a bare, unquoted `null`. + Null, + /// A JSON value, written through JSON encoding and then CSV-quoted. + Value(Value), +} + +impl JsonField { + pub fn string(value: impl Into) -> Self { + JsonField::Value(Value::String(value.into())) + } + + pub fn json(value: &T) -> Result { + Ok(JsonField::Value(serde_json::to_value(value)?)) + } +} + +/// Render a JSON-in-CSV file, the shape the platform's own exporter writes. +/// +/// Every field holds a JSON literal which is then CSV-quoted on top, so a string +/// ends up wrapped in three double quotes — one from JSON, doubled by CSV +/// escaping, inside the CSV's own pair — while a SQL NULL is written bare and +/// unquoted: +/// +/// ```text +/// id,labels,cron_config +/// """2c978b94-…""",null,"{""cron"":null,""scheduled_date"":""2026-10-24T16:15:00.000Z""}" +/// ``` +/// +/// It looks wrong and is not. The importer parses each field with +/// `deserialize_str` after CSV decoding, so the JSON layer is load-bearing. +/// +/// Line endings are `\n`: `\r\n` is what the `csv` crate defaults to and the Rust +/// reader accepts either, but a file with Windows endings diffs badly in review +/// for no benefit. +pub fn json_csv(columns: &[&str], rows: &[Vec]) -> String { + let mut out = String::new(); + out.push_str(&columns.join(",")); + out.push('\n'); + + for row in rows { + let fields: Vec = row.iter().map(json_csv_field).collect(); + out.push_str(&fields.join(",")); + out.push('\n'); + } + out +} + +fn json_csv_field(field: &JsonField) -> String { + match field { + JsonField::Null => "null".to_string(), + JsonField::Value(value) => { + let encoded = serde_json::to_string(value) + .unwrap_or_else(|_| "null".to_string()); + format!("\"{}\"", encoded.replace('"', "\"\"")) + } + } +} + +/// Render an ordinary CSV: comma-separated, minimally quoted, `\n` endings. +/// +/// Used for `export_voters` and `export_reports`, which hold plain values rather +/// than JSON literals. +pub fn plain_csv(columns: &[&str], rows: &[Vec]) -> String { + let mut out = String::new(); + out.push_str(&join_csv(columns.iter().map(|column| column.to_string()))); + out.push('\n'); + for row in rows { + out.push_str(&join_csv(row.iter().cloned())); + out.push('\n'); + } + out +} + +fn join_csv(values: impl Iterator) -> String { + let raw: Vec = values.collect(); + + // A row consisting of one empty field is the only case where emptiness needs + // quoting: unquoted it is a blank line, which a reader cannot tell from no row + // at all. An empty field beside others is unambiguous and stays bare — which + // is also what Python's csv writer does, and these files have to stay + // byte-identical to what janitor already produces. + if raw.len() == 1 && raw[0].is_empty() { + return "\"\"".to_string(); + } + + raw.into_iter() + .map(escape_csv) + .collect::>() + .join(",") +} + +/// Quote a CSV field only when its content requires it. +fn escape_csv(value: String) -> String { + let needs_quoting = value.contains(',') + || value.contains('"') + || value.contains('\n') + || value.contains('\r'); + + if needs_quoting { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value + } +} + +/// The member name the importer looks for, for each part of a bundle. +/// +/// `import_election_event.rs` dispatches on these prefixes, so a file named +/// anything else is silently ignored rather than rejected. +pub mod member { + pub const ELECTION_EVENT: &str = "export_election_event"; + pub const VOTERS: &str = "export_voters"; + pub const SCHEDULED_EVENTS: &str = "export_scheduled_events"; + pub const REPORTS: &str = "export_reports"; + + /// `export_voters-.csv` and friends. + pub fn file_name(prefix: &str, event_id: &str, extension: &str) -> String { + format!("{prefix}-{event_id}.{extension}") + } +} + +/// Join multi-valued attribute values the way the importer splits them. +pub fn join_multi_value(values: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + values + .into_iter() + .map(|value| value.as_ref().to_string()) + .collect::>() + .join(MULTI_VALUE_SEPARATOR) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::scheduled_event::{ + generate_manage_date_task_name, EventProcessors, + }; + use serde_json::json; + + // -- the JSON-in-CSV byte shape ---------------------------------------- + + #[test] + fn it_matches_a_real_export_byte_for_byte() { + // Taken from a platform export. This is the assertion that makes the + // three previous implementations redundant. + let rows = vec![vec![ + JsonField::string("2c978b94-f167-59de-aee7-dbb6d8a1b913"), + JsonField::Null, + JsonField::Value(json!({ + "cron": null, + "scheduled_date": "2026-10-24T16:15:00.000Z" + })), + JsonField::Value(json!({"election_id": null})), + ]]; + let rendered = + json_csv(&["id", "labels", "cron_config", "event_payload"], &rows); + + assert_eq!( + rendered, + concat!( + "id,labels,cron_config,event_payload\n", + "\"\"\"2c978b94-f167-59de-aee7-dbb6d8a1b913\"\"\",", + "null,", + "\"{\"\"cron\"\":null,\"\"scheduled_date\"\":\"\"2026-10-24T16:15:00.000Z\"\"}\",", + "\"{\"\"election_id\"\":null}\"\n" + ) + ); + } + + #[test] + fn it_matches_what_janitor_already_writes() { + // The property that justifies replacing three implementations with one: + // this row is copied verbatim from the file janitor's Python emitted for + // the SEIU1000 event. If Rust and Python disagree by a byte, the tools do + // not actually share a format and the unification is cosmetic. + const FROM_JANITOR: &str = concat!( + "\"\"\"543e5b91-b725-5c8d-97f8-7c8188c89a7c\"\"\",", + "\"\"\"9384db41-1b21-4b93-a6aa-edfc007136d8\"\"\",", + "\"\"\"e8e06504-e7f2-5f35-acc1-abaf576bb300\"\"\",", + "\"\"\"2026-01-01T00:00:00.000000Z\"\"\",", + "null,null,null,", + "\"{\"\"janitor.event_name\"\":\"\"Voting Period Opens\"\"}\",", + "\"\"\"START_VOTING_PERIOD\"\"\",", + "\"{\"\"cron\"\":null,\"\"scheduled_date\"\":\"\"2027-04-20T00:00:00+00:00\"\"}\",", + "\"{\"\"election_id\"\":\"\"cf433085-801f-56b4-ac9e-24245d4d516a\"\"}\",", + "\"\"\"tenant_9384db41-1b21-4b93-a6aa-edfc007136d8", + "_event_e8e06504-e7f2-5f35-acc1-abaf576bb300", + "_election_cf433085-801f-56b4-ac9e-24245d4d516a", + "_START_VOTING_PERIOD\"\"\"" + ); + + let task_id = generate_manage_date_task_name( + "9384db41-1b21-4b93-a6aa-edfc007136d8", + "e8e06504-e7f2-5f35-acc1-abaf576bb300", + Some("cf433085-801f-56b4-ac9e-24245d4d516a"), + &EventProcessors::START_VOTING_PERIOD, + ); + + let row = vec![ + JsonField::string("543e5b91-b725-5c8d-97f8-7c8188c89a7c"), + JsonField::string("9384db41-1b21-4b93-a6aa-edfc007136d8"), + JsonField::string("e8e06504-e7f2-5f35-acc1-abaf576bb300"), + JsonField::string("2026-01-01T00:00:00.000000Z"), + JsonField::Null, + JsonField::Null, + JsonField::Null, + JsonField::Value( + json!({"janitor.event_name": "Voting Period Opens"}), + ), + JsonField::string("START_VOTING_PERIOD"), + JsonField::Value( + json!({"cron": null, "scheduled_date": "2027-04-20T00:00:00+00:00"}), + ), + JsonField::Value( + json!({"election_id": "cf433085-801f-56b4-ac9e-24245d4d516a"}), + ), + JsonField::string(task_id), + ]; + + let rendered = json_csv(SCHEDULED_EVENT_COLUMNS, &[row]); + let data_line = rendered.lines().nth(1).unwrap(); + assert_eq!(data_line, FROM_JANITOR); + } + + #[test] + fn a_string_is_wrapped_in_three_quotes() { + let rows = vec![vec![JsonField::string("START_VOTING_PERIOD")]]; + assert_eq!( + json_csv(&["a"], &rows), + "a\n\"\"\"START_VOTING_PERIOD\"\"\"\n" + ); + } + + #[test] + fn a_sql_null_is_bare_but_a_json_null_is_quoted() { + // Collapsing these would make an empty column indistinguishable from one + // holding the JSON value null. + assert_eq!(json_csv(&["a"], &[vec![JsonField::Null]]), "a\nnull\n"); + assert_eq!( + json_csv(&["a"], &[vec![JsonField::Value(Value::Null)]]), + "a\n\"null\"\n" + ); + } + + #[test] + fn a_csv_reader_recovers_the_json() { + // The importer CSV-decodes and then runs deserialize_str, so a round trip + // has to give back the original structure. + let payload = json!({"cron": null, "scheduled_date": "2027-04-20T00:00:00+00:00"}); + let rendered = json_csv( + &["cron_config"], + &[vec![JsonField::Value(payload.clone())]], + ); + + let line = rendered.lines().nth(1).unwrap(); + let decoded = decode_one_csv_field(line); + assert_eq!(serde_json::from_str::(&decoded).unwrap(), payload); + } + + #[test] + fn embedded_quotes_survive_the_round_trip() { + let payload = json!({"name": "He said \"hi\""}); + let rendered = + json_csv(&["a"], &[vec![JsonField::Value(payload.clone())]]); + let decoded = decode_one_csv_field(rendered.lines().nth(1).unwrap()); + assert_eq!(serde_json::from_str::(&decoded).unwrap(), payload); + } + + /// Undo one CSV-quoted field, the way a reader would. + fn decode_one_csv_field(line: &str) -> String { + let trimmed = line + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .unwrap_or(line); + trimmed.replace("\"\"", "\"") + } + + // -- plain CSV --------------------------------------------------------- + + #[test] + fn a_plain_csv_is_minimally_quoted() { + let rows = vec![vec!["1".to_string(), "2".to_string()]]; + assert_eq!(plain_csv(&["a", "b"], &rows), "a,b\n1,2\n"); + } + + #[test] + fn a_value_with_a_comma_is_quoted() { + let rows = vec![vec!["DLC 703 Members, No CBUR".to_string()]]; + assert_eq!( + plain_csv(&["a"], &rows), + "a\n\"DLC 703 Members, No CBUR\"\n" + ); + } + + #[test] + fn a_value_with_a_quote_is_escaped() { + let rows = vec![vec!["say \"hi\"".to_string()]]; + assert_eq!(plain_csv(&["a"], &rows), "a\n\"say \"\"hi\"\"\"\n"); + } + + #[test] + fn an_empty_field_beside_others_stays_empty() { + // A voter with no email must get an empty field, never the text "None". + let rows = vec![vec!["x".to_string(), String::new()]]; + assert_eq!(plain_csv(&["a", "b"], &rows), "a,b\nx,\n"); + } + + #[test] + fn a_lone_empty_field_is_quoted() { + // Otherwise the row is a blank line, which a reader cannot tell from no + // row at all. + let rows = vec![vec![String::new()]]; + assert_eq!(plain_csv(&["a"], &rows), "a\n\"\"\n"); + } + + #[test] + fn line_endings_are_unix() { + let rows = vec![vec!["1".to_string()]]; + assert!(!plain_csv(&["a"], &rows).contains('\r')); + } + + // -- task ids ---------------------------------------------------------- + + #[test] + fn a_task_id_names_its_election_when_it_has_one() { + // The platform looks the task up by this name. `emit` used to build it here + // and this test asserted the copy matched; it calls the platform's own + // function now, and the assertion pins the shape the scheduler expects. + assert_eq!( + generate_manage_date_task_name( + "t", + "e", + Some("el"), + &EventProcessors::START_VOTING_PERIOD + ), + "tenant_t_event_e_election_el_START_VOTING_PERIOD" + ); + } + + #[test] + fn an_event_wide_task_id_omits_the_election() { + assert_eq!( + generate_manage_date_task_name( + "t", + "e", + None, + &EventProcessors::END_VOTING_PERIOD + ), + "tenant_t_event_e_END_VOTING_PERIOD" + ); + } + + // -- multi-value ------------------------------------------------------- + + #[test] + fn multi_values_join_with_a_single_pipe() { + // The workbooks use "||"; MULTIVALUE_USER_ATTRIBUTE_SEPARATOR is "|". + let joined = join_multi_value(["a", "b", "c"]); + assert_eq!(joined, "a|b|c"); + assert!(!joined.contains("||")); + } + + #[test] + fn one_value_joins_to_itself() { + assert_eq!(join_multi_value(["only"]), "only"); + } + + #[test] + fn no_values_join_to_nothing() { + assert_eq!(join_multi_value(Vec::::new()), ""); + } + + // -- member names ------------------------------------------------------ + + #[test] + fn member_names_carry_the_prefixes_the_importer_dispatches_on() { + assert_eq!( + member::file_name(member::VOTERS, "abc", "csv"), + "export_voters-abc.csv" + ); + assert_eq!( + member::file_name(member::ELECTION_EVENT, "abc", "json"), + "export_election_event-abc.json" + ); + } + + // -- the positional column orders ------------------------------------- + + #[test] + fn scheduled_event_columns_are_in_the_order_the_importer_reads() { + // import_scheduled_events.rs reads event_payload at index 10. + assert_eq!(SCHEDULED_EVENT_COLUMNS[10], "event_payload"); + assert_eq!(SCHEDULED_EVENT_COLUMNS[9], "cron_config"); + assert_eq!(SCHEDULED_EVENT_COLUMNS[8], "event_processor"); + assert_eq!(SCHEDULED_EVENT_COLUMNS[11], "task_id"); + assert_eq!(SCHEDULED_EVENT_COLUMNS.len(), 12); + } + + #[test] + fn report_columns_are_in_the_order_the_importer_reads() { + // process_reports_file reads election_id at 1 and permission_label at 7. + assert_eq!(REPORT_COLUMNS[1], "election_id"); + assert_eq!(REPORT_COLUMNS[7], "permission_label"); + assert_eq!(REPORT_COLUMNS.len(), 8); + } +} diff --git a/packages/sequent-core/src/election_config/fixtures.rs b/packages/sequent-core/src/election_config/fixtures.rs new file mode 100644 index 00000000000..179c1024e0d --- /dev/null +++ b/packages/sequent-core/src/election_config/fixtures.rs @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Bundles with known verdicts, shared by every caller of [`super::validate`]. +//! +//! The point of one validator is that two callers reach the same answer. Tests +//! written separately in Rust and in TypeScript would not prove that — they would +//! prove each side agrees with itself. So the cases are **data**, compiled in here +//! and handed to the browser through the WASM surface, and both sides run the same +//! list. +//! +//! A case is a patch, not a whole bundle. `sound.json` is the one bundle anybody +//! has to keep valid; each case says what it changes and what that should be worth. +//! Reading a case means reading the difference, which is what a case is about. +//! +//! These are not a substitute for the tests in `validate_tests.rs`, which pin exact +//! messages and paths. These pin the *verdict*, which is the part two languages +//! have to agree on. + +use crate::election_config::problem::{Code, Severity}; +use crate::election_config::schema::ImportElectionEventSchema; +use crate::election_config::validate; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// The bundle every case starts from. +pub const SOUND: &str = include_str!("fixtures/sound.json"); + +/// What each case changes, and what it should be worth. +pub const CASES: &str = include_str!("fixtures/cases.json"); + +/// What a bundle should be found to be. +/// +/// Distinct codes rather than counts. A case is about *what kind* of thing is +/// wrong; how many times validation says so is a detail that should be free to +/// change without a fixture needing an edit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Expect { + pub errors: Vec, + pub warnings: Vec, +} + +/// One bundle, and the verdict every caller must reach on it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Case { + pub name: String, + + /// Why this case is worth having. Read this before changing what it expects. + pub why: String, + + /// The bundle, with the case's patch already applied. + pub bundle: Value, + + pub expect: Expect, +} + +#[derive(Deserialize)] +struct RawCase { + name: String, + why: String, + patch: Value, + expect: Expect, +} + +/// Every case, with its patch applied to the sound bundle. +/// +/// Panics if the fixtures do not parse: they are compiled in, so a broken one is a +/// build-time mistake rather than anything a caller can handle. +pub fn cases() -> Vec { + let sound: Value = + serde_json::from_str(SOUND).expect("fixtures/sound.json must parse"); + let raw: Vec = + serde_json::from_str(CASES).expect("fixtures/cases.json must parse"); + + raw.into_iter() + .map(|case| Case { + name: case.name, + why: case.why, + bundle: merge_patch(sound.clone(), case.patch), + expect: case.expect, + }) + .collect() +} + +/// The verdict [`super::validate`] reaches on a case, in the same shape as its +/// expectation. +/// +/// Sorted and deduplicated so the comparison is about which codes appeared, not +/// the order validation happened to find them in. +pub fn verdict(bundle: &ImportElectionEventSchema) -> Expect { + let report = validate(bundle); + let collect = |severity: Severity| { + let mut codes: Vec = report + .problems + .iter() + .filter(|problem| problem.severity == severity) + .map(|problem| problem.code) + .collect(); + codes.sort_by_key(|code| format!("{code:?}")); + codes.dedup(); + codes + }; + Expect { + errors: collect(Severity::Error), + warnings: collect(Severity::Warning), + } +} + +/// RFC 7386 JSON Merge Patch: objects recurse, `null` removes, everything else +/// replaces. +/// +/// Arrays replace wholesale, which is what a case wants — "these three contests" +/// rather than "these appended to whatever was there". +fn merge_patch(target: Value, patch: Value) -> Value { + let Value::Object(patch) = patch else { + return patch; + }; + + let mut target = match target { + Value::Object(target) => target, + _ => Map::new(), + }; + for (key, value) in patch { + if value.is_null() { + target.remove(&key); + } else { + let existing = target.remove(&key).unwrap_or(Value::Null); + target.insert(key, merge_patch(existing, value)); + } + } + Value::Object(target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_case_reaches_the_verdict_it_claims() { + // The suite's whole job. When this fails, either validation changed or a + // case's `why` no longer describes what it tests — read the `why` before + // editing the expectation. + let all = cases(); + // Guard against the way a loop like this fails open: an empty list, a + // fixture file that stopped being found, and every assertion below is + // skipped while the test still passes. + assert!(all.len() >= 8, "only {} cases loaded", all.len()); + + for case in all { + let bundle: ImportElectionEventSchema = serde_json::from_value( + case.bundle.clone(), + ) + .unwrap_or_else(|error| { + panic!("{}: does not deserialize: {error}", case.name) + }); + let got = verdict(&bundle); + assert_eq!( + got, case.expect, + "{}: {}\n expected {:?}\n got {:?}", + case.name, case.why, case.expect, got + ); + } + } + + #[test] + fn the_base_bundle_is_the_one_with_nothing_wrong_with_it() { + // Every other case is a patch on it, so a problem here would be attributed + // to whichever case happened to be read first. + let sound: ImportElectionEventSchema = + serde_json::from_str(SOUND).expect("sound.json deserializes"); + let report = validate(&sound); + assert!(report.is_empty(), "the base bundle is not sound:\n{report}"); + } + + #[test] + fn the_suite_covers_every_code_validation_can_produce() { + // A code no case exercises is a verdict the two callers have never been + // checked to agree on. Two are absent on purpose and named here, so + // adding a code without a case fails rather than passing quietly. + let covered: Vec = cases() + .iter() + .flat_map(|case| { + case.expect + .errors + .iter() + .chain(case.expect.warnings.iter()) + .copied() + }) + .collect(); + + // Every variant, and the `match` below is what makes this hold: adding a + // `Code` does not compile until somebody says which side it belongs on. The + // old hard-coded list left a new variant neither covered nor reported, which + // is the opposite of what its comment claimed. + const EVERY_CODE: [Code; 11] = [ + Code::MissingField, + Code::InvalidValue, + Code::DanglingReference, + Code::DuplicateId, + Code::AreaCycle, + Code::ContestArithmetic, + Code::TallyMismatch, + Code::BallotCoverage, + Code::PermissionLabel, + Code::MissingSchedule, + Code::ConflictingColumns, + ]; + + for code in EVERY_CODE { + let wanted = match code { + Code::DanglingReference + | Code::DuplicateId + | Code::AreaCycle + | Code::ContestArithmetic + | Code::TallyMismatch + | Code::BallotCoverage + | Code::PermissionLabel => true, + // From a bundle that does not deserialize at all, or from reading a + // source document — neither of which is a case here. + Code::MissingField | Code::InvalidValue => false, + // Needs scheduled events, which arrive with the builder rather than + // with a bundle on its own. + Code::MissingSchedule => false, + // Reported while reading a workbook's columns, not from a bundle. + Code::ConflictingColumns => false, + }; + assert_eq!( + covered.contains(&code), + wanted, + "{code:?}: the fixture cases and this list disagree" + ); + } + } + + #[test] + fn a_patch_replaces_a_list_rather_than_appending_to_it() { + let merged = merge_patch( + serde_json::json!({"a": [1, 2, 3], "keep": true}), + serde_json::json!({"a": [9]}), + ); + assert_eq!(merged, serde_json::json!({"a": [9], "keep": true})); + } + + #[test] + fn a_patch_recurses_through_objects_and_null_removes() { + let merged = merge_patch( + serde_json::json!({"o": {"x": 1, "y": 2}}), + serde_json::json!({"o": {"y": null, "z": 3}}), + ); + assert_eq!(merged, serde_json::json!({"o": {"x": 1, "z": 3}})); + } + + #[test] + fn every_case_says_why_it_exists() { + // A case with no reason is a case nobody can safely change. + for case in cases() { + assert!(!case.name.is_empty()); + assert!( + case.why.len() > 40, + "{}: `why` should say what breaks, not just name it", + case.name + ); + } + } +} diff --git a/packages/sequent-core/src/election_config/fixtures/cases.json b/packages/sequent-core/src/election_config/fixtures/cases.json new file mode 100644 index 00000000000..7360423b7b5 --- /dev/null +++ b/packages/sequent-core/src/election_config/fixtures/cases.json @@ -0,0 +1,137 @@ +[ + { + "name": "sound", + "why": "The base, unchanged. If this ever reports anything, the rest of the suite is measuring the wrong thing.", + "patch": {}, + "expect": {"errors": [], "warnings": []} + }, + { + "name": "contest-names-no-election", + "why": "A contest whose election_id is not in the bundle. The commonest authoring mistake, and the importer would fail on it mid-transaction.", + "patch": { + "contests": [ + { + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "election_id": "e9000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large" + } + ] + }, + "expect": {"errors": ["dangling_reference"], "warnings": []} + }, + { + "name": "two-elections-one-id", + "why": "Two entities sharing an id. One silently overwrites the other on import, and the event ends up missing an election nobody notices.", + "patch": { + "elections": [ + { + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "external_id": "officers" + }, + { + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "external_id": "delegates" + } + ] + }, + "expect": {"errors": ["duplicate_id"], "warnings": []} + }, + { + "name": "areas-in-a-loop", + "why": "Two areas each other's parent. Walking the tree to work out a voter's ballot would not terminate.", + "patch": { + "areas": [ + { + "id": "a1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Region", + "parent_id": "a2000000-0000-5000-8000-000000000000" + }, + { + "id": "a2000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Local 1", + "parent_id": "a1000000-0000-5000-8000-000000000000" + } + ] + }, + "expect": {"errors": ["area_cycle"], "warnings": []} + }, + { + "name": "more-winners-than-candidates", + "why": "A contest electing five from a field of two. It imports and then cannot be tallied.", + "patch": { + "contests": [ + { + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "election_id": "e1000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 5, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large" + } + ] + }, + "expect": {"errors": ["contest_arithmetic"], "warnings": []} + }, + { + "name": "tally-disagrees-with-voting-type", + "why": "A preferential ballot counted by a plurality algorithm. Both values are individually valid, which is why nothing else catches it.", + "patch": { + "contests": [ + { + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "election_id": "e1000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "preferential", + "counting_algorithm": "plurality-at-large" + } + ] + }, + "expect": {"errors": ["tally_mismatch"], "warnings": []} + }, + { + "name": "nothing-on-any-ballot", + "why": "No area/contest links at all. A warning, not an error: an event still being configured looks exactly like this, and its export has to round trip for disaster recovery.", + "patch": {"area_contests": []}, + "expect": {"errors": [], "warnings": ["ballot_coverage"]} + }, + { + "name": "election-hidden-by-a-permission-label", + "why": "The one that cost a real import. A labelled election is invisible to every administrator without that label, so the event imports cleanly and the Elections list is empty.", + "patch": { + "elections": [ + { + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "external_id": "officers", + "permission_label": "statewide-officers" + } + ] + }, + "expect": {"errors": [], "warnings": ["permission_label"]} + } +] diff --git a/packages/sequent-core/src/election_config/fixtures/cases.json.license b/packages/sequent-core/src/election_config/fixtures/cases.json.license new file mode 100644 index 00000000000..33a08f69b47 --- /dev/null +++ b/packages/sequent-core/src/election_config/fixtures/cases.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Sequent Tech Inc + +SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/sequent-core/src/election_config/fixtures/sound.json b/packages/sequent-core/src/election_config/fixtures/sound.json new file mode 100644 index 00000000000..7b858547e57 --- /dev/null +++ b/packages/sequent-core/src/election_config/fixtures/sound.json @@ -0,0 +1,75 @@ +{ + "_comment": "The base every case in cases.json patches. A bundle with nothing wrong with it: one election, one contest with two candidates, two areas in a parent chain, and a ballot linking the leaf area to the contest. Deliberately minimal — anything a case needs, the case adds.", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "keycloak_event_realm": null, + "election_event": { + "id": "e0000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "is_archived": false, + "encryption_protocol": "RSA256" + }, + "elections": [ + { + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "external_id": "officers" + } + ], + "contests": [ + { + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "election_id": "e1000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large" + } + ], + "candidates": [ + { + "id": "d1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000", + "external_id": "pres-a" + }, + { + "id": "d2000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000", + "external_id": "pres-b" + } + ], + "areas": [ + { + "id": "a1000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Region" + }, + { + "id": "a2000000-0000-5000-8000-000000000000", + "tenant_id": "11111111-1111-4111-8111-111111111111", + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Local 1", + "parent_id": "a1000000-0000-5000-8000-000000000000" + } + ], + "area_contests": [ + { + "id": "b1000000-0000-5000-8000-000000000000", + "area_id": "a2000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000" + } + ], + "scheduled_events": null, + "reports": [], + "keys_ceremonies": [], + "applications": [] +} diff --git a/packages/sequent-core/src/election_config/fixtures/sound.json.license b/packages/sequent-core/src/election_config/fixtures/sound.json.license new file mode 100644 index 00000000000..33a08f69b47 --- /dev/null +++ b/packages/sequent-core/src/election_config/fixtures/sound.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Sequent Tech Inc + +SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/sequent-core/src/election_config/ids.rs b/packages/sequent-core/src/election_config/ids.rs new file mode 100644 index 00000000000..54ff9b230b4 --- /dev/null +++ b/packages/sequent-core/src/election_config/ids.rs @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Deterministic identifiers for a generated bundle. +//! +//! Every id is a version 5 UUID over the event's `external_id`, the entity kind, +//! and the row's `external_id`. Two consequences matter: +//! +//! * Regenerating an unchanged source produces byte-identical output, so a diff +//! between two runs shows only what the author actually changed. +//! * Two events built from different sources never collide, because the event's +//! `external_id` is mixed into the namespace. +//! +//! The importer rewrites every UUID it receives, so these are not the ids the +//! platform ends up storing. They exist to make the file reproducible and its +//! internal references consistent. +//! +//! Written out rather than taken from the `uuid` crate, for two reasons. The +//! crate's v4 feature pulls `getrandom`, whose WASM support is version-specific +//! and already pinned elsewhere in this workspace — nothing here needs randomness +//! and it should not acquire a reason to. And the byte layout below is the thing +//! that must never change: alter it and every event ever generated renumbers, so +//! it is better read than trusted. + +use sha1::{Digest, Sha1}; + +/// Root namespace. Arbitrary but **frozen**: changing it renumbers every event +/// ever generated. `8f2b6c41-5d3e-5a7f-9c18-3ea1b7d40f62`. +pub const ROOT_NAMESPACE: [u8; 16] = [ + 0x8f, 0x2b, 0x6c, 0x41, 0x5d, 0x3e, 0x5a, 0x7f, 0x9c, 0x18, 0x3e, 0xa1, + 0xb7, 0xd4, 0x0f, 0x62, +]; + +/// A version 5 UUID: SHA-1 over the namespace's bytes followed by the name. +/// +/// RFC 9562 §5.5. The two masked bytes are the version and the variant, and they +/// are what make the digest a UUID rather than just a hash. +pub fn uuid5(namespace: &[u8; 16], name: &str) -> [u8; 16] { + let mut hasher = Sha1::new(); + hasher.update(namespace); + hasher.update(name.as_bytes()); + let digest = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + bytes +} + +/// The canonical 8-4-4-4-12 lowercase hex form. +pub fn format_uuid(bytes: &[u8; 16]) -> String { + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +/// Mints stable ids scoped to one election event. +#[derive(Debug, Clone)] +pub struct IdFactory { + namespace: [u8; 16], +} + +impl IdFactory { + /// Scoped to the event's `external_id`, which must not be empty — without it + /// two unrelated events would share a namespace. + pub fn new(event_external_id: &str) -> Option { + if event_external_id.is_empty() { + return None; + } + Some(IdFactory { + namespace: uuid5(&ROOT_NAMESPACE, event_external_id), + }) + } + + pub fn namespace(&self) -> String { + format_uuid(&self.namespace) + } + + /// Id for one entity — `uid("contest", &["statewide-president"])`. + /// + /// `kind` keeps the per-entity keyspaces apart, so an area and a contest may + /// share an `external_id` without colliding. + /// + /// Parts are length-prefixed rather than joined by a separator. Joined by + /// one, `["a/b"]` and `["a", "b"]` would hash alike — which for an + /// area/contest link means two different pairs sharing an id, and one + /// silently overwriting the other. An `external_id` holding a slash is + /// unusual, not forbidden. + /// + /// The prefix counts **characters, not bytes**, matching the Python this was + /// ported from. A byte count would be the more obvious choice and would + /// renumber every id derived from a non-ASCII `external_id`, for no gain: + /// either count is unambiguous. + pub fn uid(&self, kind: &str, parts: &[&str]) -> String { + let mut name = String::new(); + for part in std::iter::once(&kind).chain(parts.iter()) { + name.push_str(&part.chars().count().to_string()); + name.push(':'); + name.push_str(part); + } + format_uuid(&uuid5(&self.namespace, &name)) + } + + /// A tenant id derived from the event, for when none was supplied. + /// + /// Only a fallback: importing into an existing tenant needs that tenant's + /// real id, which is why a caller should offer a way to pass one and should + /// say out loud when it had to invent one. + pub fn tenant_id(&self) -> String { + self.uid("tenant", &[]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The event id used throughout, so the pinned values below are all one + /// factory's output. + const EVENT: &str = "seiu1000-leadership-2027"; + + fn factory() -> IdFactory { + IdFactory::new(EVENT).unwrap() + } + + #[test] + fn the_root_namespace_is_the_uuid_it_claims_to_be() { + // The bytes are written out; this is what keeps them honest. + assert_eq!( + format_uuid(&ROOT_NAMESPACE), + "8f2b6c41-5d3e-5a7f-9c18-3ea1b7d40f62" + ); + } + + #[test] + fn it_agrees_with_the_python_it_replaces() { + // Every value here was produced by janitor's ids.py. Byte-identical ids + // are what let the Rust take over without renumbering a single event + // anyone has already generated — including the SEIU1000 bundle. + let ids = factory(); + assert_eq!(ids.namespace(), "a2e05988-3ddc-509e-aa7e-837d598f9b68"); + assert_eq!( + ids.uid("election_event", &[]), + "7af38708-879f-5010-8de2-efe3d30c2b9d" + ); + assert_eq!( + ids.uid("election", &["statewide-officers"]), + "cf433085-801f-56b4-ac9e-24245d4d516a" + ); + assert_eq!( + ids.uid("contest", &["statewide-president"]), + "582381fe-c453-579c-92fb-0b325a2081c6" + ); + assert_eq!( + ids.uid("area_contest", &["area-statewide", "statewide-president"]), + "815d65c4-4ec2-5cba-80e6-f88a46111772" + ); + assert_eq!(ids.tenant_id(), "f79036c6-f1d6-5c6f-9b4e-4b866395c438"); + } + + #[test] + fn a_non_ascii_external_id_hashes_the_way_the_python_does() { + // The one place a byte count and a character count differ: "José-Muñoz" + // is ten characters and twelve bytes. Getting this wrong renumbers every + // id derived from an accented name, silently. + assert_eq!( + IdFactory::new("e") + .unwrap() + .uid("candidate", &["José-Muñoz"]), + "dbaf7119-7510-5112-801c-09e6f72860f5" + ); + } + + #[test] + fn a_slash_in_one_part_does_not_collide_with_two_parts() { + // What the length prefix is for. Joined by a separator these would be the + // same id, and one area/contest link would overwrite another. + let ids = IdFactory::new("e").unwrap(); + assert_eq!( + ids.uid("area_contest", &["a/b"]), + "855fb0e7-d9f9-50f1-bcd5-0d4d07767d3f" + ); + assert_eq!( + ids.uid("area_contest", &["a", "b"]), + "e2f08132-5876-524f-8658-a0e982123176" + ); + assert_ne!( + ids.uid("area_contest", &["a/b"]), + ids.uid("area_contest", &["a", "b"]) + ); + } + + #[test] + fn a_version_5_uuid_says_so_in_its_bits() { + let id = factory().uid("election", &["x"]); + // Version nibble, then the variant nibble, at the positions RFC 9562 + // puts them. + assert_eq!(id.as_bytes()[14], b'5', "version nibble in {id}"); + assert!( + ['8', '9', 'a', 'b'].contains(&(id.as_bytes()[19] as char)), + "variant nibble in {id}" + ); + } + + #[test] + fn the_kind_keeps_keyspaces_apart() { + // An area and a contest may share an external_id; their ids must differ. + let ids = factory(); + assert_ne!(ids.uid("area", &["board"]), ids.uid("contest", &["board"])); + } + + #[test] + fn two_events_never_share_an_id() { + // The event's external_id is mixed into the namespace for exactly this. + let one = IdFactory::new("event-a").unwrap(); + let two = IdFactory::new("event-b").unwrap(); + assert_ne!( + one.uid("election", &["board"]), + two.uid("election", &["board"]) + ); + } + + #[test] + fn the_same_input_gives_the_same_id_every_time() { + // The property that makes a regenerated bundle diffable. + assert_eq!( + factory().uid("contest", &["president"]), + factory().uid("contest", &["president"]) + ); + } + + #[test] + fn an_event_with_no_external_id_gets_no_factory() { + // Without it, two unrelated events would share a namespace. + assert!(IdFactory::new("").is_none()); + } + + #[test] + fn a_formatted_uuid_is_lowercase_hex_in_the_canonical_groups() { + let formatted = format_uuid(&[ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, + 0xbb, 0xcc, 0xdd, 0xee, 0xff, + ]); + assert_eq!(formatted, "00112233-4455-6677-8899-aabbccddeeff"); + } +} diff --git a/packages/sequent-core/src/election_config/mod.rs b/packages/sequent-core/src/election_config/mod.rs new file mode 100644 index 00000000000..256fc92e0fb --- /dev/null +++ b/packages/sequent-core/src/election_config/mod.rs @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The election event import bundle: what it contains, and whether it is valid. +//! +//! This is the single definition of the format shared by everything that +//! produces or consumes an import — windmill's importer on the server, and the +//! configuration tools in `beyond/packages` in the browser. +//! +//! It lives here rather than in `beyond` because the dependency between the +//! repositories runs one way: `beyond` is a git submodule of step and +//! path-depends on this crate, so step cannot depend on `beyond`. Anything +//! windmill must use has to be here. `sequent-core` also already compiles to +//! WASM and is already vendored into the front ends as a package, so both +//! consumers reach this module through paths that already carry production +//! code. +//! +//! Everything in this module must stay **pure** — no database, no IO, no +//! clock — or it cannot run in a browser, and the browser is where a delivery +//! engineer wants the answer. +//! +//! See `beyond/docs/docusaurus/docs/engineering/election-config-architecture.md` +//! and . + +/// Turning a source document's rows into a bundle. Needs the templates, so it +/// shares their feature. +#[cfg(feature = "election_config_templates")] +pub mod build; + +/// What a bundle becomes as files. Needs the builder, so it shares its feature; +/// the zip writer itself is behind `election_config_archive`. +#[cfg(feature = "election_config_templates")] +pub mod archive; + +/// The Election Architect's plan, and how it becomes rows the builder reads. +/// Needs the builder's feature, since compiling a plan means building a bundle. +#[cfg(feature = "election_config_templates")] +pub mod architect; + +pub mod branding; +pub mod emit; +pub mod ids; +pub mod paths; +pub mod presets; + +/// Bundles with known verdicts, shared by every caller of [`validate`] so the two +/// reach the same answer rather than each agreeing with itself. +pub mod fixtures; +pub mod problem; + +/// Rendering the base entity templates, behind its own feature so a front end +/// that only validates an existing bundle carries no template engine. +#[cfg(feature = "election_config_templates")] +pub mod render; + +pub mod report; +pub mod schema; +pub mod sheet; + +/// A moment a plan names, and the instant the platform acts on. Ungated: the +/// scheduler's requirements are not a template concern. +pub mod time; +pub mod validate; + +/// Reading `.xlsx`, behind its own feature so front ends with no workbook to read +/// do not carry a spreadsheet library. +#[cfg(feature = "election_config_xlsx")] +pub mod xlsx; + +#[cfg(test)] +mod validate_tests; + +// Every re-export below carries the same gate as the module it names. A +// re-export gated differently from its module is not a style question: `pub use +// build::…` without a gate does not compile at all once the module is absent, +// and `default_features` does not imply `election_config_templates` — so the +// crate was broken for every feature set windmill, harvest, velvet and the +// browser builds actually declare. `cargo build --workspace` hid it by unifying +// step-cli's features across the graph, which is why only CI saw it. +#[cfg(feature = "election_config_templates")] +pub use architect::{validate_plan, Blueprint}; +#[cfg(feature = "election_config_templates")] +pub use archive::{layout, Artifact, Layout}; +#[cfg(feature = "election_config_templates")] +pub use build::{ + build, BuildOptions, Bundle, CommunicationTemplate, JsonTable, PlainTable, +}; +pub use emit::{json_csv, plain_csv, JsonField}; +pub use ids::IdFactory; +pub use paths::{coerce_cell, deep_merge, expand, Cell}; +pub use presets::{AuthPreset, RealmPatch}; +pub use problem::{Code, Problem, Report as ValidationReport, Severity}; +#[cfg(feature = "election_config_templates")] +pub use render::TemplateSet; +pub use report::{EReportEncryption, Report, ReportCronConfig, ReportType}; +pub use schema::ImportElectionEventSchema; +pub use sheet::{Origin, Row, Sheet, Workbook}; +pub use time::Timestamp; +pub use validate::validate; diff --git a/packages/sequent-core/src/election_config/paths.rs b/packages/sequent-core/src/election_config/paths.rs new file mode 100644 index 00000000000..e71b490a594 --- /dev/null +++ b/packages/sequent-core/src/election_config/paths.rs @@ -0,0 +1,578 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Dotted column headers, cell coercion, and deep merge. +//! +//! An authoring spreadsheet's column headers are paths into the target JSON: +//! `presentation.i18n.en.name` means +//! `{"presentation": {"i18n": {"en": {"name": …}}}}`. Keeping that mapping +//! generic is what makes a workbook client-agnostic — a new column lands in the +//! output without a code change. +//! +//! This layer knows nothing about spreadsheets. It takes [`Cell`]s, which a +//! reader produces from `.xlsx`, a CSV, or a browser's form state, and turns them +//! into `serde_json` values. Pure and dependency-light on purpose: it is the part +//! most worth testing and the part that runs in a browser. + +use crate::election_config::problem::{Code, Problem}; +use chrono::{NaiveDateTime, TimeZone, Utc}; +use serde_json::{Map, Number, Value}; + +/// Separator for multi-valued cells. +/// +/// Two pipes, as the authoring workbooks already write them — +/// `statewide-officers || dlc-officers-dburs || cburs`. Note this is *not* +/// [`super::emit::MULTI_VALUE_SEPARATOR`], which is the single pipe the importer +/// splits Keycloak attributes on; whatever reads a workbook has to convert. +pub const MULTI_VALUE_SEPARATOR: &str = "||"; + +/// A cell holding exactly this, case-insensitively, means JSON `null`. +/// +/// A blank cell means something different: leave the template's default alone. +pub const NULL_LITERAL: &str = "null"; + +/// One cell as the reader handed it over, before it becomes JSON. +/// +/// A neutral vocabulary so this module has no opinion about where a row came +/// from. `Blank` and a cell holding the text `null` are deliberately different +/// things and stay different all the way through [`coerce_cell`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Cell { + /// Empty, or nothing but whitespace: the author said nothing about this + /// field. + Blank, + Text(String), + Int(i64), + Float(f64), + Bool(bool), + /// A date or time. Spreadsheet cells carry no timezone, so the reader hands + /// over a naive instant and [`coerce_scalar_cell`] reads it as UTC. + DateTime(NaiveDateTime), +} + +impl Cell { + /// Build a cell from text, treating whitespace-only as blank. + /// + /// A cell someone typed a space into is as empty in intent as one they never + /// touched, and readers should not each have to remember that. + pub fn text(value: impl Into) -> Self { + let value: String = value.into(); + if value.trim().is_empty() { + Cell::Blank + } else { + Cell::Text(value) + } + } + + pub fn is_blank(&self) -> bool { + matches!(self, Cell::Blank) + } +} + +/// Split a dotted header into its parts. +/// +/// `"presentation.i18n.en.name"` becomes +/// `["presentation", "i18n", "en", "name"]`. Keys containing a literal dot are +/// not supported; no entity schema has one. +pub fn split_path(header: &str) -> Vec { + header + .trim() + .split('.') + .map(|part| part.trim().to_string()) + .collect() +} + +/// Turn a piece of text into the JSON value the platform expects. +/// +/// Spreadsheets are typed loosely and the platform is not: `max_votes` typed as +/// `3` must not arrive as `3.0`, which fails deserialization into `i64`, and text +/// that happens to be JSON is meant as JSON — it is how a whole +/// `voting_channels` array fits in one cell. +pub fn coerce_scalar(text: &str) -> Value { + let text = text.trim(); + + if text.eq_ignore_ascii_case(NULL_LITERAL) { + return Value::Null; + } + + if text.eq_ignore_ascii_case("true") { + return Value::Bool(true); + } + if text.eq_ignore_ascii_case("false") { + return Value::Bool(false); + } + + // A trailing ".0" on an id or a code is a spreadsheet artifact, never intent. + if let Some(integral) = parse_integral_float(text) { + return Value::from(integral); + } + + // Only bracketed text is tried as JSON. Without that guard a plain "1" would + // be reinterpreted as a number by a different route, and a candidate whose + // name is "NaN" would turn into something that is not even valid JSON. + let bracketed = (text.starts_with('{') || text.starts_with('[')) + && (text.ends_with('}') || text.ends_with(']')); + if bracketed { + if let Ok(parsed) = serde_json::from_str::(text) { + return parsed; + } + // Not JSON after all — a description that opens with a bracket is still + // a description. + } + + Value::String(text.to_string()) +} + +/// `"-12.000"` -> `-12`; anything else -> `None`. +fn parse_integral_float(text: &str) -> Option { + let (sign, digits) = match text.strip_prefix('-') { + Some(rest) => (-1i64, rest), + None => (1i64, text), + }; + let (whole, fraction) = digits.split_once('.')?; + if whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.is_empty() + || !fraction.bytes().all(|byte| byte == b'0') + { + return None; + } + whole.parse::().ok().map(|value| sign * value) +} + +/// Coerce one cell, ignoring the multi-value question. +/// +/// A blank cell has no JSON value at all, which is why this returns an `Option`: +/// `None` means "the author said nothing", and `Some(Value::Null)` means "the +/// author wrote null". Collapsing the two would make it impossible to clear a +/// template default. +pub fn coerce_scalar_cell(cell: &Cell) -> Option { + match cell { + Cell::Blank => None, + Cell::Text(text) => Some(coerce_scalar(text)), + Cell::Bool(value) => Some(Value::Bool(*value)), + Cell::Int(value) => Some(Value::from(*value)), + Cell::Float(value) => Some(coerce_float(*value)), + Cell::DateTime(naive) => { + // Read as UTC. A spreadsheet cell has no timezone, and guessing the + // author's local one would silently shift a voting window. + Some(Value::String(Utc.from_utc_datetime(naive).to_rfc3339())) + } + } +} + +/// `3.0` is `3`. A genuine fraction stays a fraction. +/// +/// No schema field wants a fraction, but truncating one silently would be worse +/// than passing it through for validation to object to. A value JSON cannot +/// represent — an infinity, a NaN — becomes null rather than an unparseable file. +fn coerce_float(value: f64) -> Value { + if value.fract() == 0.0 && value.is_finite() && value.abs() < 9e15 { + return Value::from(value as i64); + } + Number::from_f64(value).map_or(Value::Null, Value::Number) +} + +/// Coerce a cell, optionally splitting it into a list. +/// +/// Whether a column is multi-valued is decided by the column, not by the +/// content: a cell that happens to hold no separator still becomes a +/// one-element list, or the JSON type emitted would depend on the data. +pub fn coerce_cell(cell: &Cell, multi_value: bool) -> Option { + if cell.is_blank() { + return None; + } + + if multi_value { + if let Cell::Text(text) = cell { + let parts: Vec = text + .split(MULTI_VALUE_SEPARATOR) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(coerce_scalar) + .collect(); + return Some(Value::Array(parts)); + } + return coerce_scalar_cell(cell).map(|value| Value::Array(vec![value])); + } + + coerce_scalar_cell(cell) +} + +/// Set `value` at `path`, creating the objects along the way. +/// +/// Fails when the path runs through something that is not an object, which means +/// two columns disagree about the shape — `presentation` and +/// `presentation.i18n` both being set, for instance. Reported rather than +/// panicked because it is an authoring mistake, and the author is the one who +/// has to see it. +pub fn set_path( + target: &mut Map, + path: &[String], + value: Value, +) -> Result<(), Problem> { + let dotted = path.join("."); + + let (last, parents) = match path.split_last() { + Some(split) if !split.0.is_empty() => split, + // An empty header, or one ending in a dot: there is no field named "". + _ => { + return Err(Problem::error( + Code::ConflictingColumns, + dotted.clone(), + format!("'{dotted}' is not a usable column header: it names an empty field"), + )) + } + }; + + let mut cursor = target; + for key in parents { + if key.is_empty() { + return Err(Problem::error( + Code::ConflictingColumns, + dotted.clone(), + format!("'{dotted}' is not a usable column header: it names an empty field"), + )); + } + let entry = cursor + .entry(key.clone()) + .or_insert_with(|| Value::Object(Map::new())); + if entry.is_null() { + *entry = Value::Object(Map::new()); + } + // Named before descending: once `entry` is borrowed mutably it can no + // longer be inspected for the message. + let held = type_name(entry); + cursor = match entry.as_object_mut() { + Some(object) => object, + None => { + return Err(Problem::error( + Code::ConflictingColumns, + dotted.clone(), + format!( + "cannot set '{dotted}': '{key}' already holds a \ + {held}, not an object. Two columns disagree about \ + the shape." + ), + )) + } + }; + } + + cursor.insert(last.clone(), value); + Ok(()) +} + +fn type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "list", + Value::Object(_) => "object", + } +} + +/// Turn `[("a.b", 1), ("a.c", 2)]` into `{"a": {"b": 1, "c": 2}}`. +/// +/// Ordered pairs rather than a map, because a row's fields arrive in column +/// order and two columns writing the same path should resolve left to right, +/// the way a reader of the spreadsheet would expect. +pub fn expand( + fields: &[(String, Value)], +) -> Result, Problem> { + let mut nested = Map::new(); + for (header, value) in fields { + set_path(&mut nested, &split_path(header), value.clone())?; + } + Ok(nested) +} + +/// Merge `override_value` onto `base`; objects recurse, everything else replaces. +/// +/// Lists replace rather than concatenate. A cell listing three voting channels +/// means exactly those three, not those three appended to whatever the template +/// had — there would otherwise be no way to remove one. +pub fn deep_merge(base: Value, override_value: Value) -> Value { + match (base, override_value) { + (Value::Object(base), Value::Object(override_object)) => { + let mut merged = base; + for (key, value) in override_object { + let combined = match merged.remove(&key) { + Some(existing) => deep_merge(existing, value), + None => value, + }; + merged.insert(key, combined); + } + Value::Object(merged) + } + (_, override_value) => override_value, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + use serde_json::json; + + fn at(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> Cell { + Cell::DateTime( + NaiveDate::from_ymd_opt(year, month, day) + .unwrap() + .and_hms_opt(hour, minute, 0) + .unwrap(), + ) + } + + #[test] + fn a_dotted_header_becomes_a_path() { + assert_eq!( + split_path("presentation.i18n.en.name"), + vec!["presentation", "i18n", "en", "name"] + ); + } + + #[test] + fn spaces_around_a_header_and_its_parts_are_noise() { + // Spreadsheets collect trailing spaces nobody can see. + assert_eq!(split_path(" a . b "), vec!["a", "b"]); + } + + #[test] + fn a_header_with_no_dots_is_a_single_part() { + assert_eq!(split_path("max_votes"), vec!["max_votes"]); + } + + #[test] + fn whitespace_is_as_empty_as_empty() { + assert!(Cell::text(" ").is_blank()); + assert!(Cell::text("").is_blank()); + assert!(!Cell::text(" x ").is_blank()); + } + + #[test] + fn an_integral_float_loses_its_pointless_fraction() { + // Excel hands back 3.0 for a 3 someone typed, and the platform wants an + // i64. + assert_eq!(coerce_scalar_cell(&Cell::Float(3.0)), Some(json!(3))); + assert_eq!(coerce_scalar_cell(&Cell::Float(-7.0)), Some(json!(-7))); + assert_eq!(coerce_scalar("3.0"), json!(3)); + assert_eq!(coerce_scalar("-12.000"), json!(-12)); + } + + #[test] + fn a_genuine_fraction_survives() { + assert_eq!(coerce_scalar_cell(&Cell::Float(1.5)), Some(json!(1.5))); + } + + #[test] + fn a_number_json_cannot_hold_becomes_null_rather_than_a_broken_file() { + assert_eq!( + coerce_scalar_cell(&Cell::Float(f64::INFINITY)), + Some(Value::Null) + ); + assert_eq!( + coerce_scalar_cell(&Cell::Float(f64::NAN)), + Some(Value::Null) + ); + } + + #[test] + fn a_version_string_is_not_a_number() { + // "1.0.0" has a dot and digits and is still text. + assert_eq!(coerce_scalar("1.0.0"), json!("1.0.0")); + assert_eq!(coerce_scalar("3.5"), json!("3.5")); + assert_eq!(coerce_scalar(".0"), json!(".0")); + } + + #[test] + fn the_null_literal_is_json_null_and_a_blank_cell_is_not() { + // The difference decides whether a template default can be cleared. + assert_eq!(coerce_scalar_cell(&Cell::text("null")), Some(Value::Null)); + assert_eq!(coerce_scalar_cell(&Cell::text("NULL")), Some(Value::Null)); + assert_eq!(coerce_scalar_cell(&Cell::Blank), None); + } + + #[test] + fn booleans_are_recognised_whatever_their_case() { + assert_eq!(coerce_scalar("TRUE"), json!(true)); + assert_eq!(coerce_scalar("false"), json!(false)); + assert_eq!(coerce_scalar_cell(&Cell::Bool(true)), Some(json!(true))); + } + + #[test] + fn bracketed_text_is_read_as_json() { + // This is how a whole array fits in one cell. + assert_eq!(coerce_scalar(r#"["web", "ivr"]"#), json!(["web", "ivr"])); + assert_eq!(coerce_scalar(r#"{"a": 1}"#), json!({"a": 1})); + } + + #[test] + fn text_that_only_looks_like_json_stays_text() { + // A description that opens with a bracket is still a description. + assert_eq!(coerce_scalar("[not json"), json!("[not json")); + assert_eq!(coerce_scalar("[1, 2,]"), json!("[1, 2,]")); + } + + #[test] + fn a_candidate_named_like_a_keyword_is_not_reinterpreted() { + // Unbracketed text is never parsed as JSON, so these survive. + assert_eq!(coerce_scalar("NaN"), json!("NaN")); + assert_eq!(coerce_scalar("Infinity"), json!("Infinity")); + } + + #[test] + fn a_naive_timestamp_is_read_as_utc() { + // Guessing the author's local zone would shift a voting window. + assert_eq!( + coerce_scalar_cell(&at(2026, 10, 24, 16, 15)), + Some(json!("2026-10-24T16:15:00+00:00")) + ); + } + + #[test] + fn a_multi_value_column_always_yields_a_list() { + // Even with one value, or the emitted JSON type would follow the data. + assert_eq!( + coerce_cell(&Cell::text("a || b || c"), true), + Some(json!(["a", "b", "c"])) + ); + assert_eq!( + coerce_cell(&Cell::text("only"), true), + Some(json!(["only"])) + ); + assert_eq!(coerce_cell(&Cell::Int(4), true), Some(json!([4]))); + } + + #[test] + fn empty_pieces_of_a_multi_value_cell_are_dropped() { + assert_eq!( + coerce_cell(&Cell::text("a || || b"), true), + Some(json!(["a", "b"])) + ); + assert_eq!(coerce_cell(&Cell::text("||"), true), Some(json!([]))); + } + + #[test] + fn a_blank_multi_value_cell_is_still_absent_not_an_empty_list() { + // Absent must stay absent: an empty list would overwrite the template. + assert_eq!(coerce_cell(&Cell::Blank, true), None); + } + + #[test] + fn setting_a_path_builds_the_objects_on_the_way() { + let mut target = Map::new(); + set_path( + &mut target, + &split_path("presentation.i18n.en.name"), + json!("President"), + ) + .unwrap(); + assert_eq!( + Value::Object(target), + json!({"presentation": {"i18n": {"en": {"name": "President"}}}}) + ); + } + + #[test] + fn two_columns_under_one_parent_share_it() { + let expanded = expand(&[ + ("a.b".to_string(), json!(1)), + ("a.c".to_string(), json!(2)), + ]) + .unwrap(); + assert_eq!(Value::Object(expanded), json!({"a": {"b": 1, "c": 2}})); + } + + #[test] + fn a_later_column_wins_over_an_earlier_one() { + // Left to right, the way someone reading the sheet would expect. + let expanded = + expand(&[("a".to_string(), json!(1)), ("a".to_string(), json!(2))]) + .unwrap(); + assert_eq!(Value::Object(expanded), json!({"a": 2})); + } + + #[test] + fn columns_that_disagree_about_the_shape_are_reported() { + // `presentation` as a scalar and `presentation.i18n` cannot both hold. + let problem = expand(&[ + ("presentation".to_string(), json!("plain")), + ("presentation.i18n".to_string(), json!({})), + ]) + .unwrap_err(); + assert_eq!(problem.code, Code::ConflictingColumns); + assert!(problem.message.contains("already holds a string")); + } + + #[test] + fn a_null_on_the_way_through_is_replaced_rather_than_refused() { + // "null" then a child column is a cleared cell followed by a set one, + // not a contradiction. + let expanded = expand(&[ + ("a".to_string(), Value::Null), + ("a.b".to_string(), json!(1)), + ]) + .unwrap(); + assert_eq!(Value::Object(expanded), json!({"a": {"b": 1}})); + } + + #[test] + fn a_header_naming_an_empty_field_is_refused() { + assert_eq!( + expand(&[("a.".to_string(), json!(1))]).unwrap_err().code, + Code::ConflictingColumns + ); + assert_eq!( + expand(&[(".a".to_string(), json!(1))]).unwrap_err().code, + Code::ConflictingColumns + ); + assert_eq!( + expand(&[("".to_string(), json!(1))]).unwrap_err().code, + Code::ConflictingColumns + ); + } + + #[test] + fn merging_recurses_through_objects() { + let merged = deep_merge( + json!({"presentation": {"i18n": {"en": {"name": "A"}}}, "keep": 1}), + json!({"presentation": {"i18n": {"es": {"name": "B"}}}}), + ); + assert_eq!( + merged, + json!({ + "presentation": {"i18n": {"en": {"name": "A"}, "es": {"name": "B"}}}, + "keep": 1, + }) + ); + } + + #[test] + fn a_list_replaces_rather_than_appends() { + // Three channels means exactly those three; appending would leave no way + // to remove one the template had. + let merged = deep_merge( + json!({"voting_channels": ["web", "ivr", "paper"]}), + json!({"voting_channels": ["web"]}), + ); + assert_eq!(merged, json!({"voting_channels": ["web"]})); + } + + #[test] + fn an_explicit_null_clears_a_template_default() { + let merged = deep_merge( + json!({"description": "old"}), + json!({"description": null}), + ); + assert_eq!(merged, json!({"description": null})); + } + + #[test] + fn a_scalar_override_replaces_an_object() { + assert_eq!(deep_merge(json!({"a": 1}), json!(3)), json!(3)); + } +} diff --git a/packages/sequent-core/src/election_config/presets.rs b/packages/sequent-core/src/election_config/presets.rs new file mode 100644 index 00000000000..96fbae78249 --- /dev/null +++ b/packages/sequent-core/src/election_config/presets.rs @@ -0,0 +1,749 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Authentication presets: how voters prove who they are. +//! +//! A source document declares this as one `auth_type` parameter. A preset turns +//! that one cell into the realm configuration it implies — an identity provider, +//! an authenticator config, the user-profile permissions that make a field +//! typeable on the login form. +//! +//! Presets are **patches, not realms.** Two reasons, and the second is the +//! important one: +//! +//! * A realm is ~165 kB of interdependent Keycloak configuration whose client +//! `redirectUris` and endpoint URLs belong to the environment it was exported +//! from. Every realm available to copy from is saturated with them. +//! * `keycloak_event_realm` is taken wholesale by the importer: if it is present +//! it *replaces* the environment's own provisioned default rather than merging +//! with it. Emitting an invented realm would silently override configuration +//! that was deployed on purpose. +//! +//! So a preset is applied to a realm supplied as a base export, and is always +//! also written out on its own so that nothing the document asked for is silently +//! dropped. +//! +//! Every value here is transcribed from a realm that works, not invented. The +//! SAML provider relies on `enabledFromMetadata`, which is what lets it carry +//! only a metadata URL: Keycloak fetches the SSO endpoints and the signing +//! certificate from the IdP's metadata, so no certificate is embedded here and +//! none goes stale. + +use serde_json::{json, Map, Value}; +use strum_macros::{Display, EnumString}; + +/// Parameter keys a preset may consume. +/// +/// Anything a preset takes is kept out of the "carried but not interpreted" +/// bucket, so the two never contradict each other. +pub const PARAM_AUTH_TYPE: &str = "auth_type"; +pub const PARAM_SAML_METADATA_URL: &str = "saml_idp_metadata_url"; +pub const PARAM_SAML_IDP_ALIAS: &str = "saml_idp_alias"; +pub const PARAM_SAML_PRINCIPAL_ATTRIBUTE: &str = "saml_principal_attribute"; +pub const PARAM_OTP_SENDER_ID: &str = "otp_sender_id"; +pub const PARAM_OTP_LENGTH: &str = "otp_length"; +pub const PARAM_OTP_TTL_SECONDS: &str = "otp_ttl_seconds"; + +/// The flow an imported SAML identity provider hands first-time logins to. +/// +/// Present in the platform's event realm; a preset naming a flow the target realm +/// does not have is reported rather than applied blindly. +pub const SAML_FIRST_BROKER_FLOW: &str = "saml-first-broker-flow"; +pub const CERTIFICATE_FIRST_LOGIN_FLOW: &str = "certificate-first-login-flow"; + +/// `crate::types::keycloak::CERTIFICATES_IDP_ALIAS`. +/// +/// Import special-cases this alias: it generates a client secret for the matching +/// client and rewrites the provider's URLs, so the alias is not a free choice. +pub const CERTIFICATES_IDP_ALIAS: &str = "digital-certificates"; + +/// The Keycloak authenticator each preset needs the target realm to contain. +pub const OTP_AUTHENTICATOR: &str = "message-otp-authenticator"; +pub const DEFERRED_AUTHENTICATOR_CONFIG: &str = "deferred"; + +/// The OTP config a preset registers under. +pub const OTP_CONFIG_ALIAS: &str = "janitor-otp-by-availability"; + +/// Names the preset that leaves the realm alone, whatever the document declares. +/// +/// Useful while a client has not supplied what a preset needs — the SEIU document, +/// for instance, declares SAML but leaves the IdP metadata URL blank pending their +/// identity provider. +pub const NONE: &str = "none"; + +/// Which collection of the realm a requirement is about. +/// +/// A `&str` before, matched with a `_ =>` arm that meant "authenticator" — so a +/// misspelled kind was checked against the wrong collection and reported nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)] +pub enum RequirementKind { + #[strum(serialize = "flow")] + Flow, + #[strum(serialize = "authenticator")] + Authenticator, + #[strum(serialize = "authenticator_config")] + AuthenticatorConfig, +} + +/// Something a preset needs the target realm to already have. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Requirement { + pub kind: RequirementKind, + pub name: &'static str, + pub why: &'static str, +} + +/// What a preset asks of a realm: a merge, plus two things a merge cannot do. +/// +/// The two directives are separate fields rather than magic keys inside the patch +/// — which is how the Python carried them — so that writing the patch out never +/// has to strip them, and so a caller cannot forget to. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RealmPatch { + /// Merged into the realm, deeply. + pub patch: Map, + + /// Point every execution of an authenticator at a config alias. + pub bind_authenticator_config: Option<(String, String)>, + + /// Changes to named user-profile attributes. + /// + /// The user profile travels as a stringified JSON blob inside a Keycloak + /// component, so it has to be parsed, patched and re-serialised rather than + /// merged. + pub user_profile: Option>, +} + +/// The parameters a preset reads, as resolved from the source document. +#[derive(Debug, Clone, Default)] +pub struct PresetInput { + values: Vec<(String, Value)>, +} + +impl PresetInput { + pub fn new(values: Vec<(String, Value)>) -> Self { + PresetInput { values } + } + + pub fn get(&self, key: &str) -> Option<&Value> { + self.values + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value) + } + + /// A parameter as text, or `fallback` when absent or empty. + fn text(&self, key: &str, fallback: &str) -> String { + match self.get(key) { + Some(Value::String(text)) if !text.trim().is_empty() => { + text.trim().to_string() + } + Some(Value::Null) | None => fallback.to_string(), + Some(Value::String(_)) => fallback.to_string(), + Some(other) => other.to_string(), + } + } +} + +/// One way of authenticating voters. +#[derive(Debug, Clone, Copy)] +pub struct AuthPreset { + pub name: &'static str, + pub summary: &'static str, + + /// Whether a voter needs an email address or mobile number to log in. + /// + /// This is the difference between "56 of 56 voters cannot be sent a one-time + /// code" being a real problem and being noise: under SAML the client's + /// identity provider authenticates the voter, and asking for contact details + /// is not this tool's business. + pub uses_otp: bool, + + pub requires: &'static [Requirement], + pub required_parameters: &'static [&'static str], + pub optional_parameters: &'static [&'static str], + + build: fn(&PresetInput) -> RealmPatch, +} + +impl AuthPreset { + /// Turn the resolved parameters into what the realm needs. + pub fn build(&self, input: &PresetInput) -> RealmPatch { + (self.build)(input) + } + + /// Every parameter key this preset reads. + pub fn consumes(&self) -> Vec<&'static str> { + let mut keys = vec![PARAM_AUTH_TYPE]; + keys.extend(self.required_parameters); + keys.extend(self.optional_parameters); + keys + } +} + +// -- saml_sso_idp_initiated ------------------------------------------------ + +/// A SAML identity provider driven entirely by the IdP's metadata. +/// +/// `enabledFromMetadata` is the whole reason this preset is portable: Keycloak +/// reads `singleSignOnServiceUrl`, `singleLogoutServiceUrl`, the entity id and the +/// signing certificate out of the document at `metadataDescriptorUrl`. So nothing +/// environment-specific and nothing that expires is written here. +/// +/// For IdP-initiated SSO the client's IdP posts an unsolicited assertion to +/// Keycloak's broker endpoint, which is derived from the alias — which is why the +/// alias is worth being able to set. +fn saml_patch(input: &PresetInput) -> RealmPatch { + let alias = input.text(PARAM_SAML_IDP_ALIAS, "client-saml-idp"); + let metadata_url = input.text(PARAM_SAML_METADATA_URL, ""); + let principal_attribute = + input.text(PARAM_SAML_PRINCIPAL_ATTRIBUTE, "username"); + + let patch = json!({ + "identityProviders": [{ + "alias": alias, + "displayName": "Sign in with your organisation", + "providerId": "saml", + "enabled": true, + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + // SP-initiated redirect stays off: the voter arrives from the IdP. + "authenticateByDefault": false, + "linkOnly": false, + "updateProfileFirstLoginMode": "off", + "firstBrokerLoginFlowAlias": SAML_FIRST_BROKER_FLOW, + "config": { + "metadataDescriptorUrl": metadata_url, + // Endpoints and signing certificate come from the metadata. + "enabledFromMetadata": "true", + "validateSignature": "true", + "wantAssertionsSigned": "true", + "wantAssertionsEncrypted": "false", + "wantAuthnRequestsSigned": "false", + "signSpMetadata": "false", + "forceAuthn": "false", + "postBindingResponse": "true", + "postBindingAuthnRequest": "true", + "postBindingLogout": "true", + "backchannelSupported": "false", + // Match the assertion to an existing census voter rather than + // creating one: the census is the roll of who may vote. + "principalType": "ATTRIBUTE", + "principalAttribute": principal_attribute, + "nameIDPolicyFormat": + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "syncMode": "IMPORT", + "allowCreate": "false", + "hideOnLoginPage": "false", + "authnContextComparisonType": "exact", + "allowedClockSkew": "30", + }, + }], + // The IdP is the authority on who the voter is, so the realm must not + // offer a local password or a self-service registration path. + "registrationAllowed": false, + "resetPasswordAllowed": false, + "loginWithEmailAllowed": false, + "rememberMe": false, + }); + + RealmPatch { + patch: object(patch), + ..RealmPatch::default() + } +} + +// -- otp_email_or_sms ------------------------------------------------------ + +/// One OTP step that follows whatever the voter actually has. +/// +/// `messageCourierAttribute: BOTH` is availability-driven rather than a demand for +/// both channels: `Utils.sendCode` guards each channel on a non-empty address, so +/// an email-only voter gets email, a mobile-only voter gets SMS, and a voter with +/// both gets both. +fn otp_config(input: &PresetInput) -> Value { + json!({ + "alias": OTP_CONFIG_ALIAS, + "config": { + "messageCourierAttribute": "BOTH", + "telUserAttribute": "sequent.read-only.mobile-number", + "deferredUserAttribute": "false", + "one-time-link": "false", + "length": input.text(PARAM_OTP_LENGTH, "6"), + "ttl": input.text(PARAM_OTP_TTL_SECONDS, "900"), + "resendCoudActivationTimer": "60", + "max-receiver-reuse": "1", + "senderId": input.text(PARAM_OTP_SENDER_ID, "Sequent"), + "test-mode": "false", + }, + }) +} + +fn otp_patch(input: &PresetInput) -> RealmPatch { + let patch = json!({ + "authenticatorConfig": [otp_config(input)], + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "loginWithEmailAllowed": false, + // There is no password in this flow, so a "forgot password" link is a + // dead end. + "resetPasswordAllowed": false, + "bruteForceProtected": true, + }); + + RealmPatch { + patch: object(patch), + bind_authenticator_config: Some(( + OTP_AUTHENTICATOR.to_string(), + OTP_CONFIG_ALIAS.to_string(), + )), + user_profile: None, + } +} + +// -- voter_link_plus_dob --------------------------------------------------- + +/// Member id prefilled from the link, date of birth typed, then OTP. +/// +/// The date of birth is deliberately **not** prefillable. If it were, the link +/// alone would authenticate the voter, and links get forwarded. +fn voter_link_dob_patch(input: &PresetInput) -> RealmPatch { + let mut result = otp_patch(input); + + let deferred = json!({ + "alias": DEFERRED_AUTHENTICATOR_CONFIG, + "config": { + "form-mode": "LOGIN", + "password-required": "false", + "search-attributes": "username,dateOfBirth", + "hidden-profile-attributes": "locale,firstName,lastName,ssn4", + "prefill-parameters-policy": "ACCEPT", + "user-status": "sequent.read-only.id-card-number-validated", + "password-expiration-user-attribute": + "sequent.read-only.expirationDate", + }, + }); + + if let Some(Value::Array(configs)) = + result.patch.get_mut("authenticatorConfig") + { + configs.push(deferred); + } + + result.user_profile = Some(object(json!({ + "username": { + "permissions": {"view": ["admin", "user"], "edit": ["admin", "user"]}, + "annotations": {"loginHintPrefillPolicy": "READ_ONLY"}, + }, + "dateOfBirth": { + "permissions": {"view": ["admin", "user"], "edit": ["admin", "user"]}, + "required": {"roles": ["user"]}, + // IGNORE on purpose: a prefillable date of birth means the link alone + // authenticates the voter. + "annotations": { + "inputType": "html5-date", + "loginHintPrefillPolicy": "IGNORE", + }, + }, + }))); + result +} + +// -- digital_certificates -------------------------------------------------- + +/// Enable the digital-certificates provider the platform already knows. +/// +/// Only the alias and the enabled flag are set. Import rewrites this provider's +/// URLs and generates its client secret itself — `import_election_event.rs` +/// special-cases exactly this alias — so writing endpoints here would be +/// overwritten at best and wrong at worst. +fn certificates_patch(_: &PresetInput) -> RealmPatch { + let patch = json!({ + "identityProviders": [{ + "alias": CERTIFICATES_IDP_ALIAS, + "providerId": "keycloak-oidc", + "displayName": "Digital Certificates", + "enabled": true, + "trustEmail": false, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "linkOnly": false, + "updateProfileFirstLoginMode": "off", + "firstBrokerLoginFlowAlias": CERTIFICATE_FIRST_LOGIN_FLOW, + }], + "registrationAllowed": false, + "resetPasswordAllowed": false, + }); + + RealmPatch { + patch: object(patch), + ..RealmPatch::default() + } +} + +/// Every preset, in the order a caller should see them listed. +pub const PRESETS: &[AuthPreset] = &[ + AuthPreset { + name: "digital_certificates", + summary: "The voter presents a digital certificate, brokered through the \ + platform's digital-certificates provider.", + uses_otp: false, + build: certificates_patch, + required_parameters: &[], + optional_parameters: &[], + requires: &[Requirement { + kind: RequirementKind::Flow, + name: CERTIFICATE_FIRST_LOGIN_FLOW, + why: "a first-time certificate login is handed to this flow", + }], + }, + AuthPreset { + name: "otp_email_or_sms", + summary: "The voter types their member id, then a one-time code sent to \ + whichever of email and mobile the census holds for them.", + uses_otp: true, + build: otp_patch, + required_parameters: &[], + optional_parameters: &[ + PARAM_OTP_SENDER_ID, + PARAM_OTP_LENGTH, + PARAM_OTP_TTL_SECONDS, + ], + requires: &[Requirement { + kind: RequirementKind::Authenticator, + name: OTP_AUTHENTICATOR, + why: "the one-time code step is bound to this authenticator's config", + }], + }, + AuthPreset { + name: "saml_sso_idp_initiated", + summary: "The client's SAML identity provider authenticates the voter and \ + posts an assertion to Keycloak. Voters need no email address or \ + mobile number.", + uses_otp: false, + build: saml_patch, + required_parameters: &[PARAM_SAML_METADATA_URL], + optional_parameters: &[ + PARAM_SAML_IDP_ALIAS, + PARAM_SAML_PRINCIPAL_ATTRIBUTE, + ], + requires: &[Requirement { + kind: RequirementKind::Flow, + name: SAML_FIRST_BROKER_FLOW, + why: "a first-time SAML login is handed to this flow to match the \ + assertion against an existing census voter", + }], + }, + AuthPreset { + name: "voter_link_plus_dob", + summary: "A voter-specific link carries the member id read-only; the voter \ + types their date of birth, then a one-time code.", + uses_otp: true, + build: voter_link_dob_patch, + required_parameters: &[], + optional_parameters: &[ + PARAM_OTP_SENDER_ID, + PARAM_OTP_LENGTH, + PARAM_OTP_TTL_SECONDS, + ], + requires: &[ + Requirement { + kind: RequirementKind::Authenticator, + name: OTP_AUTHENTICATOR, + why: "the one-time code step is bound to this authenticator's \ + config", + }, + Requirement { + kind: RequirementKind::AuthenticatorConfig, + name: DEFERRED_AUTHENTICATOR_CONFIG, + why: "the login form's fields and prefill policy are set on this \ + config", + }, + ], + }, +]; + +/// The preset named, whatever the case and padding. +pub fn get(name: &str) -> Option<&'static AuthPreset> { + let name = name.trim().to_lowercase(); + PRESETS.iter().find(|preset| preset.name == name) +} + +/// Every preset name, in declaration order. +pub fn names() -> Vec<&'static str> { + PRESETS.iter().map(|preset| preset.name).collect() +} + +/// Keys the presets may consume. +/// +/// A caller keeps these out of the "carried but not interpreted" bucket even when +/// no preset is selected: reporting a key as uninterpreted while a preset would +/// have acted on it contradicts itself. +pub fn all_preset_parameters() -> Vec<&'static str> { + let mut keys: Vec<&'static str> = + PRESETS.iter().flat_map(AuthPreset::consumes).collect(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +/// A `json!` object as a `Map`. The macro's input is an object literal in every +/// call above, so the panic is unreachable. +fn object(value: Value) -> Map { + match value { + Value::Object(object) => object, + _ => unreachable!("preset patches are written as object literals"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(values: &[(&str, &str)]) -> PresetInput { + PresetInput::new( + values + .iter() + .map(|(key, value)| ((*key).to_string(), json!(*value))) + .collect(), + ) + } + + #[test] + fn the_presets_are_listed_in_a_stable_order() { + // A CLI's --help and an SPA's dropdown both read this. + assert_eq!( + names(), + [ + "digital_certificates", + "otp_email_or_sms", + "saml_sso_idp_initiated", + "voter_link_plus_dob", + ] + ); + } + + #[test] + fn a_preset_is_found_however_it_is_written() { + assert_eq!(get("otp_email_or_sms").unwrap().name, "otp_email_or_sms"); + assert_eq!( + get(" OTP_Email_Or_SMS ").unwrap().name, + "otp_email_or_sms" + ); + assert!(get("otp").is_none()); + assert!(get(NONE).is_none()); + } + + #[test] + fn every_preset_declares_what_the_realm_must_already_have() { + // A preset naming a flow the target realm lacks is reported rather than + // applied blindly, which is only possible if it says what it needs. + for preset in PRESETS { + assert!(!preset.requires.is_empty(), "{}", preset.name); + for requirement in preset.requires { + assert!(!requirement.why.is_empty()); + } + } + } + + #[test] + fn every_preset_consumes_the_auth_type_that_selects_it() { + for preset in PRESETS { + assert!(preset.consumes().contains(&PARAM_AUTH_TYPE)); + } + assert!(all_preset_parameters().contains(&PARAM_SAML_METADATA_URL)); + assert!(all_preset_parameters().contains(&PARAM_OTP_LENGTH)); + } + + #[test] + fn the_saml_provider_carries_only_a_metadata_url() { + // enabledFromMetadata is what makes it portable: no endpoint and no + // certificate is written, so nothing here goes stale or belongs to one + // environment. + let patch = get("saml_sso_idp_initiated").unwrap().build(&input(&[( + PARAM_SAML_METADATA_URL, + "https://idp/metadata", + )])); + let provider = &patch.patch["identityProviders"][0]; + + assert_eq!(provider["alias"], json!("client-saml-idp")); + assert_eq!( + provider["config"]["metadataDescriptorUrl"], + json!("https://idp/metadata") + ); + assert_eq!(provider["config"]["enabledFromMetadata"], json!("true")); + assert!(provider["config"].get("singleSignOnServiceUrl").is_none()); + assert!(provider["config"].get("signingCertificate").is_none()); + } + + #[test] + fn the_saml_assertion_is_matched_to_a_census_voter_not_used_to_create_one() + { + // The census is the roll of who may vote. + let patch = get("saml_sso_idp_initiated").unwrap().build(&input(&[( + PARAM_SAML_METADATA_URL, + "https://idp/metadata", + )])); + let config = &patch.patch["identityProviders"][0]["config"]; + assert_eq!(config["principalType"], json!("ATTRIBUTE")); + assert_eq!(config["principalAttribute"], json!("username")); + assert_eq!(config["allowCreate"], json!("false")); + } + + #[test] + fn saml_leaves_no_local_password_or_registration_path() { + // The IdP is the authority on who the voter is. + let patch = get("saml_sso_idp_initiated").unwrap().build(&input(&[( + PARAM_SAML_METADATA_URL, + "https://idp/metadata", + )])); + assert_eq!(patch.patch["registrationAllowed"], json!(false)); + assert_eq!(patch.patch["resetPasswordAllowed"], json!(false)); + assert_eq!(patch.patch["loginWithEmailAllowed"], json!(false)); + } + + #[test] + fn the_saml_alias_and_principal_attribute_can_be_set() { + // The broker endpoint the IdP posts to is derived from the alias. + let patch = get("saml_sso_idp_initiated").unwrap().build(&input(&[ + (PARAM_SAML_METADATA_URL, "https://idp/metadata"), + (PARAM_SAML_IDP_ALIAS, "acme-idp"), + (PARAM_SAML_PRINCIPAL_ATTRIBUTE, "employeeNumber"), + ])); + let provider = &patch.patch["identityProviders"][0]; + assert_eq!(provider["alias"], json!("acme-idp")); + assert_eq!( + provider["config"]["principalAttribute"], + json!("employeeNumber") + ); + } + + #[test] + fn the_otp_step_follows_whatever_the_voter_actually_has() { + // BOTH is availability-driven, not a demand for both channels: + // Utils.sendCode guards each on a non-empty address. + let patch = get("otp_email_or_sms").unwrap().build(&input(&[])); + let config = &patch.patch["authenticatorConfig"][0]["config"]; + assert_eq!(config["messageCourierAttribute"], json!("BOTH")); + assert_eq!(config["length"], json!("6")); + assert_eq!(config["ttl"], json!("900")); + assert_eq!(config["senderId"], json!("Sequent")); + } + + #[test] + fn the_otp_config_is_bound_to_the_authenticator_that_runs_it() { + // Registering the config without binding it leaves the step unconfigured. + let patch = get("otp_email_or_sms").unwrap().build(&input(&[])); + assert_eq!( + patch.bind_authenticator_config, + Some((OTP_AUTHENTICATOR.to_string(), OTP_CONFIG_ALIAS.to_string())) + ); + } + + #[test] + fn the_otp_length_and_lifetime_can_be_set_and_arrive_as_strings() { + // Keycloak config values are strings, whatever a spreadsheet cell was. + let patch = + get("otp_email_or_sms") + .unwrap() + .build(&PresetInput::new(vec![ + (PARAM_OTP_LENGTH.to_string(), json!(8)), + (PARAM_OTP_TTL_SECONDS.to_string(), json!(300)), + (PARAM_OTP_SENDER_ID.to_string(), json!("SEIU")), + ])); + let config = &patch.patch["authenticatorConfig"][0]["config"]; + assert_eq!(config["length"], json!("8")); + assert_eq!(config["ttl"], json!("300")); + assert_eq!(config["senderId"], json!("SEIU")); + } + + #[test] + fn there_is_no_forgotten_password_link_where_there_is_no_password() { + let patch = get("otp_email_or_sms").unwrap().build(&input(&[])); + assert_eq!(patch.patch["resetPasswordAllowed"], json!(false)); + assert_eq!(patch.patch["bruteForceProtected"], json!(true)); + } + + #[test] + fn the_link_preset_adds_a_login_form_on_top_of_the_otp_step() { + let patch = get("voter_link_plus_dob").unwrap().build(&input(&[])); + let configs = patch.patch["authenticatorConfig"].as_array().unwrap(); + assert_eq!(configs.len(), 2); + assert_eq!(configs[0]["alias"], json!(OTP_CONFIG_ALIAS)); + assert_eq!(configs[1]["alias"], json!(DEFERRED_AUTHENTICATOR_CONFIG)); + assert_eq!( + configs[1]["config"]["search-attributes"], + json!("username,dateOfBirth") + ); + } + + #[test] + fn a_date_of_birth_is_never_prefillable_from_the_link() { + // If it were, the link alone would authenticate the voter — and links get + // forwarded. + let patch = get("voter_link_plus_dob").unwrap().build(&input(&[])); + let profile = patch.user_profile.expect("a user profile patch"); + assert_eq!( + profile["username"]["annotations"]["loginHintPrefillPolicy"], + json!("READ_ONLY") + ); + assert_eq!( + profile["dateOfBirth"]["annotations"]["loginHintPrefillPolicy"], + json!("IGNORE") + ); + assert_eq!( + profile["dateOfBirth"]["required"]["roles"], + json!(["user"]) + ); + } + + #[test] + fn the_certificates_preset_sets_the_alias_import_special_cases_and_no_urls() + { + // Import rewrites this provider's URLs and generates its client secret, so + // writing endpoints would be overwritten at best. + let patch = get("digital_certificates").unwrap().build(&input(&[])); + let provider = &patch.patch["identityProviders"][0]; + assert_eq!(provider["alias"], json!(CERTIFICATES_IDP_ALIAS)); + assert_eq!(provider["providerId"], json!("keycloak-oidc")); + assert_eq!(provider["enabled"], json!(true)); + assert!(provider.get("config").is_none()); + } + + #[test] + fn only_the_presets_that_send_a_code_say_they_use_otp() { + // What decides whether "no voter can be sent a code" is a real problem. + assert!(get("otp_email_or_sms").unwrap().uses_otp); + assert!(get("voter_link_plus_dob").unwrap().uses_otp); + assert!(!get("saml_sso_idp_initiated").unwrap().uses_otp); + assert!(!get("digital_certificates").unwrap().uses_otp); + } + + #[test] + fn no_preset_writes_an_environment_specific_url() { + // The reason presets are patches: every realm available to copy from is + // saturated with the hosts of the environment it came from. + for preset in PRESETS { + let built = preset.build(&input(&[( + PARAM_SAML_METADATA_URL, + "https://idp.example.org/metadata", + )])); + let encoded = serde_json::to_string(&built.patch).unwrap(); + for host in [ + "localhost", + "127.0.0.1", + ".sequentech.io", + "https://sequent", + ] { + assert!( + !encoded.contains(host), + "{} writes {host}", + preset.name + ); + } + } + } +} diff --git a/packages/sequent-core/src/election_config/problem.rs b/packages/sequent-core/src/election_config/problem.rs new file mode 100644 index 00000000000..dc5effc6261 --- /dev/null +++ b/packages/sequent-core/src/election_config/problem.rs @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! What validation reports. +//! +//! Structured rather than a formatted string, because the same problem has to be +//! rendered three ways: a line in `step-cli`'s output, a row in a browser's +//! problem list, and an error from a server-side import. Each wants the pieces +//! arranged differently, and only the caller knows which. +//! +//! Every problem carries a machine-readable [`Code`] so a front end can group, +//! translate or link them without matching on English text. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Whether a problem stops an import or merely deserves saying out loud. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// The bundle will not import, or will import into something broken. + Error, + /// The bundle imports, but something about it is very likely a mistake. + /// + /// A warning is not a lesser error: it is a statement that this file is + /// self-consistent but probably not what its author meant. An election with + /// no voting window imports perfectly and then never opens. + Warning, +} + +/// What kind of problem this is. +/// +/// Stable identifiers: a front end may match on these, so renaming one is a +/// breaking change in a way that rewording a message is not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Code { + /// A required field is absent or empty. + MissingField, + /// A value is not one the platform accepts. + InvalidValue, + /// A reference points at something not in the bundle. + DanglingReference, + /// Two entities share an identifier. + DuplicateId, + /// The area parent chain loops. + AreaCycle, + /// A contest's vote counts contradict each other or its candidate list. + ContestArithmetic, + /// `voting_type` and `counting_algorithm` disagree. + TallyMismatch, + /// Something that would be on a ballot is not, or vice versa. + BallotCoverage, + /// An entity is scoped to a permission label, which hides it. + PermissionLabel, + /// An election has no scheduled voting window. + MissingSchedule, + /// Two spreadsheet columns disagree about the shape of the same field. + /// + /// Raised while reading a source document rather than while validating a + /// bundle: the bundle cannot be built at all until the author picks one. + ConflictingColumns, +} + +/// One thing wrong with a bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Problem { + pub severity: Severity, + pub code: Code, + + /// Where in the bundle, as a dotted path — `contests[2].max_votes`. + /// + /// Indexed rather than named because a bundle is what is being validated; + /// the tool that produced it maps this back to a wizard step or a + /// spreadsheet cell, which only it can do. + pub path: String, + + /// What is wrong, in one sentence, in English. + pub message: String, + + /// The entity's `external_id` where it has one. + /// + /// The bundle's UUIDs are regenerated on import and mean nothing to whoever + /// has to fix the source, whereas an `external_id` is what they typed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_id: Option, +} + +impl Problem { + pub fn error( + code: Code, + path: impl Into, + message: impl Into, + ) -> Self { + Problem { + severity: Severity::Error, + code, + path: path.into(), + message: message.into(), + external_id: None, + } + } + + pub fn warning( + code: Code, + path: impl Into, + message: impl Into, + ) -> Self { + Problem { + severity: Severity::Warning, + code, + path: path.into(), + message: message.into(), + external_id: None, + } + } + + pub fn about(mut self, external_id: Option<&str>) -> Self { + self.external_id = external_id.map(str::to_string); + self + } +} + +impl fmt::Display for Problem { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self.severity { + Severity::Error => "error", + Severity::Warning => "warning", + }; + write!(formatter, "{label}: {}: {}", self.path, self.message) + } +} + +/// Everything validation found, in the order it found it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Report { + pub problems: Vec, +} + +impl Report { + pub fn push(&mut self, problem: Problem) { + self.problems.push(problem); + } + + /// Whether the bundle should be refused. + pub fn has_errors(&self) -> bool { + self.problems + .iter() + .any(|problem| problem.severity == Severity::Error) + } + + pub fn errors(&self) -> impl Iterator { + self.iter_severity(Severity::Error) + } + + pub fn warnings(&self) -> impl Iterator { + self.iter_severity(Severity::Warning) + } + + fn iter_severity( + &self, + severity: Severity, + ) -> impl Iterator { + self.problems + .iter() + .filter(move |problem| problem.severity == severity) + } + + pub fn is_empty(&self) -> bool { + self.problems.is_empty() + } +} + +impl fmt::Display for Report { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for problem in &self.problems { + writeln!(formatter, " {problem}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_report_with_only_warnings_is_importable() { + // The distinction is the point: a warning must not block a build. + let mut report = Report::default(); + report.push(Problem::warning( + Code::MissingSchedule, + "elections[0]", + "no window", + )); + assert!(!report.has_errors()); + assert_eq!(report.warnings().count(), 1); + assert_eq!(report.errors().count(), 0); + } + + #[test] + fn one_error_condemns_the_report() { + let mut report = Report::default(); + report.push(Problem::warning(Code::MissingSchedule, "a", "b")); + report.push(Problem::error(Code::MissingField, "c", "d")); + assert!(report.has_errors()); + } + + #[test] + fn a_problem_reads_as_a_line() { + let problem = Problem::error( + Code::DanglingReference, + "candidates[3].contest_id", + "no contest with that id is in the bundle", + ); + assert_eq!( + problem.to_string(), + "error: candidates[3].contest_id: no contest with that id is in the bundle" + ); + } + + #[test] + fn the_code_survives_serialization() { + // A front end matches on this rather than on the English. + let problem = Problem::error(Code::TallyMismatch, "p", "m") + .about(Some("president")); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!(json["code"], "tally_mismatch"); + assert_eq!(json["severity"], "error"); + assert_eq!(json["external_id"], "president"); + } + + #[test] + fn an_absent_external_id_is_omitted_rather_than_null() { + let problem = Problem::error(Code::MissingField, "p", "m"); + let json = serde_json::to_value(&problem).unwrap(); + assert!(json.get("external_id").is_none()); + } +} diff --git a/packages/sequent-core/src/election_config/render.rs b/packages/sequent-core/src/election_config/render.rs new file mode 100644 index 00000000000..42b429ead36 --- /dev/null +++ b/packages/sequent-core/src/election_config/render.rs @@ -0,0 +1,533 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Handlebars rendering of the base entity templates. +//! +//! The templates supply the platform boilerplate; the source document overrides +//! it. That split is what keeps client configuration out of the code: a delivery +//! engineer who needs a different default supplies their own template, without +//! touching the tool. +//! +//! Only ids, timestamps and enum values are interpolated. Client text — names, +//! descriptions, CSS — is deep-merged into the *parsed* result instead, so a +//! spreadsheet cell containing a quote or a backslash cannot produce invalid +//! JSON. +//! +//! The builtin templates are compiled into the binary rather than read from +//! disk, because a browser has no directory to read and downloading eight files +//! before rendering anything would be absurd. Overrides are handed in as text by +//! whoever has a way to obtain them: `step-cli` reads a directory, a SPA can take +//! an upload. +//! +//! Behind the `election_config_templates` feature, so a front end that only +//! validates an existing bundle does not carry a template engine. + +use crate::election_config::problem::{Code, Problem}; +use handlebars::{ + Context, Handlebars, Helper, HelperResult, Output, RenderContext, + RenderErrorReason, +}; +use serde_json::{Map, Value}; + +/// Entity templates that get rendered. An override may replace any of them. +pub const ENTITY_TEMPLATES: &[&str] = &[ + "election_event", + "election", + "contest", + "candidate", + "area", + "area_contest", + "scheduled_event", + "report", +]; + +/// The builtin source for each template, compiled in. +pub const BUILTIN_TEMPLATES: &[(&str, &str)] = &[ + ( + "election_event", + include_str!("templates/election_event.hbs"), + ), + ("election", include_str!("templates/election.hbs")), + ("contest", include_str!("templates/contest.hbs")), + ("candidate", include_str!("templates/candidate.hbs")), + ("area", include_str!("templates/area.hbs")), + ("area_contest", include_str!("templates/area_contest.hbs")), + ( + "scheduled_event", + include_str!("templates/scheduled_event.hbs"), + ), + ("report", include_str!("templates/report.hbs")), +]; + +/// Escape an interpolated value for the inside of a JSON string. +/// +/// Handlebars' default escape function is for HTML, which is the wrong language +/// here: it would turn a quote into `"`, which is valid JSON holding the +/// wrong text. Turning escaping off instead would let a stray quote break the +/// document. +/// +/// The builtin templates only interpolate ids, timestamps and enum values, none +/// of which can contain anything needing this. It exists so that a custom +/// template which does interpolate something less disciplined still renders +/// parseable JSON. +fn escape_json_string(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + // The control characters JSON forbids raw. + character if (character as u32) < 0x20 => { + escaped.push_str(&format!("\\u{:04x}", character as u32)); + } + character => escaped.push(character), + } + } + escaped +} + +/// `{{json value}}` — emit `value` as JSON. +/// +/// For custom templates that do need to interpolate arbitrary data: a client +/// template building an annotations block from spreadsheet columns needs a safe +/// way to do it. Writes straight to the output, which is how it escapes the +/// escaping — the whole point is to emit a JSON literal, and `\"` around an +/// object would not be one. +fn helper_json( + helper: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let value = helper.param(0).map_or(&Value::Null, |param| param.value()); + let encoded = serde_json::to_string(value).map_err(|error| { + RenderErrorReason::Other(format!("could not encode as JSON: {error}")) + })?; + out.write(&encoded)?; + Ok(()) +} + +/// `{{default value "fallback"}}` — `fallback` when the value is absent or empty. +/// +/// Escapes for a JSON string itself, because a helper's output bypasses the +/// engine's escape function and this one is meant to land inside quotes. +fn helper_default( + helper: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let fallback = helper.param(1).map_or(&Value::Null, |param| param.value()); + let value = helper.param(0).map_or(&Value::Null, |param| param.value()); + + let chosen = match value { + Value::Null => fallback, + Value::String(text) if text.is_empty() => fallback, + value => value, + }; + + let text = match chosen { + Value::Null => String::new(), + Value::String(text) => text.clone(), + other => other.to_string(), + }; + out.write(&escape_json_string(&text))?; + Ok(()) +} + +/// Compiled entity templates, with optional overrides. +pub struct TemplateSet { + handlebars: Handlebars<'static>, + overridden: Vec, +} + +impl std::fmt::Debug for TemplateSet { + /// The compiled templates are noise; which ones were replaced is the only + /// thing worth reading in a failure message. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TemplateSet") + .field("overridden", &self.overridden) + .finish_non_exhaustive() + } +} + +impl TemplateSet { + /// The templates compiled into the binary. + pub fn builtin() -> Result { + Self::with_overrides(&[]) + } + + /// The builtins, with any of them replaced by `overrides`. + /// + /// An override for a name that is not an entity template is refused rather + /// than ignored: a file called `elections.hbs` in a templates directory is + /// someone's typo, and silently rendering the builtin instead would leave + /// them staring at output that does not reflect their edit. + pub fn with_overrides(overrides: &[(&str, &str)]) -> Result { + let mut handlebars = Handlebars::new(); + handlebars.register_escape_fn(escape_json_string); + handlebars.set_strict_mode(false); + handlebars.register_helper("json", Box::new(helper_json)); + handlebars.register_helper("default", Box::new(helper_default)); + + for (name, _) in overrides { + if !ENTITY_TEMPLATES.contains(name) { + return Err(Problem::error( + Code::InvalidValue, + format!("templates.{name}"), + format!( + "'{name}' is not an entity template. Expected one of: {}.", + ENTITY_TEMPLATES.join(", ") + ), + )); + } + } + + let mut overridden = Vec::new(); + for (name, builtin) in BUILTIN_TEMPLATES { + let source = match overrides + .iter() + .find(|(override_name, _)| override_name == name) + { + Some((_, source)) => { + overridden.push((*name).to_string()); + *source + } + None => *builtin, + }; + + handlebars.register_template_string(name, source).map_err( + |error| { + Problem::error( + Code::InvalidValue, + format!("templates.{name}"), + format!("could not be compiled: {error}"), + ) + }, + )?; + } + + overridden.sort(); + Ok(TemplateSet { + handlebars, + overridden, + }) + } + + /// Templates being taken from an override, for a caller to report. + pub fn overridden(&self) -> &[String] { + &self.overridden + } + + /// Render `name` and return the raw text. + pub fn render( + &self, + name: &str, + context: &Value, + ) -> Result { + self.handlebars.render(name, context).map_err(|error| { + Problem::error( + Code::InvalidValue, + format!("templates.{name}"), + format!("could not be rendered: {error}"), + ) + }) + } + + /// Render `name` and parse the result as a JSON object. + /// + /// A template that renders invalid JSON is a bug in the template, and the + /// message quotes the lines around the failure — debugging that from a bare + /// parse error is otherwise miserable. + pub fn render_json( + &self, + name: &str, + context: &Value, + ) -> Result, Problem> { + let rendered = self.render(name, context)?; + + let parsed: Value = + serde_json::from_str(&rendered).map_err(|error| { + Problem::error( + Code::InvalidValue, + format!("templates.{name}"), + format!( + "did not render valid JSON: {error}\n{}", + excerpt(&rendered, Some(error.line())) + ), + ) + })?; + + match parsed { + Value::Object(object) => Ok(object), + other => Err(Problem::error( + Code::InvalidValue, + format!("templates.{name}"), + format!( + "rendered a {}, expected a JSON object", + match other { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "list", + Value::Object(_) => unreachable!(), + } + ), + )), + } + } +} + +/// The lines around a parse failure, numbered, with the offending one marked. +fn excerpt(text: &str, line: Option) -> String { + const CONTEXT: usize = 2; + let lines: Vec<&str> = text.lines().collect(); + + let Some(line) = line.filter(|line| *line > 0 && !lines.is_empty()) else { + return lines + .iter() + .take(20) + .enumerate() + .map(|(index, text)| format!(" {:4} | {text}", index + 1)) + .collect::>() + .join("\n"); + }; + + let start = line.saturating_sub(1 + CONTEXT); + let end = (line + CONTEXT).min(lines.len()); + (start..end) + .map(|index| { + let marker = if index == line - 1 { '>' } else { ' ' }; + format!("{marker} {:4} | {}", index + 1, lines[index]) + }) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn context() -> Value { + json!({ + "id": "11111111-1111-5111-8111-111111111111", + "tenant_id": "22222222-2222-5222-8222-222222222222", + "election_event_id": "33333333-3333-5333-8333-333333333333", + "election_id": "44444444-4444-5444-8444-444444444444", + "area_id": "55555555-5555-5555-8555-555555555555", + "contest_id": "66666666-6666-5666-8666-666666666666", + "created_at": "2026-01-01T00:00:00+00:00", + "task_id": "tenant_x_event_y_manage_election_date", + "event_processor": "manage_election_date", + "report_type": "tally", + }) + } + + #[test] + fn every_named_template_is_compiled_in() { + // A name in the list with no source behind it fails at render time, in + // whichever tool happens to reach it first. + for name in ENTITY_TEMPLATES { + assert!( + BUILTIN_TEMPLATES.iter().any(|(builtin, _)| builtin == name), + "{name} has no builtin source" + ); + } + assert_eq!(BUILTIN_TEMPLATES.len(), ENTITY_TEMPLATES.len()); + } + + #[test] + fn every_builtin_renders_valid_json() { + // The templates are data, so nothing else would catch a stray comma. + let templates = TemplateSet::builtin().unwrap(); + for name in ENTITY_TEMPLATES { + let rendered = templates.render_json(name, &context()); + assert!(rendered.is_ok(), "{name}: {:?}", rendered.unwrap_err()); + } + } + + #[test] + fn the_ids_arrive_where_the_template_puts_them() { + let templates = TemplateSet::builtin().unwrap(); + let contest = templates.render_json("contest", &context()).unwrap(); + assert_eq!( + contest["id"], + json!("11111111-1111-5111-8111-111111111111") + ); + assert_eq!( + contest["election_id"], + json!("44444444-4444-5444-8444-444444444444") + ); + // last_updated_at reuses created_at, which is what makes a regenerated + // bundle diff cleanly. + assert_eq!(contest["created_at"], contest["last_updated_at"]); + } + + #[test] + fn a_missing_context_value_renders_empty_rather_than_failing() { + // Strict mode off on purpose: a template referring to a field this entity + // has no value for should leave it blank for the merge to fill, not stop + // the build. + let templates = TemplateSet::builtin().unwrap(); + let area = templates.render_json("area", &json!({})).unwrap(); + assert_eq!(area["id"], json!("")); + } + + #[test] + fn an_interpolated_quote_cannot_break_the_document() { + // The reason the escape function is JSON and not HTML. The builtin + // templates never interpolate anything like this; a custom one might. + let templates = + TemplateSet::with_overrides(&[("area", r#"{"name": "{{id}}"}"#)]) + .unwrap(); + let area = templates + .render_json("area", &json!({"id": r#"a "quoted" \ name"#})) + .unwrap(); + assert_eq!(area["name"], json!(r#"a "quoted" \ name"#)); + } + + #[test] + fn an_interpolated_newline_survives_as_an_escape() { + let templates = + TemplateSet::with_overrides(&[("area", r#"{"name": "{{id}}"}"#)]) + .unwrap(); + let area = templates + .render_json("area", &json!({"id": "two\nlines"})) + .unwrap(); + assert_eq!(area["name"], json!("two\nlines")); + } + + #[test] + fn the_json_helper_emits_a_literal_not_a_quoted_string() { + // What it is for: a whole object from one context value. + let templates = TemplateSet::with_overrides(&[( + "area", + r#"{"annotations": {{json extra}}}"#, + )]) + .unwrap(); + let area = templates + .render_json("area", &json!({"extra": {"a": [1, 2]}})) + .unwrap(); + assert_eq!(area["annotations"], json!({"a": [1, 2]})); + } + + #[test] + fn the_json_helper_escapes_what_it_encodes() { + let templates = TemplateSet::with_overrides(&[( + "area", + r#"{"annotations": {{json extra}}}"#, + )]) + .unwrap(); + let area = templates + .render_json("area", &json!({"extra": {"k": "a \"quote\""}})) + .unwrap(); + assert_eq!(area["annotations"]["k"], json!("a \"quote\"")); + } + + #[test] + fn the_default_helper_fills_in_for_absent_and_empty() { + let templates = TemplateSet::with_overrides(&[( + "area", + r#"{"a": "{{default missing "fallback"}}", "b": "{{default blank "fallback"}}", "c": "{{default present "fallback"}}"}"#, + )]) + .unwrap(); + let area = templates + .render_json("area", &json!({"blank": "", "present": "kept"})) + .unwrap(); + assert_eq!(area["a"], json!("fallback")); + assert_eq!(area["b"], json!("fallback")); + assert_eq!(area["c"], json!("kept")); + } + + #[test] + fn the_default_helper_escapes_for_the_string_it_lands_in() { + // A helper's output bypasses the engine's escape function, so it has to + // do its own or a quote in a fallback breaks the document. + let templates = TemplateSet::with_overrides(&[( + "area", + r#"{"a": "{{default missing "say \"hi\""}}"}"#, + )]) + .unwrap(); + let area = templates.render_json("area", &json!({})).unwrap(); + assert_eq!(area["a"], json!(r#"say "hi""#)); + } + + #[test] + fn an_override_replaces_the_builtin_and_is_reported() { + let templates = + TemplateSet::with_overrides(&[("area", r#"{"mine": true}"#)]) + .unwrap(); + assert_eq!(templates.overridden(), ["area".to_string()]); + assert_eq!( + templates.render_json("area", &context()).unwrap()["mine"], + json!(true) + ); + // The rest still come from the builtins. + assert!(templates.overridden().len() == 1); + assert!(templates.render_json("contest", &context()).is_ok()); + } + + #[test] + fn an_override_for_a_name_nobody_renders_is_refused() { + // Otherwise a typo means silently rendering the builtin, and the author + // staring at output that ignores their edit. + let problem = + TemplateSet::with_overrides(&[("elections", "{}")]).unwrap_err(); + assert_eq!(problem.code, Code::InvalidValue); + assert!(problem.message.contains("not an entity template")); + } + + #[test] + fn a_template_that_does_not_compile_names_itself() { + let problem = + TemplateSet::with_overrides(&[("area", "{{#if}}")]).unwrap_err(); + assert_eq!(problem.path, "templates.area"); + assert!(problem.message.contains("could not be compiled")); + } + + #[test] + fn a_template_that_renders_broken_json_quotes_the_offending_line() { + // A bare parse error against 100 lines of rendered output is not + // debuggable. + let templates = TemplateSet::with_overrides(&[( + "area", + "{\n \"a\": 1,\n \"b\": ,\n \"c\": 3\n}", + )]) + .unwrap(); + let problem = templates.render_json("area", &context()).unwrap_err(); + assert!(problem.message.contains("did not render valid JSON")); + assert!( + problem.message.contains(r#"> 3 | "b": ,"#), + "{}", + problem.message + ); + } + + #[test] + fn a_template_that_renders_something_other_than_an_object_is_refused() { + let templates = + TemplateSet::with_overrides(&[("area", "[1, 2]")]).unwrap(); + let problem = templates.render_json("area", &context()).unwrap_err(); + assert!(problem.message.contains("rendered a list")); + } + + #[test] + fn escaping_leaves_ordinary_text_alone() { + // Cheap to get wrong in a way that mangles every name in a bundle. + assert_eq!( + escape_json_string("Board of Directors"), + "Board of Directors" + ); + assert_eq!(escape_json_string("José-Muñoz"), "José-Muñoz"); + assert_eq!(escape_json_string("a\u{1}b"), "a\\u0001b"); + } +} diff --git a/packages/sequent-core/src/election_config/report.rs b/packages/sequent-core/src/election_config/report.rs new file mode 100644 index 00000000000..771d37feeef --- /dev/null +++ b/packages/sequent-core/src/election_config/report.rs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Report definitions, as they appear in an import bundle. +//! +//! Moved here from `windmill::postgres::reports` and +//! `windmill::services::reports::template_renderer` so that the tools which +//! *write* an import describe reports the same way the importer reads them. +//! windmill re-exports these, so its own call sites are unchanged. +//! +//! The database mapping (`ReportWrapper`, `TryFrom`) deliberately stays in +//! windmill: it needs `tokio_postgres`, which has no place in a module that has +//! to compile to WASM. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use strum_macros::{Display, EnumString, IntoStaticStr}; + +/// How a generated report document is protected. +/// +/// Serialized `snake_case`. There are exactly two: a report is either readable +/// or encrypted with a password configured alongside it. +#[allow(non_camel_case_types)] +#[derive( + Display, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + EnumString, + IntoStaticStr, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum EReportEncryption { + Unencrypted, + ConfiguredPassword, +} + +/// Schedule for a report that regenerates itself and mails the result. +/// +/// Every field defaults, because a report without a cron config is the normal +/// case and an absent key must not fail deserialization. +#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Default)] +pub struct ReportCronConfig { + #[serde(default)] + pub is_active: bool, + #[serde(default)] + pub last_document_produced: Option, + #[serde(default)] + pub cron_expression: String, + #[serde(default)] + pub email_recipients: Vec, + #[serde(default)] + pub executer_username: String, +} + +/// One report definition. +/// +/// `permission_label` is a list here, unlike `Election::permission_label`, which +/// is a single string. Both are matched against the administrator's +/// `permission_labels` attribute, and an entity carrying a label nobody holds is +/// invisible in the Admin Portal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Report { + pub id: String, + pub election_event_id: String, + pub tenant_id: String, + pub election_id: Option, + pub report_type: String, + pub template_alias: Option, + pub encryption_policy: EReportEncryption, + pub cron_config: Option, + pub created_at: DateTime, + pub permission_label: Option>, +} + +/// The kinds of report the platform can generate. +/// +/// `Report::report_type` is a `String` rather than this enum because the column +/// is free text in the database; this is the set a writer should choose from. +#[allow(non_camel_case_types)] +#[derive( + Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, +)] +pub enum ReportType { + INITIALIZATION_REPORT, + ELECTORAL_RESULTS, + BALLOT_IMAGES, + BALLOT_RECEIPT, + ACTIVITY_LOGS, + MANUAL_VERIFICATION, + PARTICIPATION_REPORT, + CREDENTIALS, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn encryption_policy_serializes_snake_case() { + // The importer reads this straight out of a CSV column, so the wire form + // is part of the file format rather than an implementation detail. + assert_eq!( + serde_json::to_string(&EReportEncryption::ConfiguredPassword) + .unwrap(), + "\"configured_password\"" + ); + assert_eq!( + serde_json::to_string(&EReportEncryption::Unencrypted).unwrap(), + "\"unencrypted\"" + ); + } + + #[test] + fn encryption_policy_parses_from_the_csv_spelling() { + assert_eq!( + EReportEncryption::from_str("configured_password").unwrap(), + EReportEncryption::ConfiguredPassword + ); + assert!(EReportEncryption::from_str("generated_password").is_err()); + } + + #[test] + fn cron_config_tolerates_an_empty_object() { + // An absent key must not fail deserialization: most reports have no cron. + let parsed: ReportCronConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed, ReportCronConfig::default()); + assert!(!parsed.is_active); + } + + #[test] + fn cron_config_round_trips_the_shape_the_workbook_writes() { + let source = r#"{"is_active":true,"last_document_produced":null, + "cron_expression":"46 0 * * *","email_recipients":["ops@example.org"], + "executer_username":"admin"}"#; + let parsed: ReportCronConfig = serde_json::from_str(source).unwrap(); + assert!(parsed.is_active); + assert_eq!(parsed.cron_expression, "46 0 * * *"); + assert_eq!(parsed.email_recipients, vec!["ops@example.org"]); + } + + #[test] + fn every_report_type_round_trips() { + for name in [ + "INITIALIZATION_REPORT", + "ELECTORAL_RESULTS", + "BALLOT_IMAGES", + "BALLOT_RECEIPT", + "ACTIVITY_LOGS", + "MANUAL_VERIFICATION", + "PARTICIPATION_REPORT", + "CREDENTIALS", + ] { + let parsed = ReportType::from_str(name) + .unwrap_or_else(|_| panic!("{name} should be a ReportType")); + assert_eq!(parsed.to_string(), name); + } + } + + #[test] + fn permission_label_is_a_list() { + // Election::permission_label is a single string; getting these the wrong + // way round fails deserialization at import time. + let source = r#"{"id":"a","election_event_id":"b","tenant_id":"c", + "election_id":null,"report_type":"ACTIVITY_LOGS","template_alias":null, + "encryption_policy":"unencrypted","cron_config":null, + "created_at":"2026-01-01T00:00:00Z","permission_label":["x","y"]}"#; + let parsed: Report = serde_json::from_str(source).unwrap(); + assert_eq!(parsed.permission_label, Some(vec!["x".into(), "y".into()])); + } +} diff --git a/packages/sequent-core/src/election_config/schema.rs b/packages/sequent-core/src/election_config/schema.rs new file mode 100644 index 00000000000..aa63316457f --- /dev/null +++ b/packages/sequent-core/src/election_config/schema.rs @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The election event import bundle. +//! +//! This is the shape of `export_election_event-.json`, and the single +//! definition of it. It was previously declared in +//! `windmill::services::import::import_election_event`, which meant every tool +//! that *wrote* an import had to reproduce it — janitor in Handlebars templates, +//! the Election Architect in a hand-built TypeScript object — and each +//! reproduction drifted. +//! +//! windmill re-exports this, so its own call sites are unchanged. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::types::hasura::core::{ + Application, Area, AreaContest, Candidate, Contest, Election, + ElectionEvent, KeysCeremony, +}; +use crate::types::scheduled_event::ScheduledEvent; +use crate::util::version::HISTORICAL_DEFAULT_VERSION; + +use super::report::Report; + +/// Everything an election event import carries. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ImportElectionEventSchema { + /// The tenant the bundle was exported from. + /// + /// A `String` rather than a `Uuid` on purpose. Import replaces this value + /// with the tenant of the importing request regardless of what it says + /// (`replace_ids`: "always replace to ensure consistency"), so it is a + /// placeholder rather than a destination, and every consumer only ever + /// stringifies it. + /// + /// Keeping it a `String` also keeps `uuid` out of this module. That crate is + /// only enabled by the `keycloak` feature here, and pulling it into + /// `default_features` would put `getrandom` in the WASM build for no benefit. + /// Its format is checked by validation instead, which reports a readable + /// problem where a `Uuid` field would have produced an opaque serde error. + pub tenant_id: String, + + /// The Keycloak realm, carried opaquely. + /// + /// Deliberately a `Value` and not a `RealmRepresentation`: that type comes + /// from the `keycloak` crate, which pulls `reqwest`, and this module has to + /// compile to WASM. Nothing is lost — serde round-trips it exactly, and + /// windmill deserializes it into the typed form where it actually talks to + /// Keycloak. + /// + /// Validating a realm needs a live Keycloak, so it is not something the + /// shared validation could check even if the type were available. + pub keycloak_event_realm: Option, + + pub election_event: ElectionEvent, + pub elections: Vec, + pub contests: Vec, + pub candidates: Vec, + pub areas: Vec, + pub area_contests: Vec, + + /// The voting window. + /// + /// Normally `None`: the importer reads scheduled events from the + /// `export_scheduled_events-.csv` member of the zip, not from here. + pub scheduled_events: Option>, + + /// Report definitions. + /// + /// Normally empty for the same reason: `insert_reports` is only ever called + /// from `process_reports_file`, so reports travel in + /// `export_reports-.csv` and a populated array here is silently + /// dropped. The field is required by the format, so it must still be present. + pub reports: Vec, + + pub keys_ceremonies: Option>, + pub applications: Option>, + + /// The platform version that wrote the bundle. + /// + /// Defaults to the first version that recorded one, so bundles predating the + /// field still import. + #[serde(default = "default_version")] + pub version: String, +} + +fn default_version() -> String { + HISTORICAL_DEFAULT_VERSION.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The smallest bundle that deserializes. Every field here is one the format + /// requires; anything omitted below has a serde default. + const MINIMAL: &str = r#"{ + "tenant_id": "9384db41-1b21-4b93-a6aa-edfc007136d8", + "keycloak_event_realm": null, + "election_event": { + "id": "11111111-1111-5111-8111-111111111111", + "tenant_id": "9384db41-1b21-4b93-a6aa-edfc007136d8", + "is_archived": false, + "encryption_protocol": "RSA256" + }, + "elections": [], + "contests": [], + "candidates": [], + "areas": [], + "area_contests": [], + "scheduled_events": null, + "reports": [], + "keys_ceremonies": [], + "applications": [] + }"#; + + #[test] + fn a_minimal_bundle_deserializes() { + let parsed: ImportElectionEventSchema = + serde_json::from_str(MINIMAL).unwrap(); + assert_eq!(parsed.election_event.encryption_protocol, "RSA256"); + assert!(parsed.keycloak_event_realm.is_none()); + } + + #[test] + fn a_missing_version_falls_back_to_the_historical_default() { + // Bundles written before the field existed must still import. + let parsed: ImportElectionEventSchema = + serde_json::from_str(MINIMAL).unwrap(); + assert_eq!(parsed.version, HISTORICAL_DEFAULT_VERSION); + } + + #[test] + fn the_realm_round_trips_untouched() { + // Carried opaquely, so whatever the exporter wrote comes back byte for + // byte — including keys this crate has never heard of. + let source = MINIMAL.replace( + "\"keycloak_event_realm\": null", + r#""keycloak_event_realm": {"realm":"r","displayName":"D","somethingNew":[1,2]}"#, + ); + let parsed: ImportElectionEventSchema = + serde_json::from_str(&source).unwrap(); + let realm = parsed.keycloak_event_realm.as_ref().unwrap(); + assert_eq!(realm["displayName"], "D"); + assert_eq!(realm["somethingNew"], serde_json::json!([1, 2])); + + let round_tripped = serde_json::to_value(&parsed).unwrap(); + assert_eq!(&round_tripped["keycloak_event_realm"], realm); + } + + #[test] + fn a_missing_required_field_is_rejected() { + // encryption_protocol is not Option, so its absence fails the whole file + // at parse time. This is the error the shared validation exists to + // pre-empt with something readable. + // + // Built by removing the key from the parsed tree rather than by editing + // the text: a string replacement that stops matching would silently pass + // this test instead of failing it. + let mut tree: serde_json::Value = + serde_json::from_str(MINIMAL).unwrap(); + let removed = tree["election_event"] + .as_object_mut() + .unwrap() + .remove("encryption_protocol"); + assert!(removed.is_some(), "the fixture should have had the field"); + + let parsed: Result = + serde_json::from_value(tree); + assert!(parsed.is_err()); + } + + #[test] + fn tenant_id_is_carried_as_written() { + // Import replaces it, so it is a placeholder; it must survive unaltered + // so that replace_ids can map it. + let parsed: ImportElectionEventSchema = + serde_json::from_str(MINIMAL).unwrap(); + assert_eq!(parsed.tenant_id, "9384db41-1b21-4b93-a6aa-edfc007136d8"); + } +} diff --git a/packages/sequent-core/src/election_config/sheet.rs b/packages/sequent-core/src/election_config/sheet.rs new file mode 100644 index 00000000000..51cade2c518 --- /dev/null +++ b/packages/sequent-core/src/election_config/sheet.rs @@ -0,0 +1,772 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! The shape of an authoring document: sheets of rows of coerced cells. +//! +//! One sheet per entity, header row first, one row per entity. This module does +//! no interpretation beyond coercing cells and remembering where each row came +//! from; resolving references and applying templates is a later step's job. +//! +//! Pure, and deliberately unaware of any spreadsheet library. Everything here +//! works on [`Cell`]s, so a `.xlsx` reader, a CSV reader and a browser form all +//! feed the same code — the `xlsx` module is the only one that needs a file +//! format. Which means the whole of table shaping, the part with the awkward +//! cases in it, is testable without a fixture file. +//! +//! Known gap: a problem found here carries its [`Origin`] flattened into +//! `Problem::path`. A spreadsheet front end that wants to highlight the offending +//! cell needs the sheet, row and column separately, which will mean a structured +//! origin on `Problem` — worth doing when there is a UI to consume it, not +//! before. + +use crate::election_config::paths::{coerce_cell, expand, Cell}; +use crate::election_config::problem::{Code, Problem}; +use serde_json::{Map, Value}; +use std::fmt; + +/// Sheet keys this module understands. +/// +/// Normalised: case and internal whitespace are removed, so `Admin Users`, +/// `admin users` and `AdminUsers` are one sheet. Authors rename tabs. +pub const SHEET_ELECTION_EVENT: &str = "electionevent"; +pub const SHEET_ELECTIONS: &str = "elections"; +pub const SHEET_CONTESTS: &str = "contests"; +pub const SHEET_CANDIDATES: &str = "candidates"; +pub const SHEET_AREAS: &str = "areas"; +pub const SHEET_AREA_CONTESTS: &str = "areacontests"; +pub const SHEET_VOTERS: &str = "voters"; +pub const SHEET_SCHEDULED_EVENTS: &str = "scheduledevents"; +pub const SHEET_PARAMETERS: &str = "parameters"; +pub const SHEET_ADMIN_USERS: &str = "adminusers"; +pub const SHEET_PERMISSIONS: &str = "permissions"; +pub const SHEET_TEMPLATES: &str = "templates"; +pub const SHEET_REPORTS: &str = "reports"; + +/// Every sheet that carries meaning. Anything else is reported as unread, which +/// is how a renamed or misspelled tab gets noticed instead of silently ignored. +pub const KNOWN_SHEETS: &[&str] = &[ + SHEET_ELECTION_EVENT, + SHEET_ELECTIONS, + SHEET_CONTESTS, + SHEET_CANDIDATES, + SHEET_AREAS, + SHEET_AREA_CONTESTS, + SHEET_VOTERS, + SHEET_SCHEDULED_EVENTS, + SHEET_PARAMETERS, + SHEET_ADMIN_USERS, + SHEET_PERMISSIONS, + SHEET_TEMPLATES, + SHEET_REPORTS, +]; + +/// Columns whose cells hold `||`-separated lists, for one sheet. +/// +/// Per sheet rather than global, because the same column name has different +/// arity in different places: `Election::permission_label` is `Option` +/// while `Report::permission_label` is `Option>`. Treating the name +/// as multi-valued everywhere turns the first into a list and fails +/// deserialization — which is exactly the bug this shape prevents. +pub fn multi_value_columns(sheet_key: &str) -> &'static [&'static str] { + match sheet_key { + SHEET_VOTERS => &["authorized-election-ids"], + SHEET_ADMIN_USERS => &["permission_labels", "authorized-election-ids"], + SHEET_REPORTS => &["permission_label"], + _ => &[], + } +} + +/// `"Admin Users"` -> `"adminusers"`. +pub fn normalise_sheet_name(name: &str) -> String { + name.chars() + .filter(|character| !character.is_whitespace()) + .flat_map(char::to_lowercase) + .collect() +} + +/// Where in the source document something is, for a message someone can act on. +/// +/// A bundle path like `elections[3].title` is no help to whoever has to fix the +/// spreadsheet; the sheet name and the row number as the spreadsheet shows it +/// are. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Origin { + pub sheet: String, + pub row: usize, + pub column: Option, +} + +impl fmt::Display for Origin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "sheet '{}' row {}", self.sheet, self.row)?; + if let Some(column) = &self.column { + write!(formatter, " column '{column}'")?; + } + Ok(()) + } +} + +/// One entity's worth of cells, plus where it came from. +#[derive(Debug, Clone, PartialEq)] +pub struct Row { + pub sheet: String, + + /// Row number as the spreadsheet shows it, so a message is actionable. + pub number: usize, + + /// Non-blank cells only, keyed by raw dotted header, in column order. + /// + /// Ordered rather than a map because two columns writing the same path have + /// to resolve left to right, the way someone reading the sheet would expect. + /// Blank cells are absent rather than null: the author said nothing about + /// them, so a template default survives. + pub cells: Vec<(String, Value)>, +} + +impl Row { + pub fn origin(&self, column: Option<&str>) -> Origin { + Origin { + sheet: self.sheet.clone(), + row: self.number, + column: column.map(str::to_string), + } + } + + pub fn get(&self, column: &str) -> Option<&Value> { + self.cells + .iter() + .find(|(header, _)| header == column) + .map(|(_, value)| value) + } + + /// The cell as text, for the reference columns that are always strings. + pub fn text(&self, column: &str) -> Option<&str> { + self.get(column).and_then(Value::as_str) + } + + /// The cell, or a problem naming the empty one. + pub fn require(&self, column: &str) -> Result<&Value, Problem> { + self.get(column).ok_or_else(|| { + Problem::error( + Code::MissingField, + self.origin(Some(column)).to_string(), + format!("'{column}' is required and this row leaves it empty"), + ) + }) + } + + /// Cells minus `exclude`, in column order. + /// + /// Used to strip the reference and control columns before the rest of the + /// row is merged onto a rendered template as dotted-path overrides. + pub fn without(&self, exclude: &[&str]) -> Vec<(String, Value)> { + self.cells + .iter() + .filter(|(header, _)| !exclude.contains(&header.as_str())) + .cloned() + .collect() + } + + /// The row as a nested object, ready to deep-merge onto a template. + pub fn overrides( + &self, + exclude: &[&str], + ) -> Result, Problem> { + expand(&self.without(exclude)).map_err(|problem| Problem { + // Re-point at the cell: `expand` only knows the header. + path: self.origin(Some(problem.path.as_str())).to_string(), + ..problem + }) + } +} + +/// One worksheet, read and coerced. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Sheet { + /// Name as it appears in the document, for messages. + pub name: String, + + /// Normalised name, for lookup. + pub key: String, + + /// Headers in column order. A blank header is kept as an empty string so + /// column positions still line up with the cells beneath them. + pub headers: Vec, + + pub rows: Vec, +} + +impl Sheet { + /// Shape a grid of cells into a sheet: first row is headers, rest are rows. + /// + /// Blank rows are dropped rather than trusted. A spreadsheet's stored + /// dimensions count rows that were merely visited — the SEIU1000 sample + /// reports a thousand rows for a sheet holding two — so emptiness has to be + /// decided from the cells. + pub fn from_grid( + name: impl Into, + grid: &[Vec], + ) -> Result { + let name: String = name.into(); + let key = normalise_sheet_name(&name); + + let Some(header_cells) = grid.first() else { + return Ok(Sheet { + name, + key, + ..Sheet::default() + }); + }; + + let headers = read_headers(&name, header_cells)?; + let multi_value = multi_value_columns(&key); + + let mut rows = Vec::new(); + for (offset, cells) in grid.iter().skip(1).enumerate() { + // +1 for the header row, +1 because spreadsheets count from one. + let number = offset + 2; + if let Some(row) = + read_row(&name, number, &headers, cells, multi_value) + { + rows.push(row); + } + } + + Ok(Sheet { + name, + key, + headers, + rows, + }) + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + pub fn len(&self) -> usize { + self.rows.len() + } +} + +fn read_headers( + sheet_name: &str, + cells: &[Cell], +) -> Result, Problem> { + let mut headers: Vec = Vec::with_capacity(cells.len()); + + for (index, cell) in cells.iter().enumerate() { + let header = match cell { + // A blank header means no column. Trailing blank columns are as + // common as trailing blank rows and mean nothing; keeping the + // placeholder preserves the positions of the ones that follow. + Cell::Blank => String::new(), + Cell::Text(text) => text.trim().to_string(), + other => { + match crate::election_config::paths::coerce_scalar_cell(other) { + Some(Value::String(text)) => text, + Some(value) => value.to_string(), + None => String::new(), + } + } + }; + + if !header.is_empty() { + if let Some(first) = headers.iter().position(|seen| seen == &header) + { + return Err(Problem::error( + Code::ConflictingColumns, + format!("sheet '{sheet_name}'"), + format!( + "column '{header}' appears twice, at positions {} and {}. \ + Which one wins would be arbitrary.", + first + 1, + index + 1 + ), + )); + } + } + headers.push(header); + } + + Ok(headers) +} + +/// One data row, or `None` if it holds nothing. +fn read_row( + sheet_name: &str, + number: usize, + headers: &[String], + cells: &[Cell], + multi_value: &[&str], +) -> Option { + let mut values: Vec<(String, Value)> = Vec::new(); + + for (header, cell) in headers.iter().zip(cells) { + if header.is_empty() || cell.is_blank() { + continue; + } + if let Some(value) = + coerce_cell(cell, multi_value.contains(&header.as_str())) + { + values.push((header.clone(), value)); + } + } + + if values.is_empty() { + return None; + } + Some(Row { + sheet: sheet_name.to_string(), + number, + cells: values, + }) +} + +/// A whole authoring document: normalised sheets of coerced rows. +#[derive(Debug, Clone, Default)] +pub struct Workbook { + sheets: Vec, +} + +impl Workbook { + /// Refuses two sheets that normalise to the same key. + /// + /// `Admin Users` beside `AdminUsers` is not a document with a duplicate tab; + /// it is a document where nobody knows which tab is live, and picking one + /// silently would eventually import the wrong voters. + pub fn new(sheets: Vec) -> Result { + for (index, sheet) in sheets.iter().enumerate() { + if let Some(earlier) = + sheets[..index].iter().find(|seen| seen.key == sheet.key) + { + return Err(Problem::error( + Code::ConflictingColumns, + format!("sheet '{}'", sheet.name), + format!( + "'{}' and '{}' are the same sheet once names are \ + normalised. Which one is meant cannot be guessed.", + earlier.name, sheet.name + ), + )); + } + } + Ok(Workbook { sheets }) + } + + pub fn sheet(&self, key: &str) -> Option<&Sheet> { + self.sheets.iter().find(|sheet| sheet.key == key) + } + + /// The sheet's rows, or none. + /// + /// Absent and empty are the same thing to every caller: a document with no + /// `Reports` sheet and one with an empty `Reports` sheet both mean no + /// reports. + pub fn rows(&self, key: &str) -> &[Row] { + self.sheet(key).map_or(&[], |sheet| sheet.rows.as_slice()) + } + + pub fn has(&self, key: &str) -> bool { + self.sheet(key).is_some() + } + + /// Sheets that carry no meaning here, so a caller can warn about a typo. + pub fn unread_sheets(&self) -> Vec<&str> { + let mut names: Vec<&str> = self + .sheets + .iter() + .filter(|sheet| !KNOWN_SHEETS.contains(&sheet.key.as_str())) + .map(|sheet| sheet.name.as_str()) + .collect(); + names.sort_unstable(); + names + } + + pub fn sheet_names(&self) -> Vec<&str> { + self.sheets + .iter() + .map(|sheet| sheet.name.as_str()) + .collect() + } + + pub fn sheets(&self) -> &[Sheet] { + &self.sheets + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn text(value: &str) -> Cell { + Cell::text(value) + } + + fn grid(rows: Vec>) -> Vec> { + rows + } + + #[test] + fn a_tab_name_normalises_to_a_key() { + // Authors rename tabs, and "Admin Users" is the same sheet as + // "adminusers". + assert_eq!(normalise_sheet_name("Admin Users"), "adminusers"); + assert_eq!(normalise_sheet_name(" ELECTIONS "), "elections"); + assert_eq!(normalise_sheet_name("Area\tContests"), "areacontests"); + } + + #[test] + fn every_named_sheet_is_in_the_known_list() { + // A constant nobody added to the list would be read but reported unread. + for key in [ + SHEET_ELECTION_EVENT, + SHEET_ELECTIONS, + SHEET_CONTESTS, + SHEET_CANDIDATES, + SHEET_AREAS, + SHEET_AREA_CONTESTS, + SHEET_VOTERS, + SHEET_SCHEDULED_EVENTS, + SHEET_PARAMETERS, + SHEET_ADMIN_USERS, + SHEET_PERMISSIONS, + SHEET_TEMPLATES, + SHEET_REPORTS, + ] { + assert!( + KNOWN_SHEETS.contains(&key), + "{key} missing from KNOWN_SHEETS" + ); + } + } + + #[test] + fn multi_value_is_decided_per_sheet_not_globally() { + // Election::permission_label is Option and + // Report::permission_label is Option>. One name, two arities. + assert!( + multi_value_columns(SHEET_REPORTS).contains(&"permission_label") + ); + assert!( + !multi_value_columns(SHEET_ELECTIONS).contains(&"permission_label") + ); + } + + #[test] + fn a_permission_label_stays_a_string_on_the_elections_sheet() { + // The regression this shape exists for: as a list it fails to + // deserialize into Option. + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("external_id"), text("permission_label")], + vec![text("board"), text("statewide-officers")], + ]), + ) + .unwrap(); + assert_eq!( + sheet.rows[0].get("permission_label"), + Some(&json!("statewide-officers")) + ); + } + + #[test] + fn the_same_column_on_the_reports_sheet_is_a_list() { + let sheet = Sheet::from_grid( + "Reports", + &grid(vec![ + vec![text("external_id"), text("permission_label")], + vec![text("tally"), text("a || b")], + ]), + ) + .unwrap(); + assert_eq!( + sheet.rows[0].get("permission_label"), + Some(&json!(["a", "b"])) + ); + } + + #[test] + fn a_single_value_in_a_multi_value_column_is_still_a_list() { + let sheet = Sheet::from_grid( + "Reports", + &grid(vec![vec![text("permission_label")], vec![text("only")]]), + ) + .unwrap(); + assert_eq!( + sheet.rows[0].get("permission_label"), + Some(&json!(["only"])) + ); + } + + #[test] + fn blank_rows_are_dropped_rather_than_trusted() { + // A spreadsheet's stored dimensions count rows that were merely visited: + // the SEIU1000 sample claims a thousand rows for a sheet holding two. + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("external_id")], + vec![text("board")], + vec![Cell::Blank], + vec![text(" ")], + vec![text("council")], + vec![Cell::Blank], + ]), + ) + .unwrap(); + assert_eq!(sheet.len(), 2); + assert_eq!(sheet.rows[0].text("external_id"), Some("board")); + assert_eq!(sheet.rows[1].text("external_id"), Some("council")); + } + + #[test] + fn a_row_number_is_the_one_the_spreadsheet_shows() { + // Off by one here means an author looking at the wrong line. + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("external_id")], + vec![text("first")], + vec![text("second")], + ]), + ) + .unwrap(); + assert_eq!(sheet.rows[0].number, 2); + assert_eq!(sheet.rows[1].number, 3); + } + + #[test] + fn a_dropped_blank_row_does_not_shift_the_numbers_after_it() { + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("external_id")], + vec![Cell::Blank], + vec![text("after the gap")], + ]), + ) + .unwrap(); + assert_eq!(sheet.rows[0].number, 3); + } + + #[test] + fn a_blank_header_ends_the_table_without_shifting_the_rest() { + // Trailing blank columns are as common as trailing blank rows. + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("a"), Cell::Blank, text("c")], + vec![text("1"), text("ignored"), text("3")], + ]), + ) + .unwrap(); + assert_eq!(sheet.headers, vec!["a", "", "c"]); + assert_eq!(sheet.rows[0].cells.len(), 2); + assert_eq!(sheet.rows[0].get("c"), Some(&json!("3"))); + } + + #[test] + fn a_duplicated_column_is_refused() { + // Which one wins would be arbitrary, and the author cannot see the + // difference. + let problem = Sheet::from_grid( + "Elections", + &grid(vec![vec![text("title"), text("title")]]), + ) + .unwrap_err(); + assert_eq!(problem.code, Code::ConflictingColumns); + assert!(problem.message.contains("positions 1 and 2")); + } + + #[test] + fn a_header_that_differs_only_by_padding_is_still_a_duplicate() { + assert!(Sheet::from_grid( + "Elections", + &grid(vec![vec![text("title"), text(" title ")]]), + ) + .is_err()); + } + + #[test] + fn a_text_cell_that_looks_numeric_stays_text() { + // Deliberate, and the same as the Python: a spreadsheet hands over a + // number for a numeric cell, so text here means the author formatted the + // column as text — which is how member id 007 stays 007. A number typed + // as a number arrives as `Cell::Float` and does become an integer. + let sheet = Sheet::from_grid( + "Voters", + &grid(vec![ + vec![text("member_id"), text("max_votes")], + vec![text("007"), Cell::Float(3.0)], + ]), + ) + .unwrap(); + assert_eq!(sheet.rows[0].get("member_id"), Some(&json!("007"))); + assert_eq!(sheet.rows[0].get("max_votes"), Some(&json!(3))); + } + + #[test] + fn a_numeric_header_is_read_as_a_name() { + // A year used as a column name is a name, not a number. + let sheet = Sheet::from_grid( + "Parameters", + &grid(vec![vec![Cell::Int(2027)], vec![text("x")]]), + ) + .unwrap(); + assert_eq!(sheet.headers, vec!["2027"]); + } + + #[test] + fn an_empty_grid_is_an_empty_sheet_rather_than_an_error() { + let sheet = Sheet::from_grid("Reports", &grid(vec![])).unwrap(); + assert!(sheet.is_empty()); + assert_eq!(sheet.key, "reports"); + } + + #[test] + fn a_header_row_with_no_rows_under_it_is_empty_too() { + let sheet = + Sheet::from_grid("Reports", &grid(vec![vec![text("external_id")]])) + .unwrap(); + assert!(sheet.is_empty()); + assert_eq!(sheet.headers, vec!["external_id"]); + } + + #[test] + fn short_rows_do_not_invent_cells() { + // Spreadsheets truncate trailing empties; zip stops at the shorter side. + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![vec![text("a"), text("b"), text("c")], vec![text("1")]]), + ) + .unwrap(); + assert_eq!(sheet.rows[0].cells.len(), 1); + assert_eq!(sheet.rows[0].get("b"), None); + } + + #[test] + fn a_row_hands_over_its_overrides_as_nested_json() { + let sheet = Sheet::from_grid( + "Contests", + &grid(vec![ + vec![ + text("external_id"), + text("election_id"), + text("presentation.i18n.en.name"), + text("max_votes"), + ], + vec![ + text("president"), + text("board"), + text("President"), + Cell::Float(3.0), + ], + ]), + ) + .unwrap(); + + let overrides = sheet.rows[0] + .overrides(&["external_id", "election_id"]) + .unwrap(); + assert_eq!( + Value::Object(overrides), + json!({ + "presentation": {"i18n": {"en": {"name": "President"}}}, + "max_votes": 3, + }) + ); + } + + #[test] + fn a_shape_conflict_in_a_row_names_the_cell_not_just_the_column() { + // The author has to find it in the spreadsheet. + let problem = Sheet::from_grid( + "Contests", + &grid(vec![ + vec![text("presentation"), text("presentation.i18n")], + vec![text("plain"), text("{}")], + ]), + ) + .unwrap() + .rows[0] + .overrides(&[]) + .unwrap_err(); + assert_eq!(problem.code, Code::ConflictingColumns); + assert!(problem.path.contains("sheet 'Contests' row 2")); + assert!(problem.path.contains("presentation.i18n")); + } + + #[test] + fn a_missing_required_cell_names_where_to_look() { + let sheet = Sheet::from_grid( + "Elections", + &grid(vec![ + vec![text("external_id"), text("title")], + vec![text("board"), Cell::Blank], + ]), + ) + .unwrap(); + let problem = sheet.rows[0].require("title").unwrap_err(); + assert_eq!(problem.code, Code::MissingField); + assert_eq!(problem.path, "sheet 'Elections' row 2 column 'title'"); + } + + #[test] + fn an_origin_reads_as_a_place() { + let row = Row { + sheet: "Voters".to_string(), + number: 47, + cells: vec![], + }; + assert_eq!(row.origin(None).to_string(), "sheet 'Voters' row 47"); + assert_eq!( + row.origin(Some("email")).to_string(), + "sheet 'Voters' row 47 column 'email'" + ); + } + + #[test] + fn an_absent_sheet_and_an_empty_one_read_the_same() { + // Callers treat both as "no reports", so neither may be a special case. + let workbook = Workbook::new(vec![Sheet::from_grid( + "Reports", + &grid(vec![vec![text("external_id")]]), + ) + .unwrap()]) + .unwrap(); + assert!(workbook.rows(SHEET_REPORTS).is_empty()); + assert!(workbook.rows(SHEET_VOTERS).is_empty()); + assert!(workbook.has(SHEET_REPORTS)); + assert!(!workbook.has(SHEET_VOTERS)); + } + + #[test] + fn two_tabs_that_normalise_alike_are_refused() { + // Not a duplicate tab: a document where nobody knows which tab is live. + let problem = Workbook::new(vec![ + Sheet::from_grid("Admin Users", &grid(vec![])).unwrap(), + Sheet::from_grid("AdminUsers", &grid(vec![])).unwrap(), + ]) + .unwrap_err(); + assert!(problem.message.contains("the same sheet")); + } + + #[test] + fn sheets_nobody_reads_are_listed_so_a_typo_shows_up() { + let workbook = Workbook::new(vec![ + Sheet::from_grid("Elections", &grid(vec![])).unwrap(), + Sheet::from_grid("Electons", &grid(vec![])).unwrap(), + Sheet::from_grid("Read Me", &grid(vec![])).unwrap(), + ]) + .unwrap(); + assert_eq!(workbook.unread_sheets(), vec!["Electons", "Read Me"]); + assert_eq!( + workbook.sheet_names(), + vec!["Elections", "Electons", "Read Me"] + ); + } +} diff --git a/packages/sequent-core/src/election_config/templates/area.hbs b/packages/sequent-core/src/election_config/templates/area.hbs new file mode 100644 index 00000000000..ab95bb6c45e --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/area.hbs @@ -0,0 +1,23 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base area. parent_id is resolved from the workbook's parent.external_id column +and merged in; a top-level area leaves it null. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "created_at": "{{created_at}}", + "last_updated_at": "{{created_at}}", + "labels": null, + "annotations": null, + "name": "", + "description": "", + "type": null, + "parent_id": null, + "presentation": { + "allow_early_voting": "no_early_voting" + } +} diff --git a/packages/sequent-core/src/election_config/templates/area_contest.hbs b/packages/sequent-core/src/election_config/templates/area_contest.hbs new file mode 100644 index 00000000000..f4f0da647f3 --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/area_contest.hbs @@ -0,0 +1,12 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base area/contest link: which contests appear on which area's ballot. Nothing +here but the join. +--}} +{ + "id": "{{id}}", + "area_id": "{{area_id}}", + "contest_id": "{{contest_id}}" +} diff --git a/packages/sequent-core/src/election_config/templates/candidate.hbs b/packages/sequent-core/src/election_config/templates/candidate.hbs new file mode 100644 index 00000000000..2a8f3608a06 --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/candidate.hbs @@ -0,0 +1,38 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base candidate. A candidate is mostly its name and its order on the ballot, +both of which come from the workbook. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "contest_id": "{{contest_id}}", + "created_at": "{{created_at}}", + "last_updated_at": "{{created_at}}", + "labels": null, + "annotations": null, + "description": "", + "type": null, + "presentation": { + "i18n": { + "en": {} + }, + "urls": [], + "sort_order": null, + "is_disabled": false, + "is_write_in": false, + "language_conf": { + "enabled_language_codes": [] + }, + "is_category_list": false, + "is_explicit_blank": false, + "is_explicit_invalid": false, + "invalid_vote_position": null + }, + "is_public": false, + "image_document_id": null, + "external_id": null +} diff --git a/packages/sequent-core/src/election_config/templates/contest.hbs b/packages/sequent-core/src/election_config/templates/contest.hbs new file mode 100644 index 00000000000..cbbe20a78fb --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/contest.hbs @@ -0,0 +1,56 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base contest. The vote counts and tally system below are what a contest gets when +the workbook does not say: single-choice plurality, one winner. They are values, +not placeholders -- because they are always present, validation's MissingField rule +for these five fields cannot fire on a built bundle, so build.rs warns for each one +it had to stand in for. Which of them should instead be mandatory is a product +decision; see say_what_the_template_stood_in_for. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "election_id": "{{election_id}}", + "created_at": "{{created_at}}", + "last_updated_at": "{{created_at}}", + "labels": null, + "annotations": null, + "is_acclaimed": false, + "is_active": true, + "description": "", + "presentation": { + "i18n": { + "en": {} + }, + "columns": null, + "sort_order": null, + "allow_writeins": false, + "candidates_order": "custom", + "over_vote_policy": "not-allowed-with-msg-and-disable", + "blank_vote_policy": "warn-only-in-review", + "collapsible_lists": "disabled", + "pagination_policy": "1", + "under_vote_policy": "warn-only-in-review", + "invalid_vote_policy": "warn-invalid-implicit-and-explicit", + "duplicated_rank_policy": "allowed-warn-and-dialog", + "enable_checkable_lists": "allow-selecting-candidates", + "preference_gaps_policy": "allowed-warn-and-dialog", + "max_selections_per_type": null, + "candidates_icon_checkbox_policy": "square-checkbox" + }, + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large", + "is_encrypted": true, + "tally_configuration": { + "tie_breaking_policy": "random" + }, + "image_document_id": null, + "conditions": null, + "external_id": null +} diff --git a/packages/sequent-core/src/election_config/templates/election.hbs b/packages/sequent-core/src/election_config/templates/election.hbs new file mode 100644 index 00000000000..6154e7de209 --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/election.hbs @@ -0,0 +1,118 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base election. See election_event.hbs for why only ids are interpolated. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "created_at": "{{created_at}}", + "last_updated_at": "{{created_at}}", + "labels": null, + "annotations": null, + "description": "", + "presentation": { + "i18n": { + "en": {} + }, + "dates": {}, + "tally": null, + "sort_order": null, + "init_report": null, + "language_conf": { + "default_language_code": "en", + "enabled_language_codes": ["en"] + }, + "contests_order": "custom", + "audit_button_cfg": "show", + "cast_vote_confirm": true, + "voting_period_end": null, + "grace_period_policy": "no-grace-period", + "cast_vote_gold_level": "no-gold-level", + "decline_to_vote_policy": "disabled", + "start_screen_title_policy": "election", + "voting_screen_back_policy": "election-selection-screen", + "consolidated_report_policy": "do-not-generate", + "manual_start_voting_period": null, + "initialization_report_policy": "not-required", + "security_confirmation_policy": "none" + }, + "status": { + "allow_tally": "requires-voting-period-end", + "init_report": "allowed", + "is_published": false, + "voting_status": "NOT_STARTED", + "early_voting_status": "NOT_STARTED", + "kiosk_voting_status": "NOT_STARTED", + "voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + }, + "telephone_voting_status": "NOT_STARTED", + "early_voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + }, + "kiosk_voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + }, + "telephone_voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + } + }, + "eml": null, + "external_id": null, + {{!-- One vote per voter by default; a revote allowance is a deliberate choice. --}} + "num_allowed_revotes": 0, + "is_consolidated_ballot_encoding": null, + "spoil_ballot_option": null, + "is_kiosk": false, + "voting_channels": { + "kiosk": false, + "online": true, + "telephone": false, + "early_voting": false + }, + "image_document_id": null, + "statistics": { + "num_sms_sent": 0, + "num_emails_sent": 0 + }, + "receipts": { + "SMS": { + "allowed": false, + "template": null + }, + "EMAIL": { + "allowed": false, + "template": null + }, + "DOCUMENT": { + "allowed": false, + "template": null + } + }, + "permission_label": null, + "initialization_report_generated": false, + "keys_ceremony_id": null +} diff --git a/packages/sequent-core/src/election_config/templates/election_event.hbs b/packages/sequent-core/src/election_config/templates/election_event.hbs new file mode 100644 index 00000000000..eed6b1b7ca7 --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/election_event.hbs @@ -0,0 +1,108 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base election event. Platform defaults only — every client-specific value +(name, branding, policies) comes from the workbook and is deep-merged over this. + +Only ids and timestamps are interpolated here, and both are safe by +construction. Client text never passes through Handlebars, so no amount of +quoting in a spreadsheet cell can produce invalid JSON. +--}} +{ + "id": "{{id}}", + "created_at": "{{created_at}}", + "updated_at": "{{created_at}}", + "labels": null, + "annotations": null, + "tenant_id": "{{tenant_id}}", + "description": "", + "presentation": { + "css": "", + "otp": "disabled", + "i18n": { + "en": {} + }, + "logo_url": "", + "materials": { + "activated": false + }, + "enrollment": "disabled", + "custom_urls": {}, + "locked_down": "not-locked-down", + "language_conf": { + "default_language_code": "en", + "enabled_language_codes": ["en"], + "language_detection_policy": "browser-detect" + }, + "elections_order": "custom", + "results_website": "{\"status\":\"disabled\",\"access\":\"public\",\"visibility_scope\":\"full_event\"}", + "ceremonies_policy": "manual-ceremonies", + "show_user_profile": false, + "skip_election_list": false, + "show_cast_vote_logs": "hide-logs-tab", + "voter_signing_policy": "with-signature", + "weighted_voting_policy": "disabled-weighted-voting", + "delegated_voting_policy": "disabled", + "automatic_recount_policy": "disabled", + "voter_certificate_policy": "disabled", + "contest_encryption_policy": "single-contest", + "voting_portal_datetime_format": "us-12h", + "voting_portal_countdown_policy": { + "policy": "NO_COUNTDOWN" + }, + "decoded_ballot_inclusion_policy": "not-included" + }, + "bulletin_board_reference": null, + "is_archived": false, + "voting_channels": { + "kiosk": false, + "online": true, + "early_voting": false + }, + {{!-- + A never-opened event. The importer replaces this with + ElectionEventStatus::default() anyway; being explicit keeps the emitted + file honest about what it describes. + --}} + "status": { + "is_published": false, + "voting_status": "NOT_STARTED", + "early_voting_status": "NOT_STARTED", + "kiosk_voting_status": "NOT_STARTED", + "voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + }, + "early_voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + }, + "kiosk_voting_period_dates": { + "last_paused_at": null, + "first_paused_at": null, + "last_started_at": null, + "last_stopped_at": null, + "first_started_at": null, + "first_stopped_at": null + } + }, + "user_boards": null, + "encryption_protocol": "RSA256", + "is_audit": null, + "audit_election_event_id": null, + "public_key": null, + "statistics": { + "num_sms_sent": 0, + "num_emails_sent": 0 + }, + "external_id": null +} diff --git a/packages/sequent-core/src/election_config/templates/report.hbs b/packages/sequent-core/src/election_config/templates/report.hbs new file mode 100644 index 00000000000..b710e43995d --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/report.hbs @@ -0,0 +1,27 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base report definition. + +cron_config is null by default: a report with a cron config is a scheduled +report that will mail documents to its recipients, which is not something a +default should switch on. Supply the whole cron_config object in one workbook +cell to enable it. + +permission_label is a list here (Report takes Option>), unlike +Election, which takes a single Option. The workbook's ||-separated cell +becomes that list. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "election_id": null, + "created_at": "{{created_at}}", + "report_type": "{{report_type}}", + "template_alias": null, + "encryption_policy": "unencrypted", + "cron_config": null, + "permission_label": null +} diff --git a/packages/sequent-core/src/election_config/templates/scheduled_event.hbs b/packages/sequent-core/src/election_config/templates/scheduled_event.hbs new file mode 100644 index 00000000000..d5c5067d1a3 --- /dev/null +++ b/packages/sequent-core/src/election_config/templates/scheduled_event.hbs @@ -0,0 +1,28 @@ +{{!-- +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only + +Base scheduled event. event_processor, the scheduled date and task_id are +computed in election_config::build_tables — task_id in particular must match +sequent_core::types::scheduled_event::generate_manage_date_task_name exactly, or +the platform cannot find the task it scheduled. +--}} +{ + "id": "{{id}}", + "tenant_id": "{{tenant_id}}", + "election_event_id": "{{election_event_id}}", + "created_at": "{{created_at}}", + "stopped_at": null, + "archived_at": null, + "labels": null, + "annotations": null, + "event_processor": "{{event_processor}}", + "cron_config": { + "cron": null, + "scheduled_date": null + }, + "event_payload": { + "election_id": null + }, + "task_id": "{{task_id}}" +} diff --git a/packages/sequent-core/src/election_config/time.rs b/packages/sequent-core/src/election_config/time.rs new file mode 100644 index 00000000000..8a8a601cc95 --- /dev/null +++ b/packages/sequent-core/src/election_config/time.rs @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! A moment a plan names, and the instant the platform acts on. +//! +//! These are not the same thing, and treating them as the same is how a wizard +//! builds an election whose voting period never opens. +//! +//! The scheduler reads `cron_config.scheduled_date` through +//! [`crate::services::date::ISO8601::to_date`], which is +//! `DateTime::parse_from_rfc3339` — and RFC 3339 **requires an offset**. A plan +//! that says `2027-03-01T09:00` produces a date that does not parse, so +//! `get_datetime` returns `None`, the poller drops the event, and nothing +//! happens on the day. No error is raised anywhere along that path. +//! +//! So a plan carries three things rather than one string: +//! +//! - `local`, the wall clock somebody typed, kept verbatim so a plan reopened +//! next month reads back as its author wrote it rather than converted into +//! wherever the reader happens to be; +//! - `zone`, the IANA name, carried for people and never computed on; +//! - `offset_minutes`, which is what turns the first into an instant. +//! +//! **There is no timezone database here, on purpose.** This module compiles to +//! wasm32, and `chrono-tz` is about a megabyte of tables to answer a question +//! the browser can already answer for free — `new Date(local).getTimezoneOffset()` +//! gives the right offset for that date, daylight saving included. Whoever picks +//! the time resolves the offset; this side records it, checks it, and computes +//! with it. + +use std::cmp::Ordering; +use std::fmt; + +use chrono::{DateTime, FixedOffset, NaiveDateTime}; +use serde::de::{self, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::problem::{Code, Problem}; + +/// Minutes east of UTC. Real zones run from -12:00 to +14:00. +const MIN_OFFSET: i32 = -12 * 60; +const MAX_OFFSET: i32 = 14 * 60; + +/// A wall-clock time, the zone it was written in, and the offset that turns it +/// into an instant. +/// +/// See the module docs for why all three are kept. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Timestamp { + /// `YYYY-MM-DDTHH:MM`, no offset — what a `datetime-local` input produces. + pub local: String, + + /// IANA name, `America/Los_Angeles`. Empty when a plan predates zones. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub zone: String, + + /// Minutes east of UTC **at `local`**, resolved by whoever chose the time. + #[serde(default)] + pub offset_minutes: i32, +} + +impl Timestamp { + /// A time in UTC, which is what a plan written before zones existed meant. + pub fn utc(local: impl Into) -> Self { + Timestamp { + local: local.into(), + zone: String::new(), + offset_minutes: 0, + } + } + + /// A time in a named zone at a known offset. + pub fn new( + local: impl Into, + zone: impl Into, + offset_minutes: i32, + ) -> Self { + Timestamp { + local: local.into(), + zone: zone.into(), + offset_minutes, + } + } + + pub fn is_empty(&self) -> bool { + self.local.trim().is_empty() + } + + /// The instant this names. + /// + /// Everything that compares two times goes through here. Comparing the + /// `local` strings instead is wrong the moment two of them sit in different + /// zones: 09:00 in Tokyo is before 08:00 in Los Angeles, and text says + /// otherwise. + pub fn instant(&self) -> Result, Problem> { + let naive = self.naive()?; + // `checked_mul` because `offset_minutes` deserializes from any `i32` and this + // runs before `check` does: the multiplication overflows above + // `i32::MAX / 60`, which panics in debug and wraps in release. + let offset = self + .offset_minutes + .checked_mul(60) + .and_then(FixedOffset::east_opt) + .ok_or_else(|| { + self.problem(format!( + "{} is not a usable offset", + self.offset_minutes + )) + })?; + naive.and_local_timezone(offset).earliest().ok_or_else(|| { + // A wall clock inside a spring-forward gap names no instant. + self.problem(format!( + "'{}' did not happen in {}: the clocks moved forward over it", + self.local, + self.zone_or_utc() + )) + }) + } + + /// The shape the platform's scheduler parses. + /// + /// `DateTime::parse_from_rfc3339` is the only reader of this value, so + /// anything it rejects is an event that silently never fires. + pub fn to_rfc3339(&self) -> Result { + Ok(self.instant()?.to_rfc3339()) + } + + /// `local` as a date and time, accepting it with or without seconds. + fn naive(&self) -> Result { + let text = self.local.trim(); + NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(text, "%Y-%m-%dT%H:%M")) + .map_err(|_| { + self.problem(format!( + "'{text}' is not a date and time. Expected YYYY-MM-DDTHH:MM." + )) + }) + } + + fn zone_or_utc(&self) -> &str { + if self.zone.trim().is_empty() { + "UTC" + } else { + &self.zone + } + } + + fn problem(&self, message: String) -> Problem { + Problem::error(Code::InvalidValue, "schedule", message) + } +} + +impl fmt::Display for Timestamp { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} ({})", self.local, self.zone_or_utc()) + } +} + +/// Order by the instant named, falling back to text when one will not parse. +/// +/// A total order is needed because validation sorts and compares, and refusing +/// to order an unparseable value would hide the very problem being reported. +pub fn compare(left: &Timestamp, right: &Timestamp) -> Ordering { + match (left.instant(), right.instant()) { + (Ok(left), Ok(right)) => left.cmp(&right), + _ => left.local.cmp(&right.local), + } +} + +/// Accept either the object or a bare string. +/// +/// A plan saved before zones existed said `"2027-03-01T09:00"`, and both this +/// implementation and the TypeScript one treated that as UTC. Reading it as UTC +/// keeps those plans compiling to what they always compiled to; validation then +/// says out loud that no zone was given, because a schedule handed to a client +/// with no zone on it is how two people arrive an hour apart. +impl<'de> Deserialize<'de> for Timestamp { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Either; + + impl<'de> Visitor<'de> for Either { + type Value = Timestamp; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a date and time, or {local, zone, offset_minutes}") + } + + fn visit_str( + self, + value: &str, + ) -> Result { + Ok(Timestamp::utc(value)) + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut local = None; + let mut zone = None; + let mut offset_minutes = None; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "local" => local = Some(map.next_value::()?), + "zone" => zone = Some(map.next_value::()?), + "offset_minutes" => { + offset_minutes = Some(map.next_value::()?) + } + // Ignored rather than refused: a plan from a newer build + // is caught by its version, not by one unknown key here. + _ => { + let _ = + map.next_value::()?; + } + } + } + Ok(Timestamp { + local: local + .ok_or_else(|| de::Error::missing_field("local"))?, + zone: zone.unwrap_or_default(), + offset_minutes: offset_minutes.unwrap_or(0), + }) + } + } + + deserializer.deserialize_any(Either) + } +} + +/// Everything wrong with one timestamp, in the plan's own vocabulary. +/// +/// `at` is the path the wizard routes the message by, so it names the field +/// somebody can actually go and fix. +pub fn check(stamp: &Timestamp, at: &str, problems: &mut Vec) { + if stamp.is_empty() { + return; + } + + if let Err(problem) = stamp.naive() { + problems.push(Problem::error(Code::InvalidValue, at, problem.message)); + return; + } + + if stamp.offset_minutes < MIN_OFFSET || stamp.offset_minutes > MAX_OFFSET { + problems.push(Problem::error( + Code::InvalidValue, + at, + format!( + "{} minutes is not a real UTC offset; they run from {MIN_OFFSET} to {MAX_OFFSET}", + stamp.offset_minutes + ), + )); + } else if stamp.offset_minutes % 15 != 0 { + // Every zone in use is a whole number of quarter-hours. A value that is + // not is in range and looks almost right, which is exactly why it needs + // saying — an offset out by minutes survives a reading that an offset + // out by hours would not. + problems.push(Problem::error( + Code::InvalidValue, + at, + format!( + "an offset of {} minutes is not a multiple of 15, and every real \ + timezone is", + stamp.offset_minutes + ), + )); + } + + if stamp.zone.trim().is_empty() { + problems.push(Problem::warning( + Code::MissingField, + at, + "no timezone was named, so this is read as UTC. A schedule handed to \ + a client without a zone on it is how two people arrive an hour apart.", + )); + } +} + +#[cfg(test)] +#[path = "time_tests.rs"] +mod time_tests; diff --git a/packages/sequent-core/src/election_config/time_tests.rs b/packages/sequent-core/src/election_config/time_tests.rs new file mode 100644 index 00000000000..6ffb8ebd671 --- /dev/null +++ b/packages/sequent-core/src/election_config/time_tests.rs @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! What a plan's times have to survive to reach the scheduler. + +use super::*; +use crate::election_config::problem::Severity; + +/// Los Angeles in March, after the clocks move: UTC-7. +const LA_SUMMER: i32 = -7 * 60; + +fn problems(stamp: &Timestamp) -> Vec { + let mut found = Vec::new(); + check(stamp, "schedule.voting_opens", &mut found); + found +} + +fn errors(stamp: &Timestamp) -> Vec { + problems(stamp) + .into_iter() + .filter(|problem| problem.severity == Severity::Error) + .collect() +} + +fn says(problems: &[Problem], fragment: &str) -> bool { + problems + .iter() + .any(|problem| problem.message.contains(fragment)) +} + +// -- the bug this module exists for --------------------------------------- + +/// The whole point. `ISO8601::to_date` is `DateTime::parse_from_rfc3339`, so a +/// value it rejects is a scheduled event that never fires — with no error +/// anywhere on the path from the plan to the day nothing happens. +#[test] +fn the_scheduled_date_parses_the_way_the_platform_parses_it() { + let stamp = + Timestamp::new("2027-03-01T09:00", "America/Los_Angeles", LA_SUMMER); + let written = stamp.to_rfc3339().expect("a sound time should render"); + + let parsed = chrono::DateTime::parse_from_rfc3339(&written); + + assert!( + parsed.is_ok(), + "the platform's own parser rejected {written:?}: {:?}", + parsed.unwrap_err() + ); + assert_eq!(written, "2027-03-01T09:00:00-07:00"); +} + +/// What the wizard used to write. Kept as a test so the regression is named. +#[test] +fn a_bare_wall_clock_is_what_the_scheduler_cannot_read() { + assert!( + chrono::DateTime::parse_from_rfc3339("2027-03-01T09:00").is_err(), + "if this ever parses, the reason for this module has gone away" + ); +} + +#[test] +fn utc_renders_with_an_offset_too() { + let stamp = Timestamp::utc("2027-03-01T09:00"); + assert_eq!(stamp.to_rfc3339().unwrap(), "2027-03-01T09:00:00+00:00"); +} + +#[test] +fn seconds_are_accepted_as_well_as_omitted() { + let with = Timestamp::utc("2027-03-01T09:00:30"); + assert_eq!(with.to_rfc3339().unwrap(), "2027-03-01T09:00:30+00:00"); +} + +// -- reading a plan -------------------------------------------------------- + +#[test] +fn a_plan_saved_before_timezones_existed_still_opens() { + let stamp: Timestamp = + serde_json::from_str(r#""2027-03-01T09:00""#).unwrap(); + + assert_eq!(stamp.local, "2027-03-01T09:00"); + assert_eq!(stamp.offset_minutes, 0, "a bare time meant UTC, as before"); + assert!(stamp.zone.is_empty()); +} + +#[test] +fn a_plan_that_names_its_zone_round_trips() { + let stamp = + Timestamp::new("2027-03-01T09:00", "America/Los_Angeles", LA_SUMMER); + let text = serde_json::to_string(&stamp).unwrap(); + let read: Timestamp = serde_json::from_str(&text).unwrap(); + + assert_eq!(read, stamp); +} + +#[test] +fn an_object_missing_its_optional_parts_reads_as_utc() { + let stamp: Timestamp = + serde_json::from_str(r#"{"local": "2027-03-01T09:00"}"#).unwrap(); + + assert_eq!(stamp.offset_minutes, 0); + assert!(stamp.zone.is_empty()); +} + +/// A newer build's extra key is not this type's business to refuse — the plan's +/// own version check is what refuses a plan from the future. +#[test] +fn an_unknown_key_does_not_stop_a_plan_opening() { + let stamp: Timestamp = + serde_json::from_str(r#"{"local": "2027-03-01T09:00", "era": "ce"}"#) + .unwrap(); + + assert_eq!(stamp.local, "2027-03-01T09:00"); +} + +// -- ordering -------------------------------------------------------------- + +/// The reason comparing text is not good enough. As strings, "09:00" sorts +/// after "08:00"; as instants, Tokyo's 09:00 is seventeen hours earlier. +#[test] +fn two_times_in_different_zones_are_ordered_by_the_instant() { + let tokyo = Timestamp::new("2027-03-01T09:00", "Asia/Tokyo", 9 * 60); + let los_angeles = + Timestamp::new("2027-03-01T08:00", "America/Los_Angeles", LA_SUMMER); + + assert_eq!(compare(&tokyo, &los_angeles), std::cmp::Ordering::Less); + assert!( + tokyo.local > los_angeles.local, + "and the text comparison this replaces would have said the opposite" + ); +} + +#[test] +fn an_unparseable_time_still_orders_rather_than_panicking() { + let sound = Timestamp::utc("2027-03-01T09:00"); + let broken = Timestamp::utc("next Tuesday"); + + let _ = compare(&sound, &broken); +} + +// -- what validation says -------------------------------------------------- + +#[test] +fn a_sound_time_in_a_named_zone_has_nothing_to_report() { + let stamp = + Timestamp::new("2027-03-01T09:00", "America/Los_Angeles", LA_SUMMER); + assert!(problems(&stamp).is_empty()); +} + +#[test] +fn a_blank_time_is_not_a_problem_here() { + assert!(problems(&Timestamp::utc("")).is_empty()); +} + +#[test] +fn something_that_is_not_a_date_is_refused() { + let found = errors(&Timestamp::utc("1st March")); + assert_eq!(found.len(), 1); + assert!(says(&found, "is not a date and time")); + assert_eq!(found[0].path, "schedule.voting_opens"); +} + +#[test] +fn an_offset_no_zone_uses_is_refused() { + let found = errors(&Timestamp::new("2027-03-01T09:00", "Nowhere", 20 * 60)); + assert!(says(&found, "not a real UTC offset")); +} + +/// In range, so the bounds check passes, but no zone on earth is offset by it. +/// A plausible-looking number is exactly the kind that survives review. +#[test] +fn an_offset_that_is_not_a_multiple_of_fifteen_minutes_is_refused() { + let found = errors(&Timestamp::new("2027-03-01T09:00", "Nowhere", -421)); + assert!(says(&found, "not a multiple of 15")); +} + +/// Seconds in a minutes field is the mistake that produces these, and it lands +/// far outside the range rather than looking almost right. +#[test] +fn an_offset_given_in_seconds_is_refused_as_out_of_range() { + let found = errors(&Timestamp::new("2027-03-01T09:00", "Nowhere", -25200)); + assert!(says(&found, "not a real UTC offset")); +} + +#[test] +fn a_time_with_no_zone_named_is_a_warning_not_an_error() { + let found = problems(&Timestamp::utc("2027-03-01T09:00")); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].severity, Severity::Warning); + assert!(says(&found, "read as UTC")); +} + +/// India is +05:30 and Nepal +05:45. Neither is an hour, and both are real. +#[test] +fn a_zone_that_is_not_a_whole_hour_is_fine() { + let kolkata = Timestamp::new("2027-03-01T09:00", "Asia/Kolkata", 330); + let kathmandu = Timestamp::new("2027-03-01T09:00", "Asia/Kathmandu", 345); + + assert!(problems(&kolkata).is_empty()); + assert!(problems(&kathmandu).is_empty()); + assert_eq!(kolkata.to_rfc3339().unwrap(), "2027-03-01T09:00:00+05:30"); +} + +#[test] +fn an_offset_too_large_to_multiply_is_reported_not_panicked() { + // `offset_minutes * 60` is i32 arithmetic and `instant` runs before `check` does, + // so a value above `i32::MAX / 60` used to panic in debug and wrap in release. + let stamp = Timestamp { + local: "2027-03-01T16:00:00".to_string(), + zone: String::new(), + offset_minutes: i32::MAX, + }; + + let problem = stamp + .instant() + .expect_err("an unusable offset is a problem"); + assert!( + problem.message.contains("not a usable offset"), + "unexpected message: {}", + problem.message + ); +} diff --git a/packages/sequent-core/src/election_config/validate.rs b/packages/sequent-core/src/election_config/validate.rs new file mode 100644 index 00000000000..debab8e358b --- /dev/null +++ b/packages/sequent-core/src/election_config/validate.rs @@ -0,0 +1,574 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Whether a bundle can be imported, and whether it should be. +//! +//! Pure: no database, no IO, no clock. That is what lets the same code answer in +//! a browser before an upload and on the server before a transaction, and it is +//! the constraint to keep in mind when adding a rule. Anything needing the +//! database — does this tenant exist, is this area name already taken — belongs +//! in windmill on top of this pass, not here. +//! +//! The rules come from two places: what the importer rejects, and what janitor +//! learned the hard way. The second kind matters most. A bundle can satisfy every +//! type in the schema and still be wrong in a way nobody notices until election +//! day — a contest on no ballot, rankings counted by plurality, an election +//! scoped to a permission label that no administrator holds. Those import +//! cleanly and fail silently, so they are checked here. + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use strum::VariantNames; + +use super::problem::{Code, Problem, Report}; +use super::schema::ImportElectionEventSchema; +use crate::types::ceremonies::CountingAlgType; + +/// Every value `CountingAlgType` accepts, from the enum itself. +pub const COUNTING_ALGORITHMS: &[&str] = CountingAlgType::VARIANTS; + +/// The algorithms `CountingAlgType::is_preferential` returns true for. +/// +/// The split is load-bearing rather than cosmetic: ballot encoding follows the +/// algorithm, so a preferential contest counted by plurality imports cleanly and +/// then reads the rankings a voter entered as unordered selections. +/// +/// Spelled out because the browser is handed a `&'static [&'static str]` and +/// `is_preferential` is not a const fn. `the_preferential_list_matches_the_enum` +/// fails if the two disagree. +pub const PREFERENTIAL_ALGORITHMS: &[&str] = &[ + "instant-runoff", + "borda", + "borda-nauru", + "borda-mas-madrid", + "pairwise-beta", + "desborda", + "desborda2", + "desborda3", +]; + +/// A contest whose ballot carries an order. +pub const PREFERENTIAL: &str = "preferential"; + +/// A contest whose ballot carries a set of marks. +pub const NON_PREFERENTIAL: &str = "non-preferential"; + +/// `IVotingType` in the Admin Portal. Rust carries `voting_type` as a free-form +/// `String`, so the portal's enum is the only authority on what it may hold. +pub const VOTING_TYPES: &[&str] = &[PREFERENTIAL, NON_PREFERENTIAL]; + +/// Check a bundle and report everything wrong with it. +/// +/// Never stops at the first problem: fixing a configuration one error per run is +/// miserable, and the caller usually wants the whole list at once. +pub fn validate(bundle: &ImportElectionEventSchema) -> Report { + let mut report = Report::default(); + + check_identity(bundle, &mut report); + check_references(bundle, &mut report); + check_area_tree(bundle, &mut report); + check_contests(bundle, &mut report); + check_ballot_coverage(bundle, &mut report); + check_permission_labels(bundle, &mut report); + check_unique_ids(bundle, &mut report); + + report +} + +fn check_identity(bundle: &ImportElectionEventSchema, report: &mut Report) { + // The schema carries tenant_id as a String so the module stays WASM-safe; + // this is where the format check it lost comes back, as a readable problem + // rather than an opaque serde error. + if bundle.tenant_id.trim().is_empty() { + report.push(Problem::error( + Code::MissingField, + "tenant_id", + "the bundle has no tenant id", + )); + } else if !looks_like_uuid(&bundle.tenant_id) { + report.push(Problem::error( + Code::InvalidValue, + "tenant_id", + format!("'{}' is not a UUID", bundle.tenant_id), + )); + } + + let event = &bundle.election_event; + if event.id.trim().is_empty() { + report.push(Problem::error( + Code::MissingField, + "election_event.id", + "the election event has no id", + )); + } + if event.encryption_protocol.trim().is_empty() { + report.push(Problem::error( + Code::MissingField, + "election_event.encryption_protocol", + "the election event has no encryption protocol", + )); + } + + if bundle.elections.is_empty() { + report.push(Problem::error( + Code::MissingField, + "elections", + "an election event needs at least one election", + )); + } + // A warning rather than a refusal, for the reason the ballot-coverage rules below + // are: the bundle is consistent and the platform imports it, it just means no + // voter can be given a ballot yet. An event still being configured looks like + // this, and so does the platform's own export of one. + if bundle.areas.is_empty() { + report.push(Problem::warning( + Code::BallotCoverage, + "areas", + "the event has no areas, so no voter can be given a ballot until one exists", + )); + } +} + +fn check_references(bundle: &ImportElectionEventSchema, report: &mut Report) { + let election_ids: HashSet<&str> = + bundle.elections.iter().map(|e| e.id.as_str()).collect(); + let contest_ids: HashSet<&str> = + bundle.contests.iter().map(|c| c.id.as_str()).collect(); + let area_ids: HashSet<&str> = + bundle.areas.iter().map(|a| a.id.as_str()).collect(); + + for (index, contest) in bundle.contests.iter().enumerate() { + if !election_ids.contains(contest.election_id.as_str()) { + report.push( + Problem::error( + Code::DanglingReference, + format!("contests[{index}].election_id"), + "points at an election that is not in the bundle", + ) + .about(contest.external_id.as_deref()), + ); + } + } + + for (index, candidate) in bundle.candidates.iter().enumerate() { + match candidate.contest_id.as_deref() { + None => report.push( + Problem::error( + Code::MissingField, + format!("candidates[{index}].contest_id"), + "a candidate must belong to a contest", + ) + .about(candidate.external_id.as_deref()), + ), + Some(id) if !contest_ids.contains(id) => report.push( + Problem::error( + Code::DanglingReference, + format!("candidates[{index}].contest_id"), + "points at a contest that is not in the bundle", + ) + .about(candidate.external_id.as_deref()), + ), + Some(_) => {} + } + } + + for (index, link) in bundle.area_contests.iter().enumerate() { + if !area_ids.contains(link.area_id.as_str()) { + report.push(Problem::error( + Code::DanglingReference, + format!("area_contests[{index}].area_id"), + "points at an area that is not in the bundle", + )); + } + if !contest_ids.contains(link.contest_id.as_str()) { + report.push(Problem::error( + Code::DanglingReference, + format!("area_contests[{index}].contest_id"), + "points at a contest that is not in the bundle", + )); + } + } +} + +fn check_area_tree(bundle: &ImportElectionEventSchema, report: &mut Report) { + let parents: HashMap<&str, Option<&str>> = bundle + .areas + .iter() + .map(|area| (area.id.as_str(), area.parent_id.as_deref())) + .collect(); + + for (index, area) in bundle.areas.iter().enumerate() { + let Some(parent) = area.parent_id.as_deref() else { + continue; + }; + + if !parents.contains_key(parent) { + report.push(Problem::error( + Code::DanglingReference, + format!("areas[{index}].parent_id"), + "points at a parent area that is not in the bundle", + )); + continue; + } + + // An infinite tree hangs the Admin Portal rather than failing the import. + let mut seen: HashSet<&str> = HashSet::from([area.id.as_str()]); + let mut cursor = Some(parent); + while let Some(current) = cursor { + if !seen.insert(current) { + report.push(Problem::error( + Code::AreaCycle, + format!("areas[{index}].parent_id"), + format!( + "area '{}' is part of a parent cycle", + area.name.as_deref().unwrap_or(&area.id) + ), + )); + break; + } + cursor = parents.get(current).copied().flatten(); + } + } +} + +fn check_contests(bundle: &ImportElectionEventSchema, report: &mut Report) { + let mut candidates_per_contest: HashMap<&str, usize> = HashMap::new(); + for candidate in &bundle.candidates { + if let Some(contest_id) = candidate.contest_id.as_deref() { + *candidates_per_contest.entry(contest_id).or_insert(0) += 1; + } + } + + for (index, contest) in bundle.contests.iter().enumerate() { + let path = |field: &str| format!("contests[{index}].{field}"); + let about = contest.external_id.as_deref(); + + let min_votes = contest.min_votes; + let max_votes = contest.max_votes; + let winners = contest.winning_candidates_num; + + // Present, and not below zero. The fields are signed and every other rule + // about them is relational — `min > max`, `winners > available` — which a + // negative value satisfies, so the floor has to be stated. + for (field, value) in [ + ("min_votes", min_votes), + ("max_votes", max_votes), + ("winning_candidates_num", winners), + ] { + match value { + None => report.push( + Problem::error( + Code::MissingField, + path(field), + format!("a contest needs {field}"), + ) + .about(about), + ), + Some(number) if number < 0 => report.push( + Problem::error( + Code::InvalidValue, + path(field), + format!("{field} is {number}, and a count cannot be negative"), + ) + .about(about), + ), + Some(_) => {} + } + } + + if let (Some(min), Some(max)) = (min_votes, max_votes) { + if min > max { + report.push( + Problem::error( + Code::ContestArithmetic, + path("min_votes"), + format!("min_votes {min} is above max_votes {max}"), + ) + .about(about), + ); + } + } + + let voting_type = contest.voting_type.as_deref(); + match voting_type { + Some(value) if VOTING_TYPES.contains(&value) => {} + other => report.push( + Problem::error( + Code::InvalidValue, + path("voting_type"), + format!( + "{} is not a voting type; expected one of {}", + other + .map(|v| format!("'{v}'")) + .unwrap_or("nothing".into()), + VOTING_TYPES.join(", ") + ), + ) + .about(about), + ), + } + + let algorithm = contest.counting_algorithm.as_deref(); + match algorithm { + // Matched exactly, then parsed: `CountingAlgType::from_str` is + // `ascii_case_insensitive` and would accept `Borda`, which + // `ICountingAlgorithm` in `ui-core` compares by value and would miss. + Some(value) if COUNTING_ALGORITHMS.contains(&value) => { + let preferential = CountingAlgType::from_str(value) + .map(|algorithm| algorithm.is_preferential()) + .unwrap_or(false); + match voting_type { + Some(PREFERENTIAL) if !preferential => report.push( + Problem::error( + Code::TallyMismatch, + path("counting_algorithm"), + format!( + "a preferential contest counted by '{value}', which \ + ignores rankings" + ), + ) + .about(about), + ), + Some(NON_PREFERENTIAL) if preferential => report.push( + Problem::error( + Code::TallyMismatch, + path("counting_algorithm"), + format!( + "a non-preferential contest counted by '{value}', \ + which needs ranked ballots" + ), + ) + .about(about), + ), + _ => {} + } + } + other => report.push( + Problem::error( + Code::InvalidValue, + path("counting_algorithm"), + format!( + "{} is not a counting algorithm; expected one of {}", + other + .map(|v| format!("'{v}'")) + .unwrap_or("nothing".into()), + COUNTING_ALGORITHMS.join(", ") + ), + ) + .about(about), + ), + } + + let available = candidates_per_contest + .get(contest.id.as_str()) + .copied() + .unwrap_or(0); + if available == 0 { + report.push( + Problem::warning( + Code::BallotCoverage, + path("id"), + "the contest has no candidates, so nobody can vote in it", + ) + .about(about), + ); + } else { + if let Some(winners) = winners { + if winners > available as i64 { + report.push( + Problem::error( + Code::ContestArithmetic, + path("winning_candidates_num"), + format!( + "elects {winners} of {available} candidates" + ), + ) + .about(about), + ); + } + } + if let Some(max) = max_votes { + if max > available as i64 { + report.push( + Problem::error( + Code::ContestArithmetic, + path("max_votes"), + format!("allows {max} selections among {available} candidates"), + ) + .about(about), + ); + } + } + } + } +} + +fn check_ballot_coverage( + bundle: &ImportElectionEventSchema, + report: &mut Report, +) { + // Neither of these breaks the import. Both mean somebody's vote is missing on + // election day, which is the most expensive time to find out. + let linked_contests: HashSet<&str> = bundle + .area_contests + .iter() + .map(|link| link.contest_id.as_str()) + .collect(); + let linked_areas: HashSet<&str> = bundle + .area_contests + .iter() + .map(|link| link.area_id.as_str()) + .collect(); + let parents: HashSet<&str> = bundle + .areas + .iter() + .filter_map(|area| area.parent_id.as_deref()) + .collect(); + + for (index, contest) in bundle.contests.iter().enumerate() { + if !linked_contests.contains(contest.id.as_str()) { + report.push( + Problem::warning( + Code::BallotCoverage, + format!("contests[{index}]"), + "appears on no area's ballot, so nobody can vote in it", + ) + .about(contest.external_id.as_deref()), + ); + } + } + + for (index, area) in bundle.areas.iter().enumerate() { + // A parent area is a grouping; only leaf areas carry a ballot. + if linked_areas.contains(area.id.as_str()) + || parents.contains(area.id.as_str()) + { + continue; + } + report.push(Problem::warning( + Code::BallotCoverage, + format!("areas[{index}]"), + format!( + "area '{}' has no contests and is not a parent of another area, so \ + its voters would see an empty ballot", + area.name.as_deref().unwrap_or(&area.id) + ), + )); + } +} + +fn check_permission_labels( + bundle: &ImportElectionEventSchema, + report: &mut Report, +) { + // Hasura filters election and report on + // permission_label IS NULL OR permission_label IN X-Hasura-Permission-Labels + // so an entity carrying a label is invisible to every administrator who does + // not hold it — including whoever runs the import. The bundle cannot know who + // holds what, so this is a warning naming the label rather than a refusal. + // Per collection: `Problem::path` is where the entity is, and a front end turns + // it into a wizard step or a spreadsheet cell. + let mut from_elections: Vec = Vec::new(); + let mut from_reports: Vec = Vec::new(); + + for election in &bundle.elections { + if let Some(label) = election.permission_label.as_deref() { + if !label.trim().is_empty() + && !from_elections.iter().any(|seen| seen == label) + { + from_elections.push(label.to_string()); + } + } + } + for report_definition in &bundle.reports { + for label in report_definition.permission_label.iter().flatten() { + if !label.trim().is_empty() + && !from_reports.iter().any(|seen| seen == label) + { + from_reports.push(label.clone()); + } + } + } + + for (path, labels) in [ + ("elections[].permission_label", &from_elections), + ("reports[].permission_label", &from_reports), + ] { + if labels.is_empty() { + continue; + } + report.push(Problem::warning( + Code::PermissionLabel, + path, + format!( + "permission labels in use: {}. Anything carrying a label is hidden \ + from every administrator without it, so whoever imports this needs \ + one of them on their own 'permission_labels' attribute or the Admin \ + Portal will show them an empty list.", + labels.join(", ") + ), + )); + } +} + +fn check_unique_ids(bundle: &ImportElectionEventSchema, report: &mut Report) { + // Two entities sharing an id means one silently overwrites the other. The ids + // are checked across every collection at once, not per collection: a contest + // and an area sharing one collides just as badly. + let groups: [(&str, Vec<&str>); 5] = [ + ( + "elections", + bundle.elections.iter().map(|e| e.id.as_str()).collect(), + ), + ( + "contests", + bundle.contests.iter().map(|c| c.id.as_str()).collect(), + ), + ( + "candidates", + bundle.candidates.iter().map(|c| c.id.as_str()).collect(), + ), + ( + "areas", + bundle.areas.iter().map(|a| a.id.as_str()).collect(), + ), + ( + "area_contests", + bundle.area_contests.iter().map(|l| l.id.as_str()).collect(), + ), + ]; + + let mut seen: HashMap<&str, &str> = HashMap::new(); + for (kind, ids) in &groups { + for id in ids { + if let Some(previous) = seen.insert(id, kind) { + report.push(Problem::error( + Code::DuplicateId, + *kind, + format!("id {id} is also used by {previous}"), + )); + } + } + } +} + +/// Whether a string is shaped like a hyphenated UUID. +/// +/// Deliberately not `Uuid::parse_str`: that crate is only enabled by the +/// `keycloak` feature here, and pulling it into `default_features` would put +/// `getrandom` in the WASM build to check a string's shape. +fn looks_like_uuid(value: &str) -> bool { + let groups: Vec<&str> = value.split('-').collect(); + groups.len() == 5 + && [8usize, 4, 4, 4, 12] + .iter() + .zip(&groups) + .all(|(expected, group)| { + group.len() == *expected + && group + .chars() + .all(|character| character.is_ascii_hexdigit()) + }) +} diff --git a/packages/sequent-core/src/election_config/validate_tests.rs b/packages/sequent-core/src/election_config/validate_tests.rs new file mode 100644 index 00000000000..82789c9be49 --- /dev/null +++ b/packages/sequent-core/src/election_config/validate_tests.rs @@ -0,0 +1,628 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Tests for [`super::validate`]. +//! +//! Each starts from a bundle that passes and breaks exactly one thing, so a +//! failure names the rule rather than a pile of unrelated problems. The builder +//! below is deliberately the smallest bundle that validates cleanly — if it ever +//! stops doing so, [`a_sound_bundle_has_no_errors`] fails first and says why. + +use super::problem::{Code, Severity}; +use super::schema::ImportElectionEventSchema; +use super::validate::{validate, COUNTING_ALGORITHMS, PREFERENTIAL_ALGORITHMS}; +use crate::types::ceremonies::CountingAlgType; +use std::str::FromStr; + +const TENANT: &str = "3f0c9d21-7b4e-4a55-9c3a-1d2e5f6a7b80"; + +/// A bundle that validates cleanly: one election, one contest with two +/// candidates, a parent area and a leaf that carries the ballot. +fn sound() -> ImportElectionEventSchema { + let json = serde_json::json!({ + "tenant_id": TENANT, + "keycloak_event_realm": null, + "election_event": { + "id": "e0000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "is_archived": false, + "encryption_protocol": "RSA256" + }, + "elections": [{ + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "external_id": "officers" + }], + "contests": [{ + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "election_id": "e1000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large" + }], + "candidates": [ + { + "id": "d1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000", + "external_id": "pres-a" + }, + { + "id": "d2000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000", + "external_id": "pres-b" + } + ], + "areas": [ + { + "id": "a1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Region" + }, + { + "id": "a2000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": "e0000000-0000-5000-8000-000000000000", + "name": "North Local 1", + "parent_id": "a1000000-0000-5000-8000-000000000000" + } + ], + "area_contests": [{ + "id": "b1000000-0000-5000-8000-000000000000", + "area_id": "a2000000-0000-5000-8000-000000000000", + "contest_id": "c1000000-0000-5000-8000-000000000000" + }], + "scheduled_events": null, + "reports": [], + "keys_ceremonies": [], + "applications": [] + }); + serde_json::from_value(json).expect("the sound fixture should deserialize") +} + +/// Every error code the report carries, for terse assertions. +fn error_codes(bundle: &ImportElectionEventSchema) -> Vec { + validate(bundle) + .problems + .iter() + .filter(|problem| problem.severity == Severity::Error) + .map(|problem| problem.code) + .collect() +} + +#[test] +fn a_sound_bundle_has_no_errors() { + let report = validate(&sound()); + assert!( + !report.has_errors(), + "the fixture should validate cleanly, but got:\n{report}" + ); +} + +// -- identity --------------------------------------------------------------- + +#[test] +fn a_malformed_tenant_id_is_reported_readably() { + // The schema carries this as a String so the module stays WASM-safe; this is + // the check that replaces the one a Uuid field used to do at parse time. + let mut bundle = sound(); + bundle.tenant_id = "not-a-uuid".into(); + assert!(error_codes(&bundle).contains(&Code::InvalidValue)); +} + +#[test] +fn an_empty_tenant_id_is_reported_as_missing_not_malformed() { + let mut bundle = sound(); + bundle.tenant_id = " ".into(); + assert!(error_codes(&bundle).contains(&Code::MissingField)); +} + +#[test] +fn a_uuid_is_accepted_in_any_case() { + let mut bundle = sound(); + bundle.tenant_id = TENANT.to_uppercase(); + assert!(!validate(&bundle).has_errors()); +} + +#[test] +fn an_event_with_no_elections_is_rejected() { + let mut bundle = sound(); + bundle.elections.clear(); + bundle.contests.clear(); + bundle.candidates.clear(); + bundle.area_contests.clear(); + assert!(error_codes(&bundle).contains(&Code::MissingField)); +} + +#[test] +fn an_event_with_no_areas_is_a_warning_not_an_error() { + // Reported: a bundle with no areas would not import. It is consistent and the + // platform takes it; it just means nobody can be given a ballot yet. + let mut bundle = sound(); + bundle.areas.clear(); + bundle.area_contests.clear(); + + let report = validate(&bundle); + assert!(!report.has_errors(), "expected no errors, got:\n{report}"); + assert!(report + .warnings() + .any(|problem| problem.code == Code::BallotCoverage + && problem.path == "areas")); + // And not as a missing field at any severity: areas are not required. + assert!(!report + .problems + .iter() + .any(|problem| problem.path == "areas" + && problem.code == Code::MissingField)); +} + +// -- references ------------------------------------------------------------- + +#[test] +fn a_contest_pointing_at_no_election_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].election_id = + "f0000000-0000-5000-8000-000000000000".into(); + assert!(error_codes(&bundle).contains(&Code::DanglingReference)); +} + +#[test] +fn a_candidate_pointing_at_no_contest_is_rejected() { + let mut bundle = sound(); + bundle.candidates[0].contest_id = + Some("f0000000-0000-5000-8000-000000000000".into()); + assert!(error_codes(&bundle).contains(&Code::DanglingReference)); +} + +#[test] +fn a_candidate_with_no_contest_at_all_is_rejected() { + let mut bundle = sound(); + bundle.candidates[0].contest_id = None; + assert!(error_codes(&bundle).contains(&Code::MissingField)); +} + +#[test] +fn a_problem_names_the_external_id_not_the_uuid() { + // Import regenerates every UUID, so the id in the bundle means nothing to + // whoever has to fix the source. The external_id is what they typed. + let mut bundle = sound(); + bundle.contests[0].election_id = + "f0000000-0000-5000-8000-000000000000".into(); + let report = validate(&bundle); + let problem = report + .problems + .iter() + .find(|problem| problem.code == Code::DanglingReference) + .expect("expected a dangling reference"); + assert_eq!(problem.external_id.as_deref(), Some("president")); + assert_eq!(problem.path, "contests[0].election_id"); +} + +// -- area tree -------------------------------------------------------------- + +#[test] +fn an_area_pointing_at_no_parent_is_rejected() { + let mut bundle = sound(); + bundle.areas[1].parent_id = + Some("f0000000-0000-5000-8000-000000000000".into()); + assert!(error_codes(&bundle).contains(&Code::DanglingReference)); +} + +#[test] +fn an_area_cycle_is_rejected() { + // An infinite tree hangs the Admin Portal rather than failing the import. + let mut bundle = sound(); + let leaf = bundle.areas[1].id.clone(); + bundle.areas[0].parent_id = Some(leaf); + assert!(error_codes(&bundle).contains(&Code::AreaCycle)); +} + +#[test] +fn an_area_that_is_its_own_parent_is_rejected() { + let mut bundle = sound(); + let own = bundle.areas[1].id.clone(); + bundle.areas[1].parent_id = Some(own); + assert!(error_codes(&bundle).contains(&Code::AreaCycle)); +} + +// -- contest arithmetic ----------------------------------------------------- + +#[test] +fn min_votes_above_max_votes_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].min_votes = Some(2); + assert!(error_codes(&bundle).contains(&Code::ContestArithmetic)); +} + +#[test] +fn a_contest_missing_a_vote_count_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].max_votes = None; + assert!(error_codes(&bundle).contains(&Code::MissingField)); +} + +#[test] +fn electing_more_winners_than_there_are_candidates_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].winning_candidates_num = Some(5); + bundle.contests[0].max_votes = Some(5); + assert!(error_codes(&bundle).contains(&Code::ContestArithmetic)); +} + +#[test] +fn allowing_more_selections_than_there_are_candidates_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].max_votes = Some(9); + assert!(error_codes(&bundle).contains(&Code::ContestArithmetic)); +} + +#[test] +fn a_negative_vote_count_is_refused() { + for field in ["min_votes", "max_votes", "winning_candidates_num"] { + let mut bundle = sound(); + match field { + "min_votes" => bundle.contests[0].min_votes = Some(-1), + "max_votes" => bundle.contests[0].max_votes = Some(-1), + _ => bundle.contests[0].winning_candidates_num = Some(-1), + } + + let report = validate(&bundle); + assert!( + report + .errors() + .any(|problem| problem.code == Code::InvalidValue + && problem.path.ends_with(field)), + "a negative {field} should be reported as an invalid value" + ); + + // No wraparound either: i64 to usize would make -1 enormous and trip "more + // winners than candidates" instead. That field only — a negative `max_votes` + // legitimately trips `min_votes > max_votes` as well. + if field == "winning_candidates_num" { + assert!(!report + .errors() + .any(|problem| problem.code == Code::ContestArithmetic)); + } + } +} + +#[test] +fn zero_is_a_count_a_contest_may_legitimately_carry() { + // The bound is negative, not "not positive": a contest a voter may abstain in has + // `min_votes` 0. + let mut bundle = sound(); + bundle.contests[0].min_votes = Some(0); + assert!(!error_codes(&bundle).contains(&Code::InvalidValue)); +} + +#[test] +fn a_contest_with_no_candidates_is_a_warning_not_an_error() { + // An event still being configured has these, and the platform's own export of + // one has to re-import. See the round-trip test at the bottom. + let mut bundle = sound(); + bundle.candidates.clear(); + let report = validate(&bundle); + assert!(!report.has_errors()); + assert!(report + .warnings() + .any(|problem| problem.code == Code::BallotCoverage)); +} + +// -- tally system ----------------------------------------------------------- + +#[test] +fn the_algorithm_list_is_the_enum_and_nothing_else() { + // Fails if a variant's `strum` spelling ever differs from its `serde` one. + for value in COUNTING_ALGORITHMS { + assert!( + CountingAlgType::from_str(value).is_ok(), + "{value} is offered but the platform cannot parse it" + ); + } +} + +#[test] +fn the_preferential_list_matches_the_enum() { + // Both directions: a variant wrongly listed, and one wrongly left out. + for value in COUNTING_ALGORITHMS { + let algorithm = CountingAlgType::from_str(value) + .expect("every offered algorithm parses"); + assert_eq!( + PREFERENTIAL_ALGORITHMS.contains(value), + algorithm.is_preferential(), + "{value}: the list and CountingAlgType::is_preferential disagree" + ); + } +} + +#[test] +fn an_unknown_counting_algorithm_is_rejected() { + // single-transferable-vote is not a CountingAlgType variant, however + // plausible it sounds. + let mut bundle = sound(); + bundle.contests[0].counting_algorithm = + Some("single-transferable-vote".into()); + assert!(error_codes(&bundle).contains(&Code::InvalidValue)); +} + +#[test] +fn an_unknown_voting_type_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].voting_type = Some("ranked".into()); + assert!(error_codes(&bundle).contains(&Code::InvalidValue)); +} + +#[test] +fn preferential_counted_by_plurality_is_rejected() { + // Imports cleanly and then tallies wrongly: ballot encoding follows the + // algorithm, so the rankings a voter entered are read as unordered picks. + let mut bundle = sound(); + bundle.contests[0].voting_type = Some("preferential".into()); + assert!(error_codes(&bundle).contains(&Code::TallyMismatch)); +} + +#[test] +fn non_preferential_counted_by_irv_is_rejected() { + let mut bundle = sound(); + bundle.contests[0].counting_algorithm = Some("instant-runoff".into()); + assert!(error_codes(&bundle).contains(&Code::TallyMismatch)); +} + +#[test] +fn every_preferential_algorithm_is_accepted_with_ranked_voting() { + for algorithm in super::validate::PREFERENTIAL_ALGORITHMS { + let mut bundle = sound(); + bundle.contests[0].voting_type = Some("preferential".into()); + bundle.contests[0].counting_algorithm = Some((*algorithm).into()); + assert!( + !validate(&bundle).has_errors(), + "{algorithm} should be valid for a preferential contest" + ); + } +} + +#[test] +fn every_non_preferential_algorithm_is_accepted_with_unranked_voting() { + let preferential = super::validate::PREFERENTIAL_ALGORITHMS; + for algorithm in super::validate::COUNTING_ALGORITHMS { + if preferential.contains(algorithm) { + continue; + } + let mut bundle = sound(); + bundle.contests[0].counting_algorithm = Some((*algorithm).into()); + assert!( + !validate(&bundle).has_errors(), + "{algorithm} should be valid for a non-preferential contest" + ); + } +} + +// -- ballot coverage -------------------------------------------------------- + +#[test] +fn a_contest_on_no_ballot_is_a_warning_not_an_error() { + // Does not break the import; means nobody can vote in it. + let mut bundle = sound(); + bundle.area_contests.clear(); + let report = validate(&bundle); + assert!(!report.has_errors()); + assert!(report + .warnings() + .any(|problem| problem.code == Code::BallotCoverage)); +} + +#[test] +fn a_leaf_area_with_no_ballot_is_a_warning_not_an_error() { + let mut bundle = sound(); + let orphan = bundle.areas[1].clone(); + let mut orphan = orphan; + orphan.id = "a3000000-0000-5000-8000-000000000000".into(); + orphan.name = Some("Nowhere".into()); + orphan.parent_id = None; + bundle.areas.push(orphan); + + let report = validate(&bundle); + assert!(!report.has_errors()); + assert!(report + .warnings() + .any(|problem| problem.code == Code::BallotCoverage + && problem.message.contains("Nowhere"))); +} + +#[test] +fn a_parent_area_needs_no_ballot_of_its_own() { + // A parent is a grouping; only leaves carry contests. The sound fixture has + // one, so this is really asserting the fixture stays representative. + assert!(!validate(&sound()).has_errors()); +} + +// -- permission labels ------------------------------------------------------ + +#[test] +fn a_permission_label_is_a_warning_not_an_error() { + // The bundle cannot know who holds which label, so this must not block a + // build — but it is the single most expensive thing to discover after import. + let mut bundle = sound(); + bundle.elections[0].permission_label = Some("officers".into()); + + let report = validate(&bundle); + assert!(!report.has_errors()); + assert_eq!( + report + .warnings() + .filter(|problem| problem.code == Code::PermissionLabel) + .count(), + 1 + ); +} + +#[test] +fn the_warning_names_the_labels_in_use() { + let mut bundle = sound(); + bundle.elections[0].permission_label = Some("officers".into()); + let report = validate(&bundle); + let warning = report + .warnings() + .find(|problem| problem.code == Code::PermissionLabel) + .expect("expected a permission label warning"); + assert!(warning.message.contains("officers")); +} + +#[test] +fn no_labels_means_no_warning() { + assert_eq!( + validate(&sound()) + .warnings() + .filter(|problem| problem.code == Code::PermissionLabel) + .count(), + 0 + ); +} + +/// A report definition carrying one permission label. +/// +/// Built rather than taken from the fixture: `reports` is normally empty, because +/// definitions travel in `export_reports-.csv`. See `schema::reports`. +fn labelled_report( + bundle: &ImportElectionEventSchema, + label: &str, +) -> crate::election_config::report::Report { + crate::election_config::report::Report { + id: "11111111-1111-4111-8111-111111111111".into(), + election_event_id: bundle.election_event.id.clone(), + tenant_id: bundle.election_event.tenant_id.clone(), + election_id: None, + report_type: "results".into(), + template_alias: None, + encryption_policy: + crate::election_config::report::EReportEncryption::Unencrypted, + cron_config: None, + created_at: chrono::DateTime::UNIX_EPOCH, + permission_label: Some(vec![label.into()]), + } +} + +#[test] +fn a_label_on_a_report_names_the_reports_collection() { + let mut bundle = sound(); + bundle.reports = vec![labelled_report(&bundle, "auditors")]; + + let report = validate(&bundle); + let warning = report + .warnings() + .find(|problem| problem.code == Code::PermissionLabel) + .expect("expected a permission label warning"); + + assert_eq!(warning.path, "reports[].permission_label"); + assert!(warning.message.contains("auditors")); +} + +#[test] +fn labels_on_both_are_reported_once_each() { + let mut bundle = sound(); + bundle.elections[0].permission_label = Some("officers".into()); + bundle.reports = vec![labelled_report(&bundle, "auditors")]; + + let report = validate(&bundle); + let paths: Vec<&str> = report + .warnings() + .filter(|problem| problem.code == Code::PermissionLabel) + .map(|problem| problem.path.as_str()) + .collect(); + + assert_eq!( + paths, + vec!["elections[].permission_label", "reports[].permission_label"] + ); +} + +// -- identifiers ------------------------------------------------------------ + +#[test] +fn two_entities_sharing_an_id_are_rejected() { + let mut bundle = sound(); + let duplicate = bundle.candidates[0].id.clone(); + bundle.candidates[1].id = duplicate; + assert!(error_codes(&bundle).contains(&Code::DuplicateId)); +} + +#[test] +fn an_id_shared_across_collections_is_rejected() { + // A contest and an area colliding is just as bad as two contests colliding. + let mut bundle = sound(); + let contest_id = bundle.contests[0].id.clone(); + bundle.areas[0].id = contest_id; + assert!(error_codes(&bundle).contains(&Code::DuplicateId)); +} + +// -- reporting -------------------------------------------------------------- + +#[test] +fn every_problem_is_reported_not_just_the_first() { + // Fixing a configuration one error per run is miserable. + let mut bundle = sound(); + bundle.tenant_id = "nope".into(); + bundle.contests[0].voting_type = Some("ranked".into()); + bundle.contests[0].min_votes = Some(99); + assert!(error_codes(&bundle).len() >= 3); +} + +#[test] +fn the_report_serializes_for_a_front_end() { + let mut bundle = sound(); + bundle.contests[0].counting_algorithm = Some("nonsense".into()); + let report = validate(&bundle); + let json = serde_json::to_value(&report).unwrap(); + let first = &json["problems"][0]; + assert!(first["code"].is_string()); + assert!(first["severity"].is_string()); + assert!(first["path"].is_string()); + assert!(first["message"].is_string()); +} + +// -- round tripping --------------------------------------------------------- + +#[test] +fn an_event_still_being_configured_can_be_re_imported() { + // The property that decides where the severity line falls. windmill refuses an + // import on errors, so anything the platform can itself export must validate + // without them — otherwise this check breaks disaster recovery to enforce a + // rule about authoring. + // + // A half-built event: a contest with no candidates yet, one not yet on a + // ballot, and an area nobody has assigned contests to. + let mut bundle = sound(); + bundle.candidates.clear(); + bundle.area_contests.clear(); + + let report = validate(&bundle); + assert!( + !report.has_errors(), + "a mid-configuration export must still import, but got:\n{report}" + ); + assert!( + !report.is_empty(), + "it should still be reported, just not fatally" + ); +} + +#[test] +fn an_inconsistent_bundle_is_still_refused() { + // The other side of that line: warnings are for consistent-but-odd, not for + // anything goes. + let mut bundle = sound(); + bundle.candidates[0].contest_id = + Some("f0000000-0000-5000-8000-000000000000".into()); + assert!(validate(&bundle).has_errors()); +} diff --git a/packages/sequent-core/src/election_config/xlsx.rs b/packages/sequent-core/src/election_config/xlsx.rs new file mode 100644 index 00000000000..c19cbd0622b --- /dev/null +++ b/packages/sequent-core/src/election_config/xlsx.rs @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +//! Reading an `.xlsx` file into a [`Workbook`]. +//! +//! The only part of this module that knows a file format. It converts `calamine` +//! cells into [`Cell`]s and hands the grid to [`Sheet::from_grid`]; every +//! decision about headers, blank rows and coercion is there, where it can be +//! tested without a fixture file. +//! +//! Behind the `election_config_xlsx` feature so the front ends that have no +//! workbook to read do not carry a spreadsheet library into their WASM bundle. +//! Reads from bytes rather than a path, so the same call serves `step-cli` and a +//! browser holding the result of a file input. + +use crate::election_config::paths::Cell; +use crate::election_config::problem::{Code, Problem}; +use crate::election_config::sheet::{Sheet, Workbook}; +use calamine::{Data, Reader, Xlsx}; +use std::io::Cursor; + +/// Read an `.xlsx` from bytes. +/// +/// Formulas are read as their cached result, which is what a spreadsheet stores +/// alongside them. A file written by a tool that does not compute formulas has +/// no cached result, so the cell reads as blank and turns up as a missing +/// required value — better than a formula string reaching the output. +pub fn read_xlsx(bytes: &[u8]) -> Result { + let mut book: Xlsx<_> = Xlsx::new(Cursor::new(bytes)).map_err(|error| { + Problem::error( + Code::InvalidValue, + "workbook", + format!("this does not read as an .xlsx file: {error}"), + ) + })?; + + let names = book.sheet_names().to_vec(); + let mut sheets = Vec::with_capacity(names.len()); + + for name in names { + let range = book.worksheet_range(&name).map_err(|error| { + Problem::error( + Code::InvalidValue, + format!("sheet '{name}'"), + format!("could not be read: {error}"), + ) + })?; + + let grid: Vec> = range + .rows() + .map(|row| row.iter().map(cell_from_data).collect()) + .collect(); + + sheets.push(Sheet::from_grid(name, &grid)?); + } + + Workbook::new(sheets) +} + +/// Narrow one spreadsheet cell to the neutral vocabulary. +fn cell_from_data(data: &Data) -> Cell { + match data { + Data::Empty => Cell::Blank, + Data::String(text) => Cell::text(text.as_str()), + Data::Float(value) => Cell::Float(*value), + Data::Int(value) => Cell::Int(*value), + Data::Bool(value) => Cell::Bool(*value), + + // A duration is asked about before being converted, because + // `as_datetime` will cheerfully turn one into an instant near the 1900 + // epoch — 0.5 becomes 1899-12-31T12:00 — which is a plausible-looking + // timestamp and completely wrong. No schema field wants a duration, so + // the number is handed over for validation to object to instead. + Data::DateTime(excel) if excel.is_duration() => { + Cell::Float(excel.as_f64()) + } + Data::DateTime(excel) => match excel.as_datetime() { + Some(naive) => Cell::DateTime(naive), + // A serial number outside the calendar. Same reasoning: visible + // rather than dropped. + None => Cell::Float(excel.as_f64()), + }, + + // Already text in the file. Left as text on purpose: these are written + // in ISO 8601, which is what the platform wants anyway, and reparsing + // them only adds a way to get the timezone wrong. + Data::DateTimeIso(text) => Cell::text(text.as_str()), + Data::DurationIso(text) => Cell::text(text.as_str()), + + // A formula that evaluated to an error — #REF!, #DIV/0!. Blank would + // hide it; the text makes it show up as an invalid value naming the + // cell. + Data::Error(error) => Cell::text(format!("{error}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::election_config::sheet::{SHEET_ELECTIONS, SHEET_REPORTS}; + use calamine::{CellErrorType, ExcelDateTime, ExcelDateTimeType}; + use serde_json::{json, Value}; + + #[test] + fn an_empty_cell_is_blank() { + assert_eq!(cell_from_data(&Data::Empty), Cell::Blank); + } + + #[test] + fn a_cell_holding_only_spaces_is_blank_too() { + // Someone typed a space; the intent is still nothing. + assert_eq!(cell_from_data(&Data::String(" ".into())), Cell::Blank); + } + + #[test] + fn the_native_types_pass_straight_through() { + assert_eq!(cell_from_data(&Data::Int(7)), Cell::Int(7)); + assert_eq!(cell_from_data(&Data::Float(1.5)), Cell::Float(1.5)); + assert_eq!(cell_from_data(&Data::Bool(true)), Cell::Bool(true)); + assert_eq!( + cell_from_data(&Data::String("board".into())), + Cell::Text("board".to_string()) + ); + } + + #[test] + fn a_date_cell_becomes_an_instant_with_its_time_intact() { + // The time matters as much as the date: it is what opens and closes a + // voting window. + let excel = ExcelDateTime::new( + 46318.677_083_333_336, + ExcelDateTimeType::DateTime, + false, + ); + let Cell::DateTime(naive) = cell_from_data(&Data::DateTime(excel)) + else { + panic!("expected a datetime"); + }; + assert_eq!(naive.to_string(), "2026-10-23 16:15:00"); + } + + #[test] + fn a_duration_keeps_its_number_rather_than_becoming_a_bogus_1899_instant() { + // `as_datetime` turns 0.5 into 1899-12-31T12:00 — plausible-looking and + // entirely wrong — so the kind is asked about first. + let excel = + ExcelDateTime::new(0.5, ExcelDateTimeType::TimeDelta, false); + assert_eq!(cell_from_data(&Data::DateTime(excel)), Cell::Float(0.5)); + } + + #[test] + fn an_iso_timestamp_stays_the_text_it_already_is() { + // ISO 8601 is what the platform wants; reparsing only risks the zone. + assert_eq!( + cell_from_data(&Data::DateTimeIso("2026-10-24T16:15:00Z".into())), + Cell::Text("2026-10-24T16:15:00Z".to_string()) + ); + } + + #[test] + fn a_formula_error_is_visible_rather_than_blank() { + // Blank would hide a broken formula and produce a bundle missing a field + // nobody meant to leave out. + assert_eq!( + cell_from_data(&Data::Error(CellErrorType::Ref)), + Cell::Text("#REF!".to_string()) + ); + } + + #[test] + fn bytes_that_are_not_a_spreadsheet_are_refused_with_a_readable_message() { + let problem = read_xlsx(b"this is not a spreadsheet").unwrap_err(); + assert_eq!(problem.code, Code::InvalidValue); + assert!(problem.message.contains("does not read as an .xlsx")); + } + + /// The smallest real `.xlsx` this can be tested against: written here rather + /// than committed, so there is no binary fixture to keep in step with the + /// code, and no chance of a client's data ending up in the repository. + fn tiny_xlsx(sheets: &[(&str, &[&[&str]])]) -> Vec { + use std::io::Write; + use zip::write::SimpleFileOptions; + + let mut buffer = Vec::new(); + { + let mut zip = zip::ZipWriter::new(Cursor::new(&mut buffer)); + let options = SimpleFileOptions::default(); + + zip.start_file("[Content_Types].xml", options).unwrap(); + let mut content_types = String::from( + r#""#, + ); + for index in 1..=sheets.len() { + content_types.push_str(&format!( + r#""# + )); + } + content_types.push_str(""); + zip.write_all(content_types.as_bytes()).unwrap(); + + zip.start_file("_rels/.rels", options).unwrap(); + zip.write_all( + br#""#, + ) + .unwrap(); + + zip.start_file("xl/workbook.xml", options).unwrap(); + let mut workbook = String::from( + r#""#, + ); + for (index, (name, _)) in sheets.iter().enumerate() { + let id = index + 1; + workbook.push_str(&format!( + r#""# + )); + } + workbook.push_str(""); + zip.write_all(workbook.as_bytes()).unwrap(); + + zip.start_file("xl/_rels/workbook.xml.rels", options) + .unwrap(); + let mut rels = String::from( + r#""#, + ); + for index in 1..=sheets.len() { + rels.push_str(&format!( + r#""# + )); + } + rels.push_str(""); + zip.write_all(rels.as_bytes()).unwrap(); + + for (index, (_, rows)) in sheets.iter().enumerate() { + zip.start_file( + format!("xl/worksheets/sheet{}.xml", index + 1), + options, + ) + .unwrap(); + let mut xml = String::from( + r#""#, + ); + for (row_index, row) in rows.iter().enumerate() { + xml.push_str(&format!(r#""#, row_index + 1)); + for (column_index, value) in row.iter().enumerate() { + let reference = format!( + "{}{}", + (b'A' + column_index as u8) as char, + row_index + 1 + ); + if value.is_empty() { + continue; + } + // Numbers are written as numbers, the way a spreadsheet + // stores them, so the float-to-integer coercion is + // actually exercised. Anything else is an inline string, + // which avoids needing a shared-string table. A value + // wrapped in single quotes is forced to text, for the + // codes an author formats as text on purpose. + if let Some(literal) = value.strip_prefix('\'') { + xml.push_str(&format!( + r#"{literal}"# + )); + } else if value.parse::().is_ok() { + xml.push_str(&format!( + r#"{value}"# + )); + } else { + xml.push_str(&format!( + r#"{value}"# + )); + } + } + xml.push_str(""); + } + xml.push_str(""); + zip.write_all(xml.as_bytes()).unwrap(); + } + + zip.finish().unwrap(); + } + buffer + } + + #[test] + fn a_real_xlsx_reads_into_normalised_sheets() { + let bytes = tiny_xlsx(&[ + ( + "Elections", + &[ + &["external_id", "presentation.i18n.en.name", "max_votes"], + &["board", "Board", "3"], + &["", "", ""], + &["council", "Council", "1"], + ], + ), + ("Read Me", &[&["This tab is documentation"]]), + ]); + + let workbook = read_xlsx(&bytes).unwrap(); + + // Tab names normalise, so lookup does not depend on how a tab is spelled. + assert_eq!(workbook.rows(SHEET_ELECTIONS).len(), 2); + assert_eq!(workbook.rows(SHEET_REPORTS).len(), 0); + + // The blank row in the middle is dropped without shifting the numbering: + // "council" is on line 4 of the spreadsheet. + let council = &workbook.rows(SHEET_ELECTIONS)[1]; + assert_eq!(council.number, 4); + assert_eq!(council.text("external_id"), Some("council")); + + // Coercion has happened: "3" is a number by the time it is a bundle. + let board = &workbook.rows(SHEET_ELECTIONS)[0]; + assert_eq!(board.get("max_votes"), Some(&json!(3))); + assert_eq!( + Value::Object(board.overrides(&["external_id"]).unwrap()), + json!({ + "presentation": {"i18n": {"en": {"name": "Board"}}}, + "max_votes": 3, + }) + ); + + // And the documentation tab is reported rather than silently ignored. + assert_eq!(workbook.unread_sheets(), vec!["Read Me"]); + } + + #[test] + fn a_code_formatted_as_text_keeps_its_leading_zeros() { + // The reason a text cell is never reparsed as a number: member id 007 is + // not 7, and turning it into one would silently fail to match a voter. + let bytes = tiny_xlsx(&[( + "Voters", + &[&["external_id", "member_id"], &["v1", "'007"]], + )]); + let workbook = read_xlsx(&bytes).unwrap(); + assert_eq!( + workbook.rows("voters")[0].get("member_id"), + Some(&json!("007")) + ); + } +} diff --git a/packages/sequent-core/src/lib.rs b/packages/sequent-core/src/lib.rs index 71fd02b022c..2960829641e 100644 --- a/packages/sequent-core/src/lib.rs +++ b/packages/sequent-core/src/lib.rs @@ -9,6 +9,13 @@ extern crate cfg_if; pub mod ballot; #[cfg(feature = "default_features")] pub mod ballot_style; + +// Gated like types::hasura, whose entities the bundle schema is built from. The +// WASM build enables default_features (see build_wasm.yml), so this module is +// present in the browser. +#[cfg(feature = "default_features")] +pub mod election_config; + #[cfg(feature = "default_features")] pub mod error; #[cfg(feature = "default_features")] diff --git a/packages/sequent-core/src/types/ceremonies.rs b/packages/sequent-core/src/types/ceremonies.rs index 2fd9cae2b90..457f30928d0 100644 --- a/packages/sequent-core/src/types/ceremonies.rs +++ b/packages/sequent-core/src/types/ceremonies.rs @@ -8,7 +8,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::default::Default; -use strum_macros::{Display, EnumString}; +use strum_macros::{Display, EnumString, VariantNames}; #[derive( Display, @@ -366,6 +366,8 @@ pub struct TallyResolution { Debug, EnumString, Display, + // `election_config::validate` reads `VARIANTS` as the list of admissible values. + VariantNames, Default, Serialize, Deserialize, diff --git a/packages/sequent-core/src/types/scheduled_event.rs b/packages/sequent-core/src/types/scheduled_event.rs index 6c9d0a9aa35..9f1548bb984 100644 --- a/packages/sequent-core/src/types/scheduled_event.rs +++ b/packages/sequent-core/src/types/scheduled_event.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use strum_macros::Display; +use strum_macros::EnumIter; use strum_macros::EnumString; #[derive( @@ -24,6 +25,9 @@ use strum_macros::EnumString; Eq, Clone, EnumString, + // So the one list of processors is this enum: `election_config` names them in a + // message, and a second list is a second thing to keep in step. + EnumIter, Hash, )] pub enum EventProcessors { diff --git a/packages/windmill/src/postgres/reports.rs b/packages/windmill/src/postgres/reports.rs index 584472591ee..6e7e337e7f8 100644 --- a/packages/windmill/src/postgres/reports.rs +++ b/packages/windmill/src/postgres/reports.rs @@ -18,58 +18,14 @@ use uuid::Uuid; use crate::services::reports::template_renderer::EReportEncryption; -#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)] -pub struct ReportCronConfig { - #[serde(default)] - pub is_active: bool, - #[serde(default)] - pub last_document_produced: Option, - #[serde(default)] - pub cron_expression: String, - #[serde(default)] - pub email_recipients: Vec, - #[serde(default)] - pub executer_username: String, -} - -impl Default for ReportCronConfig { - fn default() -> Self { - ReportCronConfig { - is_active: false, - last_document_produced: None, - cron_expression: Default::default(), - email_recipients: Default::default(), - executer_username: Default::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Report { - pub id: String, - pub election_event_id: String, - pub tenant_id: String, - pub election_id: Option, - pub report_type: String, - pub template_alias: Option, - pub encryption_policy: EReportEncryption, - pub cron_config: Option, - pub created_at: DateTime, - pub permission_label: Option>, -} - -#[allow(non_camel_case_types)] -#[derive(Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString)] -pub enum ReportType { - INITIALIZATION_REPORT, - ELECTORAL_RESULTS, - BALLOT_IMAGES, - BALLOT_RECEIPT, - ACTIVITY_LOGS, - MANUAL_VERIFICATION, - PARTICIPATION_REPORT, - CREDENTIALS, -} +// Report, ReportCronConfig and ReportType now live in +// sequent_core::election_config, so that the tools which write an import bundle +// describe reports exactly as the importer reads them. Re-exported here because +// windmill refers to them by this path in a dozen places. +// +// The database mapping stays below: it needs tokio_postgres, which cannot be in +// a module that compiles to WASM. +pub use sequent_core::election_config::{Report, ReportCronConfig, ReportType}; pub struct ReportWrapper(pub Report); diff --git a/packages/windmill/src/services/export/export_election_event.rs b/packages/windmill/src/services/export/export_election_event.rs index ab875569e47..ccb2ef17fe6 100644 --- a/packages/windmill/src/services/export/export_election_event.rs +++ b/packages/windmill/src/services/export/export_election_event.rs @@ -28,6 +28,7 @@ use crate::types::documents::EDocuments; use anyhow::{anyhow, Context, Result}; use deadpool_postgres::{Client as DbClient, Transaction}; use futures::try_join; +use sequent_core::election_config::emit::{plain_csv, MULTI_VALUE_SEPARATOR, REPORT_COLUMNS}; use sequent_core::services::keycloak::get_event_realm; use sequent_core::services::keycloak::KeycloakAdminClient; use sequent_core::services::s3; @@ -146,8 +147,10 @@ pub async fn read_export_data( std::env::var(ENV_VAR_APP_VERSION).unwrap_or_else(|_| DEV_APP_VERSION.to_string()); let import_election_event_schema = ImportElectionEventSchema { - tenant_id: parse_uuid_v4(&tenant_id)?, - keycloak_event_realm: Some(realm), + // parse_uuid_v4 still runs: the schema now carries a String, but an + // export must not emit a tenant id that is not a UUID. + tenant_id: parse_uuid_v4(&tenant_id)?.to_string(), + keycloak_event_realm: Some(serde_json::to_value(realm)?), election_event: election_event, elections: export_elections, contests: contests.clone(), @@ -448,18 +451,12 @@ pub async fn process_export_zip( let temp_reports_file = NamedTempFile::new() .map_err(|e| anyhow!("Error creating temporary reports file: {e:?}"))?; { - let mut wtr = csv::Writer::from_writer(&temp_reports_file); - wtr.write_record(&[ - "ID", - "Election ID", - "Report Type", - "Template Alias", - "Cron Config", - "Encryption Policy", - "Password", - "Permission Labels", - ]) - .map_err(|e| anyhow!("Error writing CSV header: {e:?}"))?; + // Written through the shared emitter. The header used to read "ID", + // "Election ID", … — harmless, because `process_reports_file` skips it + // and reads by index, but it meant an export and a generated bundle + // differed on sight for no reason. REPORT_COLUMNS is the one name for + // this file's shape. + let mut rows: Vec> = Vec::new(); for report in reports_data { let password = get_password( &hasura_transaction, @@ -470,7 +467,7 @@ pub async fn process_export_zip( .await? .unwrap_or("".to_string()); - wtr.write_record(&[ + rows.push(vec![ report.id.to_string(), report.election_id.unwrap_or_default().to_string(), report.report_type.to_string(), @@ -479,12 +476,15 @@ pub async fn process_export_zip( .map_err(|e| anyhow!("Error serializing cron config: {e:?}"))?, report.encryption_policy.to_string(), password, - report.permission_label.unwrap_or_default().join("|"), - ]) - .map_err(|e| anyhow!("Error writing CSV record: {e:?}"))?; + report + .permission_label + .unwrap_or_default() + .join(MULTI_VALUE_SEPARATOR), + ]); } - wtr.flush() - .map_err(|e| anyhow!("Error flushing CSV writer: {e:?}"))?; + + std::fs::write(temp_reports_file.path(), plain_csv(REPORT_COLUMNS, &rows)) + .map_err(|e| anyhow!("Error writing reports CSV: {e:?}"))?; } let mut reports_file = File::open(temp_reports_file.path()) .map_err(|e| anyhow!("Error opening temporary reports file: {e:?}"))?; diff --git a/packages/windmill/src/services/export/export_schedule_events.rs b/packages/windmill/src/services/export/export_schedule_events.rs index 33404020e69..ff7f5caa1b9 100644 --- a/packages/windmill/src/services/export/export_schedule_events.rs +++ b/packages/windmill/src/services/export/export_schedule_events.rs @@ -6,8 +6,8 @@ use crate::services::documents::upload_and_return_document; use crate::services::providers::transactions_provider::provide_hasura_transaction; use anyhow::Context; use anyhow::{anyhow, Result}; -use csv::Writer; use deadpool_postgres::{Client as DbClient, Transaction}; +use sequent_core::election_config::emit::{json_csv, JsonField, SCHEDULED_EVENT_COLUMNS}; use sequent_core::types::scheduled_event::ScheduledEvent; use sequent_core::util::temp_path::write_into_named_temp_file; use tempfile::{NamedTempFile, TempPath}; @@ -27,6 +27,37 @@ pub async fn read_export_data( Ok(scheduled_events) } +/// One row of `export_scheduled_events-.csv`, in the order the importer reads. +/// +/// Named field by field on purpose. The order is [`SCHEDULED_EVENT_COLUMNS`], and +/// stating each one here is what makes a mismatch a compile-time or test failure +/// instead of a payload silently read as a task id. +fn scheduled_event_row(event: &ScheduledEvent) -> Result> { + /// An `Option` as a field: absent is a SQL NULL, written bare. + fn optional(value: &Option) -> Result { + match value { + None => Ok(JsonField::Null), + Some(value) => JsonField::json(value) + .map_err(|e| anyhow!("Error serializing scheduled event field: {e:?}")), + } + } + + Ok(vec![ + JsonField::string(event.id.clone()), + optional(&event.tenant_id)?, + optional(&event.election_event_id)?, + optional(&event.created_at)?, + optional(&event.stopped_at)?, + optional(&event.archived_at)?, + optional(&event.labels)?, + optional(&event.annotations)?, + optional(&event.event_processor)?, + optional(&event.cron_config)?, + optional(&event.event_payload)?, + optional(&event.task_id)?, + ]) +} + #[instrument(err, skip(transaction))] pub async fn write_export_document( data: Vec, @@ -36,49 +67,29 @@ pub async fn write_export_document( election_event_id: &str, to_upload: bool, ) -> Result<(TempPath)> { - let headers = if let Some(example_event) = data.get(0) { - serde_json::to_value(example_event)? - .as_object() - .ok_or_else(|| anyhow!("Failed to convert ScheduledEvent to JSON object for headers"))? - .keys() - .cloned() - .collect::>() - } else { - vec![ - "id".to_string(), - "tenant_id".to_string(), - "election_event_id".to_string(), - "created_at".to_string(), - "stopped_at".to_string(), - "archived_at".to_string(), - "labels".to_string(), - "annotations".to_string(), - "event_processor".to_string(), - "cron_config".to_string(), - "event_payload".to_string(), - "task_id".to_string(), - ] - }; - let name = format!("scheduled_events-{}", election_event_id); - let mut writer = Writer::from_writer(vec![]); - writer.write_record(&headers)?; - - for scheduled_event in data.clone() { - let values: Vec = serde_json::to_value(scheduled_event)? - .as_object() - .ok_or_else(|| anyhow!("Failed to convert ScheduledEvent to JSON object"))? - .values() - .map(|value| value.to_string()) - .collect(); - - writer.write_record(&values)?; - } + // Written through the shared emitter, which is also what `step-cli` and the + // browser-side tools use, so an export and a generated bundle are the same + // shape rather than two implementations that happen to agree. + // + // This used to derive both the header and each row from + // `serde_json::to_value(event).as_object()`, taking `.keys()` and `.values()`. + // That worked, but only because three unstated things lined up: the + // `preserve_order` feature is enabled somewhere in the dependency graph, so a + // `serde_json::Map` iterates in insertion order rather than alphabetically; + // insertion order is `ScheduledEvent`'s field order; and that order happens to + // match what the importer reads. `import_scheduled_events.rs` takes the + // payload from `record.get(10)` — under alphabetical ordering index 10 is + // `task_id` and the payload is at 5, so every exported event would import with + // its payload read as a task name. Reordering the struct, or losing + // `preserve_order` from the graph, would have done that silently. + let rows: Vec> = data + .iter() + .map(scheduled_event_row) + .collect::>>()?; - let data_bytes = writer - .into_inner() - .map_err(|e| anyhow!("Error converting writer into inner: {e:?}"))?; + let data_bytes = json_csv(SCHEDULED_EVENT_COLUMNS, &rows).into_bytes(); // Write the serialized data into a temporary file let (temp_path, temp_path_string, file_size) = diff --git a/packages/windmill/src/services/import/import_election_event.rs b/packages/windmill/src/services/import/import_election_event.rs index c4b759be401..06347c5d4e7 100644 --- a/packages/windmill/src/services/import/import_election_event.rs +++ b/packages/windmill/src/services/import/import_election_event.rs @@ -110,28 +110,21 @@ use sequent_core::types::keycloak::{ }; use sequent_core::types::scheduled_event::*; use sequent_core::util::temp_path::{generate_temp_file, get_file_size}; -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ImportElectionEventSchema { - pub tenant_id: Uuid, - pub keycloak_event_realm: Option, - pub election_event: ElectionEvent, - pub elections: Vec, - pub contests: Vec, - pub candidates: Vec, - pub areas: Vec, - pub area_contests: Vec, - pub scheduled_events: Option>, - pub reports: Vec, - pub keys_ceremonies: Option>, - pub applications: Option>, - #[serde(default = "default_version")] - pub version: String, -} - -/// Set the default version of an imported election event to be compatible with version 9, which is the first version to include this feature. -fn default_version() -> String { - HISTORICAL_DEFAULT_VERSION.to_string() -} +// The bundle schema now lives in sequent_core::election_config, so that the tools +// which write an import describe it the same way this importer reads it. +// Re-exported because windmill refers to it by this path throughout. +// +// Two field types differ from the struct that used to be here, both so the module +// can compile to WASM for the browser-side tools: +// +// tenant_id String, not Uuid. Import replaces it with the importing +// request's tenant regardless, and every use here +// stringifies it. Its format is checked by validation. +// keycloak_event_realm serde_json::Value, not RealmRepresentation. That type +// comes from the keycloak crate, which pulls reqwest. +// Deserialized into the typed form where it is used. +use sequent_core::election_config; +pub use sequent_core::election_config::ImportElectionEventSchema; #[instrument(err)] pub async fn upsert_b3_and_elog( @@ -579,7 +572,7 @@ pub async fn get_election_event_schema( // with a more obscure error when trying to deserialize data that is incompatible with the current version. let raw: serde_json::Value = serde_json::from_str(data_str) .map_err(|e| anyhow!("Failed to parse import data as JSON: {e}"))?; - let default_ver = default_version(); + let default_ver = HISTORICAL_DEFAULT_VERSION.to_string(); let imported_version = raw .get(VERSION_KEY) .and_then(|v| v.as_str()) @@ -588,9 +581,47 @@ pub async fn get_election_event_schema( .map_err(|_| anyhow!("Environment variable {ENV_VAR_APP_VERSION} should be set"))?; check_version_compatibility(imported_version, ¤t_version)?; let original_data: ImportElectionEventSchema = deserialize_str(data_str)?; + check_bundle(&original_data)?; replace_ids(data_str, &original_data, event_id, tenant_id.clone()) } +/// Run the shared validation, refusing the import if it found anything fatal. +/// +/// The same code answers in the browser before an upload, so a bundle the +/// configuration tools accepted reaches this and passes. When one does not, the +/// operator gets every problem at once rather than the first — and the same +/// wording they would have seen client-side. +/// +/// Validates the bundle as written, before `replace_ids` rewrites the +/// identifiers: a problem naming an id the author never chose is not much use to +/// them. +/// +/// This is deliberately additive. It does not replace the checks that follow — +/// those need the database, and this pass by design does not touch it. +#[instrument(err, skip_all)] +fn check_bundle(data: &ImportElectionEventSchema) -> Result<()> { + let report = election_config::validate(data); + + for problem in report.warnings() { + event!(Level::WARN, "election event import: {problem}"); + } + + if report.has_errors() { + let listing = report + .errors() + .map(|problem| format!(" {problem}")) + .collect::>() + .join("\n"); + let count = report.errors().count(); + let noun = if count == 1 { "problem" } else { "problems" }; + return Err(anyhow!( + "The election event bundle cannot be imported; {count} {noun} found:\n{listing}" + )); + } + + Ok(()) +} + #[instrument(err, skip_all)] pub async fn process_election_event_file( hasura_transaction: &Transaction<'_>, @@ -670,10 +701,18 @@ pub async fn process_election_event_file( default_language = Some(data.election_event.get_default_language()); } + // The bundle carries the realm opaquely; this is where it becomes typed. + let keycloak_event_realm: Option = data + .keycloak_event_realm + .clone() + .map(deserialize_value) + .transpose() + .with_context(|| "Error deserializing keycloak_event_realm")?; + upsert_keycloak_realm( tenant_id.as_str(), &election_event_id, - data.keycloak_event_realm.clone(), + keycloak_event_realm, default_language ) .await @@ -1421,7 +1460,8 @@ pub async fn process_document( if file_name.contains(EDocuments::CERTIFICATES.to_file_name()) { let pem_content = String::from_utf8(file_contents.clone()) .context("Failed to decode certificates PEM as UTF-8")?; - let tenant_uuid = election_event_schema.tenant_id; + let tenant_uuid = Uuid::parse_str(&election_event_schema.tenant_id) + .context("Invalid tenant_id in the imported bundle")?; let election_event_uuid = Uuid::parse_str(&election_event_schema.election_event.id) .context("Failed to parse election event UUID")?; let pem_chunks = split_pem_bundle(&pem_content); @@ -1565,3 +1605,104 @@ pub async fn maybe_create_scheduled_event( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + const TENANT: &str = "90505c8a-23a9-4cdf-a26b-4e19f6a097d5"; + const EVENT: &str = "e0000000-0000-5000-8000-000000000000"; + + /// A bundle that deserializes and is fatally invalid: its contest points at an + /// election that is not in it. + /// + /// Deliberately not a sound one — a sound bundle belongs in `election_config`'s + /// fixtures, shared by both callers rather than copied here. + fn a_bundle_with_a_dangling_election() -> String { + serde_json::json!({ + "tenant_id": TENANT, + "keycloak_event_realm": null, + "election_event": { + "id": EVENT, + "tenant_id": TENANT, + "is_archived": false, + "encryption_protocol": "RSA256" + }, + "elections": [{ + "id": "e1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": EVENT, + "external_id": "officers" + }], + "contests": [{ + "id": "c1000000-0000-5000-8000-000000000000", + "tenant_id": TENANT, + "election_event_id": EVENT, + "election_id": "e9000000-0000-5000-8000-000000000000", + "external_id": "president", + "min_votes": 0, + "max_votes": 1, + "winning_candidates_num": 1, + "voting_type": "non-preferential", + "counting_algorithm": "plurality-at-large" + }], + "candidates": [], + "areas": [], + "area_contests": [], + "scheduled_events": null, + "reports": [], + "keys_ceremonies": [], + "applications": [] + }) + .to_string() + } + + /// The import refuses a bundle the shared rules call fatal, and says why. + /// + /// The integration boundary rather than the rule: `election_config`'s own suite + /// covers what counts as a problem. + #[tokio::test] + async fn a_fatal_bundle_does_not_import() { + std::env::set_var(ENV_VAR_APP_VERSION, DEV_APP_VERSION); + + let outcome = get_election_event_schema( + &a_bundle_with_a_dangling_election(), + None, + TENANT.to_string(), + ) + .await; + + let error = outcome + .expect_err("a contest pointing at a missing election should not import") + .to_string(); + assert!( + error.contains("cannot be imported"), + "unexpected message: {error}" + ); + assert!( + error.contains("contests[0].election_id"), + "unexpected message: {error}" + ); + } + + /// And a bundle with no fatal problems gets through this gate. + /// + /// The same bundle with its one fault repaired, so the pair says which fault the + /// refusal was about. + #[tokio::test] + async fn a_bundle_whose_fault_is_fixed_gets_through() { + std::env::set_var(ENV_VAR_APP_VERSION, DEV_APP_VERSION); + + let mut bundle: serde_json::Value = + serde_json::from_str(&a_bundle_with_a_dangling_election()).expect("the fixture parses"); + bundle["contests"][0]["election_id"] = + serde_json::json!("e1000000-0000-5000-8000-000000000000"); + + let (_schema, ids) = + get_election_event_schema(&bundle.to_string(), None, TENANT.to_string()) + .await + .expect("a bundle with no fatal problems should get past validation"); + + assert!(!ids.is_empty()); + } +} diff --git a/packages/windmill/src/services/reports/template_renderer.rs b/packages/windmill/src/services/reports/template_renderer.rs index 4916d4b6311..c0fb082fbfd 100644 --- a/packages/windmill/src/services/reports/template_renderer.rs +++ b/packages/windmill/src/services/reports/template_renderer.rs @@ -82,16 +82,9 @@ pub enum ReportOriginatedFrom { ReportsTab, } -#[allow(non_camel_case_types)] -#[derive( - Display, Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumString, IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum EReportEncryption { - Unencrypted, - ConfiguredPassword, -} +// Moved to sequent_core::election_config so the import writers and the importer +// agree on the wire form. Re-exported: windmill refers to it by this path. +pub use sequent_core::election_config::EReportEncryption; pub const DEFAULT_ITEMS_PER_REPORT_LIMIT: usize = 1000; /// Trait that defines the behavior for rendering templates