diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000000..3183c465fb4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Sequent Tech Inc +# SPDX-License-Identifier: AGPL-3.0-only +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + enabled: true + auto_incremental_review: true + auto_pause_after_reviewed_commits: 0 + base_branches: + - 'release/10\.0' + - 'feat/meta-12767/.*' diff --git a/docs/docusaurus/README.md b/docs/docusaurus/README.md index e33ce046230..ebb88868d17 100644 --- a/docs/docusaurus/README.md +++ b/docs/docusaurus/README.md @@ -44,3 +44,37 @@ $ GIT_USER= yarn deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. + +## Synchronized code examples + +Use ordinary Markdown fences for shared commands. For alternatives, place adjacent +fences with the same `group` and a distinct `tab` label: + +````markdown +```bash group="engine" tab="k6" +step-cli load prepare voting-load.yaml \ + --engine k6 \ + --output runs/smoke +``` + +```bash group="engine" tab="Chromium" +step-cli load prepare voting-load.yaml \ + --engine chromium \ + --output runs/smoke +``` +```` + +Selecting a tab switches all matching groups on that page and remembers the +choice. Use consistent labels within a group. Groups on other pages are independent. +Any labels and number of alternatives work: for example, `group="language"` with +`tab="Go"`, `tab="Rust"` and `tab="PHP"`. A group missing the selected alternative +keeps its current selection. A lone fence renders without tabs. + +No imports or JSX are needed. Titles and line highlighting work as usual. Keep +prose outside adjacent alternatives; prose separates them into distinct selectors. +The first alternative is the default for a new reader. Use different group names +for independent choices on the same page. + +From this directory, run `node --test plugins/*.test.js` to test the Markdown +transform and `yarn build` to check the complete site. The widget uses Docusaurus +Tabs for keyboard navigation, synchronization and light/dark theme support. diff --git a/docs/docusaurus/docs/07-developers/02-cli/01-cli_cli.md b/docs/docusaurus/docs/07-developers/02-cli/01-cli_cli.md index d923d466e5d..0f138ff9826 100644 --- a/docs/docusaurus/docs/07-developers/02-cli/01-cli_cli.md +++ b/docs/docusaurus/docs/07-developers/02-cli/01-cli_cli.md @@ -1,16 +1,98 @@ --- id: cli -title: CLI +title: Step CLI --- - + + +`step-cli` administers elections through authenticated APIs. `step-cli load` prepares synthetic voting workloads and runs them with k6 or Chromium. +## Build in the devcontainer +From the repository root: -This is a placeholder page for the section: cli. +```bash +devenv shell +export CARGO_TARGET_DIR="$PWD/packages/step-cli/rust-local-target" +cargo build \ + --manifest-path packages/step-cli/Cargo.toml \ + --bin step-cli +export PATH="$CARGO_TARGET_DIR/debug:$PATH" +step-cli load \ + --help +``` -Content will be added here soon. +The binary includes Rust coordination, native encryption, SQLite aggregation, SVG/HTML reporting and election fixtures. HTTP workloads require only k6 alongside the CLI; the repository devenv provides it. Browser runs and report screenshots additionally need Node.js, `@playwright/test` and its matching Chromium installation. The devcontainer provides Chromium, which initialization discovers on `PATH`. + +For browser workloads or report screenshots outside devenv, install Playwright: + +```bash +npm install \ + --prefix .load-browser \ + --save-exact @playwright/test@1.62.1 +.load-browser/node_modules/.bin/playwright install \ + --with-deps chromium +``` + +Set `runtime.playwright_dir` to the absolute `.load-browser` path in your workload. Install k6 in `PATH`, or set `runtime.k6` to its executable. `load check` reports missing dependencies before election provisioning. + +## Authenticate a tenant administrator + +Use an existing tenant reserved for synthetic voters, an administrator account, and a Keycloak client enabled for CLI authentication. The client belongs to the tenant's administrative realm. Obtain its ID and secret from the deployment operator; a public client uses an empty secret. + +```bash +umask 077 +read -r -p 'Synthetic tenant ID: ' TENANT_ID +read -r -p 'GraphQL URL: ' GRAPHQL_URL +read -r -p 'Keycloak base URL: ' KEYCLOAK_URL +read -r -p 'Administrator username: ' ADMIN_USER +read -rs -p 'Administrator password: ' ADMIN_PASSWORD +read -r -p 'CLI client ID: ' CLIENT_ID +read -rs -p 'CLI client secret: ' CLIENT_SECRET + +step-cli step config \ + --tenant-id "$TENANT_ID" \ + --endpoint-url "$GRAPHQL_URL" \ + --keycloak-url "$KEYCLOAK_URL" \ + --keycloak-user "$ADMIN_USER" \ + --keycloak-password "$ADMIN_PASSWORD" \ + --keycloak-client-id "$CLIENT_ID" \ + --keycloak-client-secret "$CLIENT_SECRET" +unset ADMIN_PASSWORD CLIENT_SECRET +``` + +The local devcontainer URLs are `http://graphql-engine:8080/v1/graphql` and `http://keycloak:8090`. The CLI stores its session in `config/configuration.json` beside the executable; that directory must be writable and kept private. + +Before automatic election setup, check the tenant's registered trustees: + +```bash +step-cli step list-trustees +``` + +If registration is needed, obtain each running trustee service's name and public key, then register it: + +```bash +read -r -p 'Trustee service name: ' TRUSTEE_NAME +read -r -p 'Trustee public key (base64): ' TRUSTEE_PUBLIC_KEY +step-cli step create-trustee \ + --name "$TRUSTEE_NAME" \ + --public-key "$TRUSTEE_PUBLIC_KEY" +``` + +Register enough running trustees for the configured ceremony threshold. Continue with the [voting load quickstart](../05-voting-portal/voter-status-performance.md). + +## Reference and development + +Use `step-cli load reference` for configuration defaults and command options. The [generated reference](./voting-load-reference.md) is produced from the CLI help and configuration rustdoc. + +```bash +step-cli load reference \ + --output docs/docusaurus/docs/07-developers/02-cli/voting-load-reference.md +cargo test \ + --manifest-path packages/step-cli/Cargo.toml +cargo doc \ + --manifest-path packages/step-cli/Cargo.toml \ + --no-deps \ + --document-private-items +``` diff --git a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/_category_.yml b/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/_category_.yml deleted file mode 100644 index 29c5f76eb13..00000000000 --- a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/_category_.yml +++ /dev/null @@ -1,2 +0,0 @@ -label: 'Tutorials' -position: 2 \ No newline at end of file diff --git a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/_category_.yml b/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/_category_.yml deleted file mode 100644 index 89864188a19..00000000000 --- a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/_category_.yml +++ /dev/null @@ -1 +0,0 @@ -label: 'Load Testing' \ No newline at end of file diff --git a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/assets/duplicate_votes_usage.mp4 b/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/assets/duplicate_votes_usage.mp4 deleted file mode 100644 index b296fd325e0..00000000000 Binary files a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/assets/duplicate_votes_usage.mp4 and /dev/null differ diff --git a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/load_testing.md b/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/load_testing.md deleted file mode 100644 index ba76415f7e0..00000000000 --- a/docs/docusaurus/docs/07-developers/02-cli/02-tutorials/load-testing/load_testing.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -id: load_testing -title: Load Testing ---- - -## Introduction - -This tutorial will allow you to create an election with 1M voters, cast 1K votes -using a headless chrome web browser and then duplicate votes faster using the -step cli. - -## Requirements - -You need: -- Basic knowledge of command line terminal usage. -- [Kubectl installed][kubectl]. -- A [kubeconfig file][kubeconfig] that gives access to the cluster. We'll assume - it's in `~/.kube/prod1-euw1-kubeconfig.yml` throughout the tutorial. - -## Creating an election - -## Duplicating votes - -### 1. Access and Configuration - -First we will set the path to the kubeconfig file so that we can use it for all -our `kubectl` plugins: - -```bash -export KUBECONFIG=~/.kube/prod1-euw1-kubeconfig.yml -``` - -Let's review the loadtesting pod using the following command: - -```bash -kubectl get pods -n test-apps -l app.kubernetes.io/name=loadtesting -``` - -The output should looks something like: - -```bash -NAME READY STATUS RESTARTS AGE -loadtesting-86c5944494-j7gnq 1/1 Running 0 4d21h -``` - -Please note that we are filtering for pods in `test-apps` namespace. Change this -accordingly to the name of your environment. For example, the `ehu` environment -would require to use here the `ehu-apps`. - -We can connect to any of these loadtesting pods using the following kind of -command. Please change the pod name and the namespace name accordingly: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh --help -Usage: /entrypoint.sh [options] -Subcommands: - load-tool Run the load-tool tool - vote-cast Run vote casting load tests [--voting-url ] - shell Start an interactive shell - sleep Sleeps for an infinite amount of time - step-cli Run step-cli - -For vote-cast subcommand: - --voting-url Voting URL (falls back to $VOTING_URL or $LOADTESTING_VOTING_URL if not provided) - Other options are forwarded to /run_bg_voting.sh (e.g. --batches, --instances, --save-screenshots, --env chrome) -``` - -With this command, you can check what load testing actions and commands are available. - -### 2. Executing the `load-tool` script to duplicate votes - -We can check what `load-tool` options are available by running the entrypoint: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh load-tool --help -usage: load_tool.py [-h] [--working-directory WORKING_DIRECTORY] {generate-voters,duplicate-votes,generate-applications,generate-activity-logs} ... - -Load Testing Tool - -positional arguments: - {generate-voters,duplicate-votes,generate-applications,generate-activity-logs} - Action to perform - generate-voters Generate random voters CSV file - duplicate-votes Duplicate cast votes in the database - generate-applications - Generate applications in different states - generate-activity-logs - Generate activity logs - -options: - -h, --help show this help message and exit - --working-directory WORKING_DIRECTORY - Path to working directory (input/output directory) -``` - -At this stage we are assuming we have: -1. The election event created. -2. The Keys ceremony has been executed. -3. The election event has been published. -4. The eligible voters have been loaded and there's enough voters to add more - votes. -5. The voting period is open, so votes can be cast. -6. There's at least one vote cast. -7. The election allows revoting, because the votes are added randomly and - otherwise in some cases more than 1 vote might be added for a single voter. - -Given the above, we can just duplicate votes with a command like below, please -change the election event id accordingly: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - load-tool duplicate-votes \ - --num-votes 10 \ - --election-event-id 7d7f840a-4e75-4ba4-b431-633196da1a2c -``` - -The election event id can be found in the admin portal in the URL of the -election event. - -#### Environment variables required for duplicate votes - -The duplicate-votes action connects to two PostgreSQL databases (Keycloak and Hasura). Connection parameters are read from environment variables inside the loadtesting container. Ensure these are set in the Deployment (typically via Secrets/ConfigMaps) for the loadtesting pod: - -- Keycloak DB - - KEYCLOAK_DB__DBNAME - - KEYCLOAK_DB__USER - - KEYCLOAK_DB__PASSWORD - - KEYCLOAK_DB__HOST - - KEYCLOAK_DB__PORT - -- Hasura DB - - HASURA_DB__DBNAME - - HASURA_DB__USER - - HASURA_DB__PASSWORD - - HASURA_DB__HOST - - HASURA_DB__PORT - -You can quickly verify that these variables are present in the running pod: - -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- env | grep -E '^(KEYCLOAK_DB__|HASURA_DB__)' -``` - -Note: If any are missing or incorrect, update the loadtesting Deployment (or its referenced Secret/ConfigMap) and redeploy so the pod picks them up. - -#### duplicate-votes arguments - -The duplicate-votes subcommand accepts the following arguments: - -- --num-votes `` (required) - - Number of votes to insert by duplicating existing votes. -- --election-event-id `` (required) - - The Election Event ID the votes belong to. -- --election-id `` (optional) - - If omitted, the tool discovers an election_id with at least one existing vote in the event and uses that. -- --tenant-id `` (optional; default: 90505c8a-23a9-4cdf-a26b-4e19f6a097d5) - - Used to build the Keycloak realm name as `tenant-{tenant_id}-event-{election_event_id}` for querying eligible voters. - -Operational notes: -- The tool will: - 1) Find an existing vote for the given election event (and election if specified) to use as a base template. - 2) Determine the area_id and election_id (if not passed). - 3) Fetch up to --num-votes random eligible voter IDs from Keycloak for that area. - 4) Duplicate existing cast_vote rows, reassigning voter_id_string to the fetched users, and bulk-insert via COPY for speed. -- The election should allow revoting to avoid collisions, since random users may already have cast a vote. -- There must already be at least one cast vote in the target election/area to serve as the duplication template. - -Please find below a short video that shows how we: -1. Enter the Dashboard of the Election Event, which currently has 400K voters - and 12 votes cast today and 566 votes in total. -2. Copy the election event id from the Admin Portal URL. -3. Execute the `duplicate-votes` subcommand adding 10 votes. -4. Show in the Dashboard that 10 votes have been added, having now 22 votes - cast today and 576 in total. - - - -[kubectl]: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/ -[kubeconfig]: https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/ - -## Cast votes using chromium headless -### 1. Executing the `vote-cast` command to perform vote loading tests - -The `vote-cast` subcommand drives a Nightwatch-based browser test inside the loadtesting pod to cast votes through the public voting UI. - -How it works (pipeline): -- `/entrypoint.sh vote-cast` forwards arguments to `/run_bg_voting.sh`. -- `/run_bg_voting.sh` orchestrates parallel Nightwatch runs using the base test at `/nightwatch/src/voting.js` by default. -- Nightwatch runs headlessly by default (env `default`); you can switch to non-headless with `--env chrome`. - -Key flags and environment variables: -- `--voting-url ` - - The voting login URL. If omitted, the script uses `$VOTING_URL` or `$LOADTESTING_VOTING_URL` if set in the pod. - - Always quote the URL. -- `--batches ` - - Total iterations each Nightwatch instance will perform. This maps 1:1 to `NUMBER_OF_ITERATIONS` consumed by `nightwatch/src/voting.js`. -- `--instances ` - - Parallelism. The orchestrator duplicates the base test into N files and runs them concurrently using Nightwatch workers. -- `--save-screenshots ` (default: `false`) - - When `true`, screenshots are saved during the flow. -- `--number-of-voters ` (default: `4096`) - - Used by the test to randomize test users. -- `--voter-min-index ` (default: `1`) - - The ids for the voters will be selected between `voter-min-index` and `voter-min-index + number-of-voters - 1`. -- `--candidates-pattern ` (default: empty) - - Regular expression to filter candidates by name. Supports JavaScript regex format like `/^(?!.*text).*$/` to exclude candidates containing specific text. -- `--username-pattern ` (default: `user{n}`) -- `--password-pattern ` (default: `user{n}`) - - `{n}` is replaced by the randomized user index. -- `--env ` (default: `default`) - - Nightwatch environment. `default` runs Chrome headless; `chrome` is non-headless (not recommended in pods). -- Advanced: - - `--base-test ` (default: `nightwatch/src/voting.js`) - - Allows running another test file, e.g., `nightwatch/src/voting2.js`. - - `--keep-parallel-files` - - Keeps the generated duplicate test files under `/nightwatch/src/_parallel_`. - -Outputs and logs: -- Aggregated Nightwatch log: `/logs/nightwatch_.log` (inside the container). -- Screenshots (when enabled): `/nightwatch/screenshots` in the container. -- Temporary per-run test copies: `/nightwatch/src/_parallel__PID` (removed by default unless `--keep-parallel-files`). - -Examples: -- Single instance, single iteration (sanity check): -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 1 \ - --instances 1 -``` - -- 8 instances in parallel, 200 iterations each: -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 200 \ - --instances 8 \ - --save-screenshots false -``` - -- Debugging with non-headless Chrome (use sparingly; pods may not support it): -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 1 \ - --instances 1 \ - --env chrome \ - --save-screenshots true -``` - -Troubleshooting: -- If your run prints defaults (e.g., `INSTANCES: 4` or `ITERATIONS: 10`) despite passing flags, ensure the flags follow `vote-cast` and that the URL is quoted. -- If the site markup requires different selectors, consider providing an alternative base test via `--base-test nightwatch/src/voting2.js`. -- To keep the generated parallel files for inspection, add `--keep-parallel-files` and check `/nightwatch/src/_parallel_*`. - -## Managing an election event through the `step-cli` - -You can check what options are available to you by calling the `step-cli` CLI tool: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh step-cli step --help -Usage: step-cli step - -Commands: - config Create a config file - create-election-event Create a new election event - create-election Create a new election - create-contest Create a new contest - create-candidate Create a new candidate - create-area Create a new area - create-area-contest Create area contest - create-voter Create a new voter - export-cast-votes Export a cast vote - update-voter Edit a voter - update-election-event-status Update election event status - update-election-status Update election status - import-election Import Election Event - publish Publish election event ballot changes - refresh-token Refresh auth jwt - start-key-ceremony Start Key Ceremony - complete-key-ceremony Complete Key Ceremony - start-tally Start Tally Ceremony - update-tally Update tally status - confirm-key-tally Confirm trustee key for tally ceremony - render-template Render a handlebars-rs template with variables - generate-voters - duplicate-votes - create-applications - create-electoral-logs - hash-password Process a CSV file to hash passwords and generate salts - help Print this message or the help of the given subcommand(s) - -Options: - -h, --help Print help -``` diff --git a/docs/docusaurus/docs/07-developers/02-cli/voting-load-reference.md b/docs/docusaurus/docs/07-developers/02-cli/voting-load-reference.md new file mode 100644 index 00000000000..4586f6e8328 --- /dev/null +++ b/docs/docusaurus/docs/07-developers/02-cli/voting-load-reference.md @@ -0,0 +1,295 @@ +--- +title: Voting load reference +sidebar_position: 10 +--- + + + + +Generated by `step-cli load reference`. Unknown configuration keys are rejected. Local initialization derives tenant endpoints, the available browser, and Docker networking from the coordinator. Paths in preparation are relative to the workload YAML. Secrets are referenced by environment-variable name. + +## reference + +```text +Generate command/configuration Markdown from help text and Rust field documentation + +Usage: reference [OPTIONS] + +Options: + --output + Destination file; stdout when omitted + + -h, --help + Print help + +``` + +## image + +```text +Build a source-only worker image from the bundled runtime + +Usage: image [OPTIONS] --engine --tag + +Options: + --engine + Engine to include in the image + + Possible values: + - k6: Authenticated HTTP with native encryption completed before measurement + - chromium: Full browser rendering, selection, encryption and confirmation + + --tag + Docker tag; include a registry for Kubernetes workers + + --push + Push the built image using the current Docker registry credentials + + --rust-image + Rust builder image for the standalone worker + + [default: rust:1.96.0-bookworm] + + --k6-image + Image supplying the k6 executable + + [default: grafana/k6:1.6.0] + + --worker-image + Base image for protocol workers + + [default: debian:bookworm-slim] + + --browser-image + Base image for browser workers + + [default: node:22-bookworm-slim] + + --playwright-version + Playwright version, kept in sync with the portal test runner + + [default: 1.62.1] + + -h, --help + Print help (see a summary with '-h') + +``` + +## init + +```text +Generate a documented workload using the configured tenant administrator + +Usage: init [OPTIONS] + +Options: + --target + Local devcontainer defaults or remote deployment URLs from CLI configuration + + [default: local] + [possible values: local, remote] + + --output + YAML destination; existing files are never overwritten + + [default: voting-load.yaml] + + --portal-url + Voting-portal base URL; required for remote targets + + --storage-origin + Public publication-storage origin; required for remote targets + + -h, --help + Print help + +``` + +## prepare + +```text +Validate dependencies, create the election/census, and prepare immutable worker inputs + +Usage: prepare [OPTIONS] --output + +Arguments: + + Workload YAML written by init + +Options: + --output + Fresh run directory, including private provisioning logs and worker inputs + + --engine + Override the engine before preparing inputs + + Possible values: + - k6: Authenticated HTTP with native encryption completed before measurement + - chromium: Full browser rendering, selection, encryption and confirmation + + --workers + Override parallel preparation workers + + -h, --help + Print help (see a summary with '-h') + +``` + +## run + +```text +Execute a prepared run once and automatically write its HTML report + +Usage: run [OPTIONS] + +Arguments: + + Prepared run directory + +Options: + --workers + Override the worker count recorded in the workload + + --executor + Execution backend; defaults to the workload's configured executor + + Possible values: + - local: Parallel worker processes on the coordinator + - docker: Isolated containers sharing prepared inputs through a bind mount + - kubernetes: Indexed pods with durable shared input and attempt storage + + -h, --help + Print help (see a summary with '-h') + +``` + +## report + +```text +Rebuild aggregate results; optional database auditing remains coordinator-only + +Usage: report [OPTIONS] + +Arguments: + + Completed or interrupted run directory + +Options: + --dsn-env + Environment variable containing a read-only backend PostgreSQL DSN + + --open + Open the standalone report with the configured browser opener + + --screenshot + Capture the report as a PNG using the configured Playwright browser + + -h, --help + Print help + +``` + +## check + +```text +Validate configuration and check installed engine dependencies + +Usage: check + +Arguments: + + + +Options: + -h, --help + Print help + +``` + +## target + +| Setting | Default | Meaning | +| --- | --- | --- | +| `tenant_id` | `from CLI session` | Existing synthetic tenant administered by the CLI session. | +| `portal_url` | `"http://localhost:3000"` | Voting portal base URL, including a path prefix if deployed under one. | +| `keycloak_url` | `"http://keycloak:8090"` | Keycloak base URL. | +| `graphql_url` | `"http://graphql-engine:8080/v1/graphql"` | Full GraphQL endpoint. | +| `storage_origins` | `["http://minio-proxy:9002"]` | Public S3/CDN origins returned by publication signing. | +| `upload_mode` | `"local"` | Rewrite upload hosts for the devcontainer's local storage network only. | + +## workload + +| Setting | Default | Meaning | +| --- | --- | --- | +| `engine` | `"k6"` | HTTP protocol or full browser journey. | +| `mode` | `"vote"` | `vote` executes the whole journey; `status` measures login and voter status. | +| `count` | `100` | Number of distinct voters, independent of worker count. | +| `start` | `0` | Numeric suffix of the first voter. | +| `username_prefix` | `"load-"` | Prefix prepended to every numeric suffix. | +| `shard_size` | `25` | Maximum ballots loaded by one worker at a time. | +| `concurrency` | `2` | Concurrent voters per worker. | +| `max_duration` | `"30m"` | Maximum duration of each finite k6 shard, in k6 duration notation. | +| `request_timeout` | `"30s"` | HTTP request deadline for login, status and publication downloads. | +| `cast_timeout` | `"60s"` | HTTP cast request deadline. | +| `locale` | `"en"` | Language requested from Keycloak during protocol login. | +| `password_env` | `"LOAD_PASSWORD"` | Environment variable containing the shared synthetic voter password. | +| `hash_iterations` | `27500` | PBKDF2-SHA256 rounds, also installed as the fixture realm's policy. | +| `client_id` | `"voting-portal"` | OIDC client registered in the election realm. | +| `journey_timeout_ms` | `180000` | Browser journey timeout, in milliseconds. | +| `action_timeout_ms` | `15000` | Browser locator/expectation timeout, in milliseconds. | +| `trace_http` | `false` | Retain sanitized per-fetch diagnostics in private logs. | + +## execution + +| Setting | Default | Meaning | +| --- | --- | --- | +| `workers` | `1` | Independent worker processes or pods. | +| `executor` | `"local"` | `local`, `docker`, or `kubernetes`. | +| `image` | `"voting-load:k6"` | Worker image, built from the bundled package before remote execution. | +| `network` | `"step_devcontainer_default"` | Docker network reachable from the target endpoints. | +| `docker_mount_source` | `null` | Optional daemon-host path to prepared inputs; devcontainer bind mounts are otherwise discovered. | +| `namespace` | `"default"` | Kubernetes namespace for this run. | +| `storage_class` | `""` | ReadWriteMany storage class; required for Kubernetes. | +| `storage_size` | `"20Gi"` | Kubernetes volume capacity, including results. | +| `wait_timeout` | `"1h"` | Maximum wait for Kubernetes resources, using kubectl duration notation. | +| `resources` | `{"limits":{"cpu":"2","memory":"4Gi"},"requests":{"cpu":"1","memory":"1Gi"}}` | Kubernetes per-worker requests and limits (cpu/memory resource quantities). | + +## preparation + +| Setting | Default | Meaning | +| --- | --- | --- | +| `template` | `null` | Optional exported event template; the bundled single-election fixture is the default. | +| `existing_event` | `null` | Optional prior generated event config; provisions only a new voter range. | +| `choices` | `null` | Optional explicit decoded ballot selections. | +| `threshold` | `2` | Automatic trustee threshold. | +| `poll_interval_seconds` | `5` | Key-ceremony polling interval in seconds. | +| `ceremony_timeout_seconds` | `600` | Maximum ceremony wait in seconds. | +| `publication_preparer` | `null` | Optional application publication writer for deployments with separate S3 preparation. | + +## runtime + +| Setting | Default | Meaning | +| --- | --- | --- | +| `k6` | `"k6"` | k6 executable. | +| `node` | `"node"` | Node.js executable for Chromium workers. | +| `playwright_dir` | `packages/voting-portal under the current directory` | Directory containing the installed @playwright/test package. | +| `chromium` | `CHROMIUM_EXECUTABLE_PATH, chromium on PATH, or Playwright default` | Optional system Chromium executable; otherwise use Playwright's matching browser. | +| `opener` | `"xdg-open"` | Executable used by `report --open`. | + +## reporting + +| Setting | Default | Meaning | +| --- | --- | --- | +| `max_errors` | `20` | Maximum failure details retained in a report; total failure counts remain exact. | +| `cdf_points` | `51` | Quantile points per cumulative latency curve, including its endpoints. | +| `bins` | `60` | Maximum throughput chart buckets; independent of voter count. | +| `sqlite_cache_kib` | `16384` | SQLite aggregation cache in KiB; sorting spills to disk. | +| `audit_batch_size` | `1000` | Receipt IDs queried per read-only audit batch. | +| `audit_timeout_ms` | `30000` | PostgreSQL statement timeout in milliseconds. | +| `audit_connect_timeout_seconds` | `15` | PostgreSQL connection timeout in seconds. | +| `screenshot_width` | `1200` | Screenshot viewport width in CSS pixels. | +| `screenshot_height` | `1000` | Screenshot initial viewport height; the full report is captured. | + +## Goals + +`goals` defaults to an empty mapping. Supported stages are `status_ms`, `cast_ms`, and `journey_ms`; each accepts positive `p50` and `p99` limits in milliseconds. `min_casts_per_second` defaults to zero (disabled). Status-only workloads cannot configure cast goals. Every planned journey must succeed regardless of optional performance goals. diff --git a/docs/docusaurus/docs/07-developers/05-voting-portal/voter-status-performance.md b/docs/docusaurus/docs/07-developers/05-voting-portal/voter-status-performance.md new file mode 100644 index 00000000000..464b433c059 --- /dev/null +++ b/docs/docusaurus/docs/07-developers/05-voting-portal/voter-status-performance.md @@ -0,0 +1,355 @@ +--- +sidebar_position: 7 +title: Voting load tests +--- + + + + +Measure real voting journeys with **k6** or **Chromium** through `step-cli load`. Both authenticate distinct synthetic voters, read voter status, download published ballots from S3, and submit votes through the normal API. + +| Engine | Measures | Preparation | +| --- | --- | --- | +| k6 | Authenticated HTTP journey through cast acceptance | Native encryption; no browser needed | +| Chromium | Full portal, including rendering and WASM encryption | Census and publication | + +## What a run exercises + +1. Authenticate each voter through Keycloak authorization, username/password login and PKCE token exchange. +2. Read `GetVoterStatus` through GraphQL for eligibility, cast status and signed publication URLs. +3. Download the event, elections, summaries and ballot styles from S3. +4. Submit `InsertCastVote` and verify the returned receipt. + +Chromium opens the real portal, renders the election list, selects candidates and encrypts through WASM. k6 performs the HTTP journey using ciphertexts generated before the timed run. It requires no browser capture. Preparation and reporting run in Rust for both engines. + +## Before you start + +Run against a deployment with the S3 voting path enabled, its portal and cast services running, and a tenant reserved for synthetic voters. The tenant must have automatic trustees registered and running. The default fixture uses a threshold of two; `preparation.threshold` is configurable. + +Install and authenticate the CLI using [CLI setup](../02-cli/01-cli_cli.md). In a repository devcontainer, enter `devenv shell` from the repository root; it provides k6. Coordination, census generation, encryption and reporting run natively in Rust. The devcontainer also provides Chromium; initialization records its executable automatically. `load check` launches it before browser preparation to verify dependencies. Outside devenv, follow the browser installation instructions in CLI setup. + +## Prepare and run + +`init` uses the tenant and administrator session already configured in the CLI. Its default workload is 100 voters, two concurrent voters per worker, and a single-election fixture. + +```bash +step-cli load init \ + --target local + +read -rs -p 'Shared synthetic voter password: ' LOAD_PASSWORD +export LOAD_PASSWORD + +step-cli load check voting-load.yaml +``` + +Choose an engine below; matching examples on this page switch together. + +```bash group="engine" tab="k6" +step-cli load prepare voting-load.yaml \ + --engine k6 \ + --output runs/smoke +step-cli load run runs/smoke \ + --workers 4 +step-cli load report runs/smoke \ + --open +``` + +```bash group="engine" tab="Chromium" +step-cli load prepare voting-load.yaml \ + --engine chromium \ + --output runs/smoke +step-cli load run runs/smoke \ + --workers 4 +step-cli load report runs/smoke \ + --open +``` + +Preparation creates the election, imports the census, completes the automatic key ceremony, publishes, opens voting and prepares worker inputs. The run command automatically writes `runs/smoke/report.html`; `report` opens or regenerates it. No administrator credentials are sent to workers. + +Each preparation creates an isolated election unless `preparation.existing_event` is configured. Every run can be executed **once**. Keep an interrupted run for investigation; do not delete its attempt markers or rerun it with the same voters. + +## Define your workload + +Edit `voting-load.yaml` before preparation. All effective defaults are written by `init`. For example: + +```yaml group="engine" tab="k6" +workload: + engine: k6 + mode: vote + count: 10000 + username_prefix: load- + start: 0 + shard_size: 1000 + concurrency: 20 + password_env: LOAD_PASSWORD +execution: + workers: 20 +goals: + status_ms: {p50: 100, p99: 500} + cast_ms: {p50: 100, p99: 500} + journey_ms: {p50: 2000, p99: 5000} +min_casts_per_second: 100 +``` + +```yaml group="engine" tab="Chromium" +workload: + engine: chromium + mode: vote + count: 1000 + username_prefix: load- + start: 0 + shard_size: 100 + concurrency: 2 + password_env: LOAD_PASSWORD +execution: + workers: 4 +goals: + status_ms: {p50: 100, p99: 500} + cast_ms: {p50: 100, p99: 500} + journey_ms: {p50: 2000, p99: 5000} +min_casts_per_second: 1 +``` + +Start with 100 voters when checking a deployment. The examples above use 10,000 voters for k6 and 1,000 for Chromium; increase load only after reviewing the smaller run. Goals are workload targets, not promised capacity. + +A worker loads one shard at a time. Census generation computes one shared password hash and streams it into CSV; login still verifies every password. k6 ballots have fresh randomness and unique IDs even when every voter selects the same candidates. Preparation time is excluded from measured throughput. + +For a status-only workload, use `workload.mode: status` with k6, configure only status/journey latency goals, and leave `min_casts_per_second: 0`. This measures login and voter status without casting. + +### How workers divide the census + +Worker count controls independent processes or pods; `workload.concurrency` controls simultaneous voters within each worker. With 20 workers and concurrency 20, up to 400 voters run concurrently. These settings control offered load; the report measures the throughput the deployment actually achieves. + +Shard `s`, iteration `i` owns voter `prefix + (start + s × shard_size + i)`. Worker `n` of `N` processes shards `n, n + N, …`. The final shard may be shorter. Each iteration gets one distinct voter; neither workers nor iterations wrap around the census. + +Census CSV and encryption output are streamed, workers load one shard at a time, and reporting merges individual samples in SQLite on disk. `shard_size` bounds ballot input memory; total voter count determines disk usage and preparation time. Allow space for prepared ciphertexts and result samples on the coordinator and shared volume. + +Prepared configuration and ciphertexts are checked by digest before execution. A durable, exclusive attempt marker prevents a restarted worker from silently casting a shard twice. Use a new preparation and unused voter range for each subsequent run. + +## Target a remote deployment + +Authenticate `step-cli step config` against the synthetic tenant in your target deployment, then initialize its workload: + +```bash +step-cli load init \ + --target remote \ + --portal-url https://vote.example.org \ + --storage-origin https://ballots.example.org \ + --output remote.yaml +``` + +Use your deployment's portal and public S3/CDN addresses in place of the example domains. Keycloak and GraphQL addresses come from the authenticated CLI configuration. For multiple storage hosts, list each in `target.storage_origins`. These hosts must be reachable from every worker. Signed URLs are never edited. + +Remote initialization selects `target.upload_mode: direct`. The `local` mode is only for devcontainer upload routing. The deployment's configured portal URL must agree with `target.portal_url`, because it determines Keycloak redirects. Install any private CA in the coordinator and worker trust stores. + +```bash +read -rs -p 'Shared synthetic voter password: ' LOAD_PASSWORD +export LOAD_PASSWORD +step-cli load check remote.yaml +``` + +Choose **one** execution method below, finish its configuration, then prepare and run. For local processes, use the [prepare and run commands](#prepare-and-run) with `remote.yaml`. Start with a small cohort; increasing generator capacity does not establish server capacity. + +## Docker + +For a remote target, set the image and Docker network in `remote.yaml` before preparation: + +```yaml group="engine" tab="k6" +execution: + image: voting-load:k6 + network: bridge +``` + +```yaml group="engine" tab="Chromium" +execution: + image: voting-load:chromium + network: bridge +``` + +Build the selected worker image, then prepare and run: + +```bash group="engine" tab="k6" +step-cli load image \ + --engine k6 \ + --tag voting-load:k6 +step-cli load prepare remote.yaml \ + --engine k6 \ + --output runs/remote +step-cli load run runs/remote \ + --executor docker \ + --workers 4 +``` + +```bash group="engine" tab="Chromium" +step-cli load image \ + --engine chromium \ + --tag voting-load:chromium +step-cli load prepare remote.yaml \ + --engine chromium \ + --output runs/remote +step-cli load run runs/remote \ + --executor docker \ + --workers 4 +``` + +Local initialization selects the coordinator's network so a loopback-only portal remains reachable. In the repository devcontainer this is `container:`; on a Linux host it is `host`. For older configurations targeting a portal on the named devcontainer, use: + +```yaml +execution: + network: container:devcontainer +``` + +The images contain a standalone Rust worker and the selected engine; the coordinator mounts prepared inputs and passes only the synthetic password. The CLI translates devcontainer bind mounts to daemon-host paths automatically. For unusual remote-daemon layouts, set `execution.docker_mount_source` to the host path of the prepared `inputs` directory. + +## Kubernetes + +Use your current kubectl context, with permission to create Jobs, Pods, Secrets and PVCs in the configured namespace. Your cluster needs a ReadWriteMany storage class. Configure the image, namespace, storage class and volume size before preparation: + +```yaml group="engine" tab="k6" +execution: + executor: kubernetes + workers: 20 + image: registry.example.org/team/voting-load:k6 + namespace: load-testing + storage_class: shared-storage + storage_size: 20Gi + wait_timeout: 1h +``` + +```yaml group="engine" tab="Chromium" +execution: + executor: kubernetes + workers: 20 + image: registry.example.org/team/voting-load:chromium + namespace: load-testing + storage_class: shared-storage + storage_size: 20Gi + wait_timeout: 1h +``` + +Create the namespace if it does not already exist: + +```bash +kubectl create namespace load-testing +``` + +Build and publish the image using your Docker registry credentials: + +```bash group="engine" tab="k6" +step-cli load image \ + --engine k6 \ + --tag registry.example.org/team/voting-load:k6 \ + --push +step-cli load prepare remote.yaml \ + --engine k6 \ + --output runs/remote +step-cli load run runs/remote \ + --executor kubernetes \ + --workers 20 +``` + +```bash group="engine" tab="Chromium" +step-cli load image \ + --engine chromium \ + --tag registry.example.org/team/voting-load:chromium \ + --push +step-cli load prepare remote.yaml \ + --engine chromium \ + --output runs/remote +step-cli load run runs/remote \ + --executor kubernetes \ + --workers 20 +``` + +The CLI creates a Secret and PVC, transfers prepared inputs, starts the indexed Job, and collects worker results. It prints resource names and retains the Job, PVC and Secret for reconciliation. After collecting and reviewing results, use the resource name printed by the CLI: + +```bash +read -r -p 'Run resource name printed by the CLI: ' LOAD_RESOURCE +kubectl delete job,pod,pvc,secret "$LOAD_RESOURCE" \ + --namespace load-testing \ + --ignore-not-found +``` + +Use your configured namespace in place of `load-testing`. Failed or interrupted jobs are not automatically retried. Keep worker clocks synchronized; the combined measured interval uses timestamps from all workers. + +## Existing events and preparation + +To reuse an already provisioned event, set `preparation.existing_event` to a previous run's `inputs/config.json`, and choose an unused `workload.start` range. The CLI imports the new census and prepares fresh ballots; it does not republish that event. The existing event must still be open and eligible. + +Custom fixtures use `preparation.template`; explicit ballot selections use `preparation.choices`. Paths resolve relative to the workload YAML. Deployments that run S3 publication preparation separately can set `preparation.publication_preparer` to their application writer executable; its database and S3 environment must be configured on the coordinator. + +## Read and share results + +Open `report.html` from the run directory. It is a self-contained document suitable for sharing or printing to PDF: successful journeys, accepted casts per second, response distributions and configured goals. + +![Example voting-load report captured from an actual local run](/img/voting-load-report.png) + +### Local example: 100 voters per engine + +These full voting journeys ran sequentially on the same local devcontainer deployment on 8 September 2026. Each engine used four workers with two concurrent voters per worker and its own census range. All 200 unique API receipts matched persisted PostgreSQL ballots. The screenshot shows the Chromium run. + +| Engine | Successful voters | Workers × concurrency | Duration | Casts/s | Status p50 / p99 | Cast p50 / p99 | Journey p50 / p99 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| k6 | 100/100 | 4 × 2 | 1.84 s | 54.29 | 24.0 / 47.1 ms | 25.0 / 232.9 ms | 129.0 / 340.1 ms | +| Chromium | 100/100 | 4 × 2 | 62.80 s | 1.59 | 32.9 / 80.9 ms | 18.7 / 36.4 ms | 4837.5 / 6000.1 ms | + +Preparation is excluded. Chromium includes page rendering and browser encryption; k6 uses prepared encrypted ballots. These small local runs illustrate the report and verify the journey; they are not deployment capacity estimates. No latency or throughput goals were configured for these examples. + +### Interpret the measurements + +| Measure | Meaning | +| --- | --- | +| Successful journeys | Distinct voters that completed the configured journey | +| Accepted casts/s | Unique API receipts divided by the interval from the first journey start to the last completion | +| p50 / p99 | Global percentiles of individual samples across all workers | +| Duration | Measured journey interval, excluding census generation and encryption preparation | +| Goals | Explicit thresholds from the workload configuration | + +k6 includes authentication, voter status, publication downloads and cast acceptance. Chromium additionally includes rendering and browser encryption. Compare runs using the same engine and workload. Status-only reports show successful journeys per second because they cast no votes. + +Every planned journey must succeed; voting runs also require unique receipts. A missing worker, failed journey or missed threshold makes the run fail. Reports are still produced for partial runs. An API receipt confirms acceptance; it does not independently prove database persistence or successful tallying. + +### Regenerate, audit or capture a report + +```bash +step-cli load report runs/smoke \ + --open +step-cli load report runs/smoke \ + --screenshot report.png +``` + +Screenshot capture needs Playwright and Chromium configured in `runtime`; ordinary HTML reporting does not. Screenshot dimensions are in `reporting`. + +For an optional read-only receipt audit, use a DSN with `sslmode=require` for remote PostgreSQL; the native TLS connector validates the server certificate against system trust. Plain HTTP and non-TLS PostgreSQL are supported for isolated synthetic local deployments only. Remote CLI and Keycloak endpoints should use HTTPS. + +```bash +read -rs -p 'Read-only backend PostgreSQL DSN: ' LOAD_AUDIT_DSN +export LOAD_AUDIT_DSN +step-cli load report runs/smoke \ + --dsn-env LOAD_AUDIT_DSN +unset LOAD_AUDIT_DSN +``` + +## Investigate a failure + +The run contains `settings.yaml` (effective configuration), `setup/` (private provisioning logs), `inputs/` (publication and encrypted shards), and `inputs/results/` (worker logs, samples and attempt markers). Census CSV batches and import checkpoints live in `setup/census/`, including runs that reuse an existing event. + +`results.json` retains the request inventory for diagnostics. Enable `workload.trace_http` before preparation for sanitized per-fetch protocol logs. These details stay out of the summary report. Client request counts describe traffic to Keycloak, Hasura and object storage; they do not reveal every internal Hasura SQL query. Optional developer capture tools can collect browser HAR and database statement-log intervals, which may include background work. Keep raw logs and inputs private: they may contain voter credentials, signed URLs and ballots. + +If preparation fails, inspect `setup/setup.log` and `setup/setup-state.json` before creating another election. If execution fails, inspect the affected worker log and reconcile accepted receipts. Preserve the run and prepare a new range; deleting attempt markers risks duplicate casts. + +## Remove a synthetic election + +After retaining the report and reconciling failures, delete the event created by that run. Its ID is `election_event_id` in `inputs/config.json`, or in `setup/setup-state.json` if preparation stopped early. Authenticate the CLI against the same tenant first. + +```bash +read -r -p 'Synthetic election event ID from this run: ' LOAD_EVENT_ID +step-cli step delete-election-event \ + --election-event-id "$LOAD_EVENT_ID" +``` + +A run configured with `preparation.existing_event` does not own that event; retain it for its other users. Deleting an election does not remove the local report or Kubernetes resources. + +## Command and configuration reference + +Use the [load CLI reference](../02-cli/voting-load-reference.md) for every command, configuration field and default. For simulated calls, use the separate [telephone load-testing guide](../12-ivr/telephone-load-testing-guide.md). diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/_category_.yml b/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/_category_.yml deleted file mode 100644 index 1faa51195e3..00000000000 --- a/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/_category_.yml +++ /dev/null @@ -1,2 +0,0 @@ -label: 'Load Testing' -position: 2 \ No newline at end of file diff --git a/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/load_testing.md b/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/load_testing.md deleted file mode 100644 index f204a389c38..00000000000 --- a/docs/docusaurus/docs/07-developers/10-tutorials/02-load-testing/load_testing.md +++ /dev/null @@ -1,305 +0,0 @@ ---- -id: load_testing -title: Load Testing ---- - -## Introduction - -This tutorial will allow you to create an election with 1M voters, cast 1K votes -using a headless chrome web browser and then duplicate votes faster using the -step cli. - -## Requirements - -You need: -- Basic knowledge of command line terminal usage. -- [Kubectl installed][kubectl]. -- A [kubeconfig file][kubeconfig] that gives access to the cluster. We'll assume - it's in `~/.kube/prod1-euw1-kubeconfig.yml` throughout the tutorial. - -## Creating an election - -## Duplicating votes - -### 1. Access and Configuration - -First we will set the path to the kubeconfig file so that we can use it for all -our `kubectl` plugins: - -```bash -export KUBECONFIG=~/.kube/prod1-euw1-kubeconfig.yml -``` - -Let's review the loadtesting pod using the following command: - -```bash -kubectl get pods -n test-apps -l app.kubernetes.io/name=loadtesting -``` - -The output should looks something like: - -```bash -NAME READY STATUS RESTARTS AGE -loadtesting-86c5944494-j7gnq 1/1 Running 0 4d21h -``` - -Please note that we are filtering for pods in `test-apps` namespace. Change this -accordingly to the name of your environment. For example, the `ehu` environment -would require to use here the `ehu-apps`. - -We can connect to any of these loadtesting pods using the following kind of -command. Please change the pod name and the namespace name accordingly: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh --help -Usage: /entrypoint.sh [options] -Subcommands: - load-tool Run the load-tool tool - vote-cast Run vote casting load tests [--voting-url ] - shell Start an interactive shell - sleep Sleeps for an infinite amount of time - step-cli Run step-cli - -For vote-cast subcommand: - --voting-url Voting URL (falls back to $VOTING_URL or $LOADTESTING_VOTING_URL if not provided) - Other options are forwarded to /run_bg_voting.sh (e.g. --batches, --instances, --save-screenshots, --env chrome) -``` - -With this command, you can check what load testing actions and commands are available. - -### 2. Executing the `load-tool` script to duplicate votes - -We can check what `load-tool` options are available by running the entrypoint: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh load-tool --help -usage: load_tool.py [-h] [--working-directory WORKING_DIRECTORY] {generate-voters,duplicate-votes,generate-applications,generate-activity-logs} ... - -Load Testing Tool - -positional arguments: - {generate-voters,duplicate-votes,generate-applications,generate-activity-logs} - Action to perform - generate-voters Generate random voters CSV file - duplicate-votes Duplicate cast votes in the database - generate-applications - Generate applications in different states - generate-activity-logs - Generate activity logs - -options: - -h, --help show this help message and exit - --working-directory WORKING_DIRECTORY - Path to working directory (input/output directory) -``` - -At this stage we are assuming we have: -1. The election event created. -2. The Keys ceremony has been executed. -3. The election event has been published. -4. The eligible voters have been loaded and there's enough voters to add more - votes. -5. The voting period is open, so votes can be cast. -6. There's at least one vote cast. -7. The election allows revoting, because the votes are added randomly and - otherwise in some cases more than 1 vote might be added for a single voter. - -Given the above, we can just duplicate votes with a command like below, please -change the election event id accordingly: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - load-tool duplicate-votes \ - --num-votes 10 \ - --election-event-id 7d7f840a-4e75-4ba4-b431-633196da1a2c -``` - -The election event id can be found in the admin portal in the URL of the -election event. - -#### Environment variables required for duplicate votes - -The duplicate-votes action connects to two PostgreSQL databases (Keycloak and Hasura). Connection parameters are read from environment variables inside the loadtesting container. Ensure these are set in the Deployment (typically via Secrets/ConfigMaps) for the loadtesting pod: - -- Keycloak DB - - KEYCLOAK_DB__DBNAME - - KEYCLOAK_DB__USER - - KEYCLOAK_DB__PASSWORD - - KEYCLOAK_DB__HOST - - KEYCLOAK_DB__PORT - -- Hasura DB - - HASURA_DB__DBNAME - - HASURA_DB__USER - - HASURA_DB__PASSWORD - - HASURA_DB__HOST - - HASURA_DB__PORT - -You can quickly verify that these variables are present in the running pod: - -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- env | grep -E '^(KEYCLOAK_DB__|HASURA_DB__)' -``` - -Note: If any are missing or incorrect, update the loadtesting Deployment (or its referenced Secret/ConfigMap) and redeploy so the pod picks them up. - -#### duplicate-votes arguments - -The duplicate-votes subcommand accepts the following arguments: - -- --num-votes `` (required) - - Number of votes to insert by duplicating existing votes. -- --election-event-id `` (required) - - The Election Event ID the votes belong to. -- --election-id `` (optional) - - If omitted, the tool discovers an election_id with at least one existing vote in the event and uses that. -- --tenant-id `` (optional; default: 90505c8a-23a9-4cdf-a26b-4e19f6a097d5) - - Used to build the Keycloak realm name as `tenant-{tenant_id}-event-{election_event_id}` for querying eligible voters. - -Operational notes: -- The tool will: - 1) Find an existing vote for the given election event (and election if specified) to use as a base template. - 2) Determine the area_id and election_id (if not passed). - 3) Fetch up to --num-votes random eligible voter IDs from Keycloak for that area. - 4) Duplicate existing cast_vote rows, reassigning voter_id_string to the fetched users, and bulk-insert via COPY for speed. -- The election should allow revoting to avoid collisions, since random users may already have cast a vote. -- There must already be at least one cast vote in the target election/area to serve as the duplication template. - -Please find below a short video that shows how we: -1. Enter the Dashboard of the Election Event, which currently has 400K voters - and 12 votes cast today and 566 votes in total. -2. Copy the election event id from the Admin Portal URL. -3. Execute the `duplicate-votes` subcommand adding 10 votes. -4. Show in the Dashboard that 10 votes have been added, having now 22 votes - cast today and 576 in total. - - - -[kubectl]: https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/ -[kubeconfig]: https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/ - -## Cast votes using chromium headless -### 1. Executing the `vote-cast` command to perform vote loading tests - -The `vote-cast` subcommand drives a Nightwatch-based browser test inside the loadtesting pod to cast votes through the public voting UI. - -How it works (pipeline): -- `/entrypoint.sh vote-cast` forwards arguments to `/run_bg_voting.sh`. -- `/run_bg_voting.sh` orchestrates parallel Nightwatch runs using the base test at `/nightwatch/src/voting.js` by default. -- Nightwatch runs headlessly by default (env `default`); you can switch to non-headless with `--env chrome`. - -Key flags and environment variables: -- `--voting-url ` - - The voting login URL. If omitted, the script uses `$VOTING_URL` or `$LOADTESTING_VOTING_URL` if set in the pod. - - Always quote the URL. -- `--batches ` - - Total iterations each Nightwatch instance will perform. This maps 1:1 to `NUMBER_OF_ITERATIONS` consumed by `nightwatch/src/voting.js`. -- `--instances ` - - Parallelism. The orchestrator duplicates the base test into N files and runs them concurrently using Nightwatch workers. -- `--save-screenshots ` (default: `false`) - - When `true`, screenshots are saved during the flow. -- `--number-of-voters ` (default: `4096`) - - Used by the test to randomize test users. -- `--username-pattern ` (default: `user{n}`) -- `--password-pattern ` (default: `user{n}`) - - `{n}` is replaced by the randomized user index. -- `--env ` (default: `default`) - - Nightwatch environment. `default` runs Chrome headless; `chrome` is non-headless (not recommended in pods). -- `--disable-voter-tracking` - - Disables the anti-double-voting mechanism. When disabled, the same voter can be randomly selected multiple times across iterations. **Note**: If the election does not allow revoting, tests will fail when a voter is reused. By default, voter tracking is **enabled** to ensure each voter is used only once per test run. -- `--previous-voters-file ` - - Path to a `used_voters.txt` file from a previous test run. Voters listed in this file will be excluded from the current run. Useful for chaining multiple test runs or coordinating distributed load testing across multiple machines without voter overlap. -- Advanced: - - `--base-test ` (default: `nightwatch/src/voting.js`) - - Allows running another test file, e.g., `nightwatch/src/voting2.js`. - - `--keep-parallel-files` - - Keeps the generated duplicate test files and the `used_voters.txt` tracking file under `/nightwatch/src/_parallel_` for inspection. By default, these are cleaned up after the test completes. - - `--voter-min-index` (default: `1`) - - The ids for the voters will be selected between `voter-min-index` and `voter-min-index + number-of-voters - 1`. - - `--candidates-pattern ` (default: empty) - - Regular expression to filter candidates by name. Supports JavaScript regex format like `/^(?!.*text).*$/` to exclude candidates containing specific text. - -Outputs and logs: -- Aggregated Nightwatch log: `/logs/nightwatch_.log` (inside the container). -- Screenshots (when enabled): `/nightwatch/screenshots` in the container. -- Temporary per-run test copies: `/nightwatch/src/_parallel__PID` (removed by default unless `--keep-parallel-files`). - -Examples: -- Single instance, single iteration (sanity check): -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 1 \ - --instances 1 -``` - -- 8 instances in parallel, 200 iterations each: -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 200 \ - --instances 8 \ - --save-screenshots false -``` - -- Debugging with non-headless Chrome (use sparingly; pods may not support it): -```bash -kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh \ - vote-cast \ - --voting-url "https://voting-test.sequent.vote/tenant/90505c8a-23a9-4cdf-a26b-4e19f6a097d5/event/7d7f840a-4e75-4ba4-b431-633196da1a2c/login" \ - --batches 1 \ - --instances 1 \ - --env chrome \ - --save-screenshots true -``` - -Troubleshooting: -- If your run prints defaults (e.g., `INSTANCES: 4` or `ITERATIONS: 10`) despite passing flags, ensure the flags follow `vote-cast` and that the URL is quoted. -- If the site markup requires different selectors, consider providing an alternative base test via `--base-test nightwatch/src/voting2.js`. -- To keep the generated parallel files for inspection, add `--keep-parallel-files` and check `/nightwatch/src/_parallel_*`. - -## Managing an election event through the `step-cli` - -You can check what options are available to you by calling the `step-cli` CLI tool: - -```bash -$ kubectl exec -it deployment/loadtesting -n test-apps -- /entrypoint.sh step-cli step --help -Usage: step-cli step - -Commands: - config Create a config file - create-election-event Create a new election event - create-election Create a new election - create-contest Create a new contest - create-candidate Create a new candidate - create-area Create a new area - create-area-contest Create area contest - create-voter Create a new voter - export-cast-votes Export a cast vote - update-voter Edit a voter - update-election-event-status Update election event status - update-election-status Update election status - import-election Import Election Event - publish Publish election event ballot changes - refresh-token Refresh auth jwt - start-key-ceremony Start Key Ceremony - complete-key-ceremony Complete Key Ceremony - start-tally Start Tally Ceremony - update-tally Update tally status - confirm-key-tally Confirm trustee key for tally ceremony - render-template Render a handlebars-rs template with variables - generate-voters - duplicate-votes - create-applications - create-electoral-logs - hash-password Process a CSV file to hash passwords and generate salts - help Print this message or the help of the given subcommand(s) - -Options: - -h, --help Print help -``` diff --git a/docs/docusaurus/docs/07-developers/12-ivr/telephone-load-testing-guide.md b/docs/docusaurus/docs/07-developers/12-ivr/telephone-load-testing-guide.md new file mode 100644 index 00000000000..034db76d2ea --- /dev/null +++ b/docs/docusaurus/docs/07-developers/12-ivr/telephone-load-testing-guide.md @@ -0,0 +1,346 @@ +--- +id: telephone-load-testing-guide +title: Telephone Load Testing Guide +--- + + + +# Telephone Load Testing Guide + +Step-by-step instructions for running the telephone (IVR/DTMF) load test: +provisioning an election event with many voters, then driving many +simulated phone calls against it. The setup script provisions through `step-cli`; the runner starts independent `ivr-cli` processes against real Keycloak and Hasura services, without telephony infrastructure. + +All commands below assume a terminal opened **inside the dev container** +(VS Code Dev Containers or GitHub Codespaces) — that's where `cargo`, +`step-cli`'s dependencies (Hasura/Keycloak), and the `trustee1`/`trustee2` +containers are reachable. + +Every command below writes its output under +`packages/step-cli/scripts/telephone-load-test-output/` (gitignored) rather +than `/tmp`, so a run survives a container restart and stays easy to find +between steps. + +## Configuration + +`setup_telephone_load_test.py` and `run_telephone_load_test.py` take **no command-line arguments** — every setting +lives in `packages/step-cli/scripts/telephone-load-test-inputs/config/layers.yaml`, +under the `setup:` / `telephone_run:` sections respectively. Online browser and k6 workloads use the [voting load CLI](../05-voting-portal/voter-status-performance.md). +`config/` is gitignored (it holds real per-server credentials); copy the +tracked +[`layers.yaml.example`](https://github.com/sequentech/step/blob/main/packages/step-cli/scripts/telephone-load-test-inputs/layers.yaml.example) +template there first: + +```bash +mkdir -p packages/step-cli/scripts/telephone-load-test-inputs/config +cp packages/step-cli/scripts/telephone-load-test-inputs/layers.yaml.example \ + packages/step-cli/scripts/telephone-load-test-inputs/config/layers.yaml +``` + +Edit that copy before each run instead of passing flags. A field left as +`null` falls back to the environment variable named in the comment beside +it (already exported in this repo's devcontainer). + +## Prerequisites + +- The `keycloak`, `graphql-engine` (Hasura), `trustee1` and `trustee2` + containers must be running (`docker ps`; `docker start trustee1 trustee2` + if they aren't — the keys ceremony hangs without them). +- Python 3 with PyYAML (already available in the devenv shell). + +## 0. Build the CLIs + +Both scripts look for their binary on `PATH` first, falling back to the +default release build path — so a release build is enough, no need to keep +using `cargo run`: + +```bash +CARGO_TARGET_DIR=/workspaces/step/packages/step-cli/rust-local-target \ +cargo build \ + --manifest-path packages/step-cli/Cargo.toml \ + --release \ + --package step-cli +CARGO_TARGET_DIR=/workspaces/step/beyond/packages/rust-local-target \ +cargo build \ + --manifest-path beyond/packages/Cargo.toml \ + --release \ + --package ivr-cli +export PATH="/workspaces/step/packages/step-cli/rust-local-target/release:$PATH" +``` + +## 1. Stage 1 — provision the election event and voters + +`layers.yaml`'s `setup:` section already points at the tracked example +election event with `voting_channel: TELEPHONE` and 20 voters — run it as-is: + +```bash +python3 packages/step-cli/scripts/setup_telephone_load_test.py +``` + +This imports the tracked example election event, generates 20 voters (all +placed in the same area, with unique numeric username/PIN/date-of-birth +each), runs the keys ceremony, publishes, and opens `TELEPHONE` voting. +Outputs land in `setup.out_dir` (`telephone-load-test-output/run` by +default): a top-level `tenants.json` index, and one `tenant-/` +subdirectory per provisioned tenant holding that tenant's own `summary.json` +and voters CSV (with a single tenant, that's just one subdirectory). + +By default Stage 1 provisions `setup.tenant_id` itself. Set +`setup.new_tenants: N` to instead create `N` brand-new tenants and import +the *same* election event into each — Stage 2 then places calls across every tenant `tenants.json` lists. Leaving `tenant_id` +unset defaults `new_tenants` to `1`, so omitting it entirely provisions one +fresh tenant instead of reusing an existing one (creating a tenant needs +`setup.keycloak_admin_user`/`keycloak_admin_password` — a Keycloak +master-realm admin, distinct from `admin_portal_user` — to look up each new +tenant's freshly generated `api-key-client` secret). + +A brand-new tenant starts blank — no trustees, and Keycloak/roles config +from the generic default template rather than `tenant_id`'s own. For each +one, the script: exports `tenant_id`'s Keycloak/roles config +(`export-tenant-config`), downloads it and re-uploads it so the new tenant +owns its own copy (documents are tenant-owned records — a document can't be +imported into a tenant that doesn't own it), imports that copy +(`import-tenant-config`), then copies `tenant_id`'s registered trustees +(`list-trustees` / `create-trustee`) so the keys ceremony has trustees to +work with at all. None of this needs configuring — it's automatic whenever +`new_tenants > 0`. + +Set `setup.use_existing_tenants` to a list of already-existing tenant IDs to +*also* provision this election event into, alongside `tenant_id` itself / +any `new_tenants` brand-new tenants — typically tenants a previous run's +`new_tenants` created (copy them straight from that run's `tenants.json`). +Unlike `new_tenants`, these are never created or cloned — the script +authenticates into each directly and provisions the election event, so every +one must already have its own registered trustees and Keycloak/roles config +in place (true for any tenant `new_tenants` itself created previously). +Looking up each one's `api-key-client` secret needs +`keycloak_admin_user`/`keycloak_admin_password`, same as `new_tenants > 0`. + +The keys ceremony defaults to `setup.ceremony_policy: AUTOMATIC`: each +trustee's `braid` service still does its DKG round the same way, but nothing +needs to log in as `trustee1`/`trustee2` to confirm it — the ceremony's +status flips to done on its own once every trustee's public key is on the +board, matching the Admin Portal's "automatic ceremony" option. Set it to +`MANUAL` to instead drive `complete-key-ceremony` as each configured +trustee, as the CLI always did previously. + +Each run appends a random 5-character suffix to the election event's alias +(e.g. `TECUMSEH - DATAFIX Test - K3F9Q`) — the admin portal's election event +list renders alias, not name, so that's the field that needs the suffix to +actually be visible there. The full alias is printed at the end of the run +and recorded as `election_event_alias` in `summary.json`. + +Using your own election event JSON instead of the tracked example works the +same way — just point `setup.election_event_json` at it in `layers.yaml`. If +it has more than one area, set `setup.voter_area_name` to pick which one +voters are generated into (`null` defaults to the first area). + +Importing the election event also creates its own Keycloak realm +(`tenant--event-`, printed as `keycloak_realm` +in `summary.json`), seeded with its own `ivr-service`/`ivr-voting` clients — +these are **not** in the tenant's realm alongside `api-key-client`. Since +every Stage 1 run creates a brand-new election event (and therefore a new +realm), fetch `telephone_run.keycloak_ivr_service_client_secret`/ +`keycloak_ivr_voting_client_secret` from *this* realm after each run — +Keycloak admin console → the realm printed above → Clients → +`ivr-service`/`ivr-voting` → Credentials tab — rather than reusing a value +from a previous run's election event. + +### Match the IVR authentication and ballot + +Telephone credentials must be numeric and no longer than eight digits. The setup starts usernames at 100 (`setup.voter_username_start`); use a `RandomNumeric` password policy with at most eight digits. The realm's `/ivr-config` endpoint defines the actual login fields, which may be voter ID and PIN or [date of birth and PIN](dob-pin-direct-grant-authenticator.md). + +The setup opens the `TELEPHONE` voting channel explicitly. Opening only `ONLINE` does not make a voter eligible for a telephone call. All generated voters use `setup.voter_area_name` (the first area by default), because one DTMF ballot template must match the contests offered to every voter. + +## 2. Get a DTMF template + +`packages/step-cli/scripts/dtmf-template.example.txt` — the default for +`telephone_run.dtmf_template` — is already captured for the tracked example +election's first area and works as-is — skip to +[step 3](#3-stage-2--fan-out-the-simulated-calls). + +If you're testing a **different** election event JSON or +`setup.voter_area_name`, capture a new template: the ballot portion +(candidate numbering, confirm/submit keys) depends on that election's +contests. First get a `phone_config.json` and a running session store by +running Stage 2 once with any existing template (the calls themselves may +fail if the template doesn't match your election — that's fine, the side +effects are what you need): + +```bash +python3 packages/step-cli/scripts/run_telephone_load_test.py +``` + +Then drive one call by hand, noting every prompt and keystroke: + +```bash +PHONE_CONFIG_PATH=packages/step-cli/scripts/telephone-load-test-output/calls/phone_config.json \ +beyond/packages/rust-local-target/release/ivr-cli \ + --bundle dev \ + --system-number +111111111111 \ + --number +15550000000 \ + --show-internal-state +``` + +Log in with the first row of the voters CSV. Transcribe the full keystroke +sequence into a copy of `dtmf-template.example.txt` (and point +`telephone_run.dtmf_template` at your copy), replacing the identifier/PIN +lines with `{{VOTER_ID}}`/`{{PIN}}` or `{{DOB}}`/`{{PIN}}` — check which +fields this realm's IVR flow expects first: + +```bash +read -r -p 'Election realm from summary.json: ' IVR_REALM +read -rs -p 'ivr-service client secret: ' IVR_CLIENT_SECRET +TOKEN=$(curl \ + --fail \ + --silent \ + --show-error \ + --request POST \ + --data-urlencode grant_type=client_credentials \ + --data-urlencode client_id=ivr-service \ + --data-urlencode "client_secret=$IVR_CLIENT_SECRET" \ + "http://keycloak:8090/realms/$IVR_REALM/protocol/openid-connect/token" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') +curl \ + --fail \ + --silent \ + --show-error \ + --header "Authorization: Bearer $TOKEN" \ + "http://keycloak:8090/realms/$IVR_REALM/ivr-config" +unset TOKEN IVR_CLIENT_SECRET +``` + +(`IVR_REALM` is `summary.json`'s `keycloak_realm` — the election event's own +realm, not the tenant's; see the note in [step 1](#1-stage-1--provision-the-election-event-and-voters). +The client secret goes in `telephone_run.keycloak_ivr_service_client_secret` +in `layers.yaml` — or `.devcontainer/.env.development` for the local +devcontainer stack, which always targets the same seeded test election +event.) + +## 3. Stage 2 — fan out the simulated calls + +```bash +python3 packages/step-cli/scripts/run_telephone_load_test.py +``` + +This generates `phone_config.json`, renders one DTMF input file per voter +from the template, and fans out `telephone_run.concurrency` parallel +`ivr-cli` calls. A local `valkey` container is started automatically as the +session store if none is reachable (reused across runs; disable with +`telephone_run.start_valkey: false`). Results land in `results.csv` and +per-call logs under `telephone_run.out_dir` (`telephone-load-test-output/calls` +by default). + +## 4. Stage 3 — clean up: delete the election event(s) and tenant(s) + +`cleanup_telephone_load_test.py` automates this stage: it reads Stage 1's +`tenants.json` (every tenant it provisioned into — one, unless +`setup.new_tenants`/`setup.use_existing_tenants` was set) and each tenant's +`summary.json`, then for each `(tenant_id, election_event_id)` pair +re-authenticates `step-cli` against that tenant (a session is scoped to one +tenant at a time) and calls `delete-election-event`. Once every election +event is gone, it also deletes every non-bootstrap tenant (never the +bootstrap tenant itself — see below): + +```bash +python3 packages/step-cli/scripts/cleanup_telephone_load_test.py +``` + +Unlike the other load-test scripts, this one takes command-line flags — +they scope how destructive a run is, which is a per-invocation choice, not +something that belongs in `layers.yaml`: + +- `--events-only` — delete election events only; leave every tenant realm in + place, including ones `setup.new_tenants` created this run. +- `--new-tenants-only` — delete election events as usual, but only delete + tenants `tenants.json` marks `"source": "new"` — tenants Stage 1 reused via + `setup.use_existing_tenants` (`"source": "existing"`), and any entry with + no `source` at all (e.g. a hand-written `tenants.json`), are left in + place. This is the field `setup_telephone_load_test.py` writes per tenant + to record whether it created that tenant this run or just provisioned an + election event into an existing one — see the note on + `setup.use_existing_tenants` above. + +With neither flag: delete every election event and every non-bootstrap +tenant, as it's always done. + +It reads the same `setup:` section of `layers.yaml` Stage 1 used. For the +bootstrap tenant (`setup.tenant_id`) it reuses the already-known +`setup.keycloak_client_secret`; for every other tenant, it looks up that +tenant's own `api-key-client` secret via +`setup.keycloak_admin_user`/`keycloak_admin_password` first — the same +Keycloak master-realm admin lookup `setup_telephone_load_test.py` did, +whether that tenant was created fresh or reused via +`setup.use_existing_tenants`. + +`delete-election-event` calls the `delete_election_event` GraphQL mutation, +which queues an async task tearing down the election event's Postgres/Hasura +rows, its Keycloak realm, and its ImmuDB and document-store data — the +command blocks and polls until that task finishes (or fails/times out after +5 minutes), so a `Success!` means cleanup is actually done, not just queued. + +To delete an event by hand, [authenticate the CLI](../02-cli/01-cli_cli.md#authenticate-a-tenant-administrator) against its tenant, then run: + +```bash +read -r -p 'Synthetic election event ID from summary.json: ' ELECTION_EVENT_ID +step-cli step delete-election-event \ + --election-event-id "$ELECTION_EVENT_ID" +``` + +`delete-tenant` deletes a tenant outright — its Postgres/Hasura rows (trustees, +templates, election types, any tenant-level documents), its Keycloak realm, +and its remaining S3 documents — via the `delete_tenant` GraphQL mutation, +polled the same way as `delete-election-event`. It refuses to run while the +tenant still has any election events, and it's a super-admin-only action: the +caller must be authenticated as the bootstrap tenant (`setup.tenant_id`), not +as the tenant being deleted — matching how `create-tenant` itself is +authorized. + +The `tenant-delete` role only needs to exist in the bootstrap tenant's own +Keycloak realm (never in the tenants being deleted), and is already assigned +to the `admin`/`admin-light` roles there in the dev container's default +realm import. Deployed environments provisioned via `beyond`'s `client-setup` +chart don't seed this role yet (kept out deliberately, to avoid changing that +chart's realm export for every deployment), so before running cleanup against +such an environment, add the `tenant-delete` role to the bootstrap tenant's +realm by hand — Keycloak admin console → bootstrap tenant realm → Realm roles +→ create `tenant-delete`, then assign it to `$ADMIN_PORTAL_USER`'s role (or +the role it inherits it from) — otherwise `delete-tenant` fails with an +authorization error. After authenticating the CLI against the bootstrap tenant, delete a disposable tenant with: + +```bash +read -r -p 'Disposable tenant ID to delete: ' TENANT_ID +step-cli step delete-tenant \ + --tenant-id "$TENANT_ID" +``` + +The bootstrap tenant itself is never deleted, even once its election event is +gone — it's the persistent identity these scripts authenticate as, meant to +be reused across runs, not a disposable one Stage 1 created. + +## Notes + +- **Each voter casts exactly one vote.** Re-running Stage 2 against the + *same* `telephone_run.run_dir` re-uses the same (already-voted) voters — + every call logs in successfully but reports "voting is now complete" + without casting a ballot, since there's nothing left for that voter to + vote on. This is the system correctly rejecting a duplicate vote, not a + failure. To place a fresh batch of calls, re-run + [Stage 1](#1-stage-1--provision-the-election-event-and-voters) to + provision a new election event and voter set. +- **Re-running after a dev container restart:** the auto-started `valkey` + container is reused if it's already there (even if stopped), so you don't + need to remove it manually between runs. +- **IVR client secrets are per-election-event, not per-tenant.** Since Stage + 1 provisions a new election event realm every run, a + `keycloak_ivr_service_client_secret`/`keycloak_ivr_voting_client_secret` + that worked for a previous run's election event will not work for a new + one — re-fetch them from the new realm each time (see the note in + [step 1](#1-stage-1--provision-the-election-event-and-voters)). With more + than one tenant (`setup.new_tenants > 1`), the flat + `telephone_run.keycloak_ivr_service_client_secret`/ + `keycloak_ivr_voting_client_secret` can cover at most one of them — set + `telephone_run.tenant_ivr_secrets` (keyed by `tenant_id`) for the rest. diff --git a/docs/docusaurus/docusaurus.config.js b/docs/docusaurus/docusaurus.config.js index e62aa7fede2..5f1f9fb354c 100644 --- a/docs/docusaurus/docusaurus.config.js +++ b/docs/docusaurus/docusaurus.config.js @@ -34,6 +34,7 @@ const config = { ({ docs: { path: 'docs', + remarkPlugins: [require('./plugins/remark-code-tabs')], sidebarPath: require.resolve('./sidebars.js'), editUrl: 'https://github.com/sequentech/step/edit/main/docs/docusaurus', diff --git a/docs/docusaurus/package.json b/docs/docusaurus/package.json index 406f38249f6..36f3b011ce6 100644 --- a/docs/docusaurus/package.json +++ b/docs/docusaurus/package.json @@ -11,7 +11,8 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "prebuild": "node --test plugins/*.test.js" }, "dependencies": { "@docusaurus/core": "^3.8.1", diff --git a/docs/docusaurus/plugins/remark-code-tabs.js b/docs/docusaurus/plugins/remark-code-tabs.js new file mode 100644 index 00000000000..57e5fa975e3 --- /dev/null +++ b/docs/docusaurus/plugins/remark-code-tabs.js @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only + +const path = require("node:path"); + +/** Build an MDX element without parsing or interpolating executable code. */ +function element(name, attributes, children) { + return { + type: "mdxJsxFlowElement", + name, + attributes: Object.entries(attributes).map(([name, value]) => ({ + type: "mdxJsxAttribute", + name, + value, + })), + children, + }; +} + +/** + * Turn adjacent fences with group="…" tab="…" into synchronized code tabs. + * Groups are scoped to the source document; normal fences remain untouched. + * Other fence metadata (titles, line highlighting) stays on the code block. + */ +module.exports = function remarkCodeTabs() { + return (tree, file) => { + const document = path.relative( + path.resolve(__dirname, ".."), + file.path || "document", + ); + function metadata(node) { + if (node.type !== "code") return null; + const fields = {}; + const rest = (node.meta || "") + .replace(/(?:^|\s)(group|tab)="([^"]*)"/g, (_, key, value) => { + if (key in fields || !value.trim()) + file.fail(`Code tabs require a nonempty, unique ${key}.`, node); + fields[key] = value; + return ""; + }) + .trim(); + if (!Object.keys(fields).length) return null; + if (!fields.group || !fields.tab) + file.fail('Code tabs require both group="…" and tab="…".', node); + return { ...fields, rest }; + } + function transform(parent) { + if (!parent.children) return; + const output = []; + for (let index = 0; index < parent.children.length; ) { + const node = parent.children[index]; + const first = metadata(node); + if (!first) { + transform(node); + output.push(node); + index++; + continue; + } + const tabs = []; + const labels = new Set(); + while (index < parent.children.length) { + const code = parent.children[index]; + const info = metadata(code); + if (!info || info.group !== first.group) break; + if (labels.has(info.tab)) + file.fail(`Duplicate code tab "${info.tab}".`, code); + labels.add(info.tab); + tabs.push( + element("CodeTab", { value: info.tab, label: info.tab }, [ + { ...code, meta: info.rest || null }, + ]), + ); + index++; + } + // A lone variant needs no selector, just like a shared command. + output.push( + tabs.length === 1 + ? tabs[0].children[0] + : element( + "CodeTabs", + { + groupId: `code:${document}:${first.group}`, + className: "code-mode-tabs", + }, + tabs, + ), + ); + } + parent.children = output; + } + transform(tree); + }; +}; diff --git a/docs/docusaurus/plugins/remark-code-tabs.test.js b/docs/docusaurus/plugins/remark-code-tabs.test.js new file mode 100644 index 00000000000..8170076915b --- /dev/null +++ b/docs/docusaurus/plugins/remark-code-tabs.test.js @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const plugin = require("./remark-code-tabs"); + +const code = (meta, value = "echo hello") => ({ + type: "code", + lang: "bash", + meta, + value, +}); +function render(children, path = "/docs/guide.md") { + const tree = { type: "root", children }; + plugin()(tree, { + path, + fail(message) { + throw new Error(message); + }, + }); + return tree.children; +} +const variants = () => [ + code('group="engine" tab="k6" title="Run" {1}'), + code('group="engine" tab="Chromium"'), +]; +const attributes = (node) => + Object.fromEntries(node.attributes.map(({ name, value }) => [name, value])); + +test("preserves code, highlighting and titles while synchronizing separated examples", () => { + const [first, shared, second] = render([ + ...variants(), + code(null), + ...variants(), + ]); + assert.equal(first.name, "CodeTabs"); + assert.equal(attributes(first).groupId, attributes(second).groupId); + assert.deepEqual(shared, code(null)); + assert.equal(first.children[0].children[0].meta, 'title="Run" {1}'); + assert.equal(first.children[0].children[0].value, "echo hello"); +}); + +test("scopes unrelated documents and groups independently", () => { + assert.notEqual( + attributes(render(variants())[0]).groupId, + attributes(render(variants(), "/docs/other.md")[0]).groupId, + ); + const other = variants().map((node) => ({ + ...node, + meta: node.meta.replace("engine", "language"), + })); + assert.notEqual( + attributes(render(variants())[0]).groupId, + attributes(render(other)[0]).groupId, + ); +}); + +test("supports arbitrary labels and counts, nested blocks and single unwrapped variants", () => { + const items = ["Go", "Rust", "PHP 8"].map((label) => + code(`group="language" tab="${label}"`), + ); + assert.equal(render(items)[0].children.length, 3); + assert.equal( + render([{ type: "blockquote", children: items }])[0].children[0].name, + "CodeTabs", + ); + assert.equal(render([items[0]])[0].type, "code"); +}); + +test("rejects incomplete, duplicate and empty metadata at build time", () => { + for (const meta of [ + 'group="engine"', + 'tab="k6"', + 'group="" tab="k6"', + 'group="a" group="b" tab="k6"', + ]) { + assert.throws(() => render([code(meta)]), /Code tabs/); + } + assert.throws( + () => render([variants()[0], variants()[0]]), + /Duplicate code tab/, + ); +}); diff --git a/docs/docusaurus/src/css/custom.css b/docs/docusaurus/src/css/custom.css index f3a940eeb04..85577271060 100644 --- a/docs/docusaurus/src/css/custom.css +++ b/docs/docusaurus/src/css/custom.css @@ -164,3 +164,24 @@ body.home .navbar__brand .navbar__title { width: 190px; height: 190px; } + +/* Code variants share the theme's accessible tabs and light/dark palette. */ +.code-mode-tabs { + border-bottom: 1px solid var(--ifm-color-emphasis-300); + gap: 0.25rem; +} + +.code-mode-tabs .tabs__item { + padding: 0.6rem 1rem; + font-size: 0.9rem; + border-radius: var(--ifm-global-radius) var(--ifm-global-radius) 0 0; +} + +.code-mode-tabs .tabs__item--active { + background: var(--ifm-color-emphasis-100); +} + +.code-mode-tabs .tabs__item:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: -2px; +} diff --git a/docs/docusaurus/src/theme/MDXComponents/index.js b/docs/docusaurus/src/theme/MDXComponents/index.js new file mode 100644 index 00000000000..2ece2dd7a24 --- /dev/null +++ b/docs/docusaurus/src/theme/MDXComponents/index.js @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only + +import MDXComponents from "@theme-original/MDXComponents"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +// Reuse the theme's keyboard navigation, persisted selection and code rendering. +export default { ...MDXComponents, CodeTabs: Tabs, CodeTab: TabItem }; diff --git a/docs/docusaurus/static/img/voting-load-report.png b/docs/docusaurus/static/img/voting-load-report.png new file mode 100644 index 00000000000..895b637fb61 Binary files /dev/null and b/docs/docusaurus/static/img/voting-load-report.png differ diff --git a/docs/docusaurus/static/img/voting-load-report.png.license b/docs/docusaurus/static/img/voting-load-report.png.license new file mode 100644 index 00000000000..b5b8fdafc5d --- /dev/null +++ b/docs/docusaurus/static/img/voting-load-report.png.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Sequent Tech Inc +SPDX-License-Identifier: AGPL-3.0-only diff --git a/docs/permissions.md b/docs/permissions.md index 2a6f14393f7..f331585dca2 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -26,7 +26,7 @@ CRUD endpoints for: # Permissions -- tenant-create|read|write +- tenant-create|read|write|delete - election-event-create|read|write|delete|archive - keycloak-realm-attributes-read|write - election-create|read|write|delete diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index fd52df870f4..0e3a072b833 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -1,3 +1,7 @@ +type Query { + get_ballot_files_urls(election_event_id: String!): jsonb! +} + type Mutation { ApplicationChangeStatus( body: ApplicationChangeStatusBody! @@ -143,6 +147,10 @@ type Mutation { delete_election_event(election_event_id: String!): DeleteElectionEvent } +type Mutation { + delete_tenant(tenant_id: String!): DeleteTenant +} + type Mutation { delete_permission( tenant_id: String! @@ -1695,6 +1703,12 @@ type DeleteElectionEvent { error_msg: String } +type DeleteTenant { + id: String + task_execution: tasks_execution_type + error_msg: String +} + type ExportTasksExecutionOutput { error_msg: String document_id: String! diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 769271d10b9..4ed919df702 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -1,4 +1,17 @@ actions: + - name: get_ballot_files_urls + definition: + type: query + handler: http://{{HARVEST_DOMAIN}}/get-ballot-files-urls + forward_client_headers: true + request_transform: + body: + action: transform + template: "{{$body.input}}" + template_engine: Kriti + version: 2 + permissions: + - role: user - name: ApplicationChangeStatus definition: kind: synchronous @@ -306,6 +319,23 @@ actions: permissions: - role: election-event-delete - role: admin-user + - name: delete_tenant + definition: + kind: synchronous + handler: http://{{HARVEST_DOMAIN}}/delete-tenant + forward_client_headers: true + headers: + - name: X-Hasura-Admin-Secret + value_from_env: ACTIONS_ADMIN_SECRET + request_transform: + body: + action: transform + template: "{{$body.input}}" + template_engine: Kriti + version: 2 + permissions: + - role: admin-user + comment: delete_tenant - name: delete_permission definition: kind: synchronous diff --git a/hasura/metadata/databases/backend-db/tables/sequent_backend_ballot_style.yaml b/hasura/metadata/databases/backend-db/tables/sequent_backend_ballot_style.yaml index a9804d65135..df0340e95a5 100644 --- a/hasura/metadata/databases/backend-db/tables/sequent_backend_ballot_style.yaml +++ b/hasura/metadata/databases/backend-db/tables/sequent_backend_ballot_style.yaml @@ -1,6 +1,17 @@ table: name: ballot_style schema: sequent_backend +object_relationships: + - name: election + using: + manual_configuration: + column_mapping: + tenant_id: tenant_id + election_event_id: election_event_id + election_id: id + remote_table: + name: election + schema: sequent_backend insert_permissions: - comment: "" permission: @@ -170,6 +181,8 @@ select_permissions: - tenant_id filter: _and: + - election_event_id: + _eq: X-Hasura-Election-Event-Id - tenant_id: _eq: X-Hasura-Tenant-Id - area_id: diff --git a/hasura/metadata/databases/backend-db/tables/sequent_backend_cast_vote.yaml b/hasura/metadata/databases/backend-db/tables/sequent_backend_cast_vote.yaml index 572c085800a..a50a7a8b5a8 100644 --- a/hasura/metadata/databases/backend-db/tables/sequent_backend_cast_vote.yaml +++ b/hasura/metadata/databases/backend-db/tables/sequent_backend_cast_vote.yaml @@ -112,6 +112,8 @@ select_permissions: - voter_id_string filter: _and: + - election_event_id: + _eq: X-Hasura-Election-Event-Id - area_id: _eq: X-Hasura-Area-Id - tenant_id: diff --git a/hasura/metadata/databases/backend-db/tables/sequent_backend_election.yaml b/hasura/metadata/databases/backend-db/tables/sequent_backend_election.yaml index 49b5a4df4cf..3135465d4af 100644 --- a/hasura/metadata/databases/backend-db/tables/sequent_backend_election.yaml +++ b/hasura/metadata/databases/backend-db/tables/sequent_backend_election.yaml @@ -490,8 +490,13 @@ select_permissions: - tenant_id - voting_channels filter: - tenant_id: - _eq: X-Hasura-Tenant-Id + _and: + - tenant_id: + _eq: X-Hasura-Tenant-Id + - election_event_id: + _eq: X-Hasura-Election-Event-Id + - id: + _in: X-Hasura-Authorized-Election-Ids role: user update_permissions: - comment: "" diff --git a/hasura/metadata/databases/backend-db/tables/sequent_backend_election_event.yaml b/hasura/metadata/databases/backend-db/tables/sequent_backend_election_event.yaml index 3d1e3388cfa..519ad6ce897 100644 --- a/hasura/metadata/databases/backend-db/tables/sequent_backend_election_event.yaml +++ b/hasura/metadata/databases/backend-db/tables/sequent_backend_election_event.yaml @@ -413,8 +413,11 @@ select_permissions: - user_boards - voting_channels filter: - tenant_id: - _eq: X-Hasura-Tenant-Id + _and: + - tenant_id: + _eq: X-Hasura-Tenant-Id + - id: + _eq: X-Hasura-Election-Event-Id role: user update_permissions: - comment: "" diff --git a/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/down.sql b/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/down.sql new file mode 100644 index 00000000000..c5b7fefe779 --- /dev/null +++ b/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/down.sql @@ -0,0 +1,42 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +CREATE OR REPLACE FUNCTION check_revote_limit() +RETURNS TRIGGER AS $$ +DECLARE + allowed_revotes integer; +BEGIN + -- Serialize the count-and-insert decision for one voter and election. Without + -- this lock two concurrent inserts can both observe the same count. + PERFORM pg_advisory_xact_lock( + hashtextextended( + NEW.tenant_id::text || ':' || NEW.election_event_id::text || ':' || + NEW.election_id::text || ':' || NEW.voter_id_string, + 0 + ) + ); + + SELECT num_allowed_revotes INTO allowed_revotes + FROM "sequent_backend"."election" + WHERE id = NEW.election_id + AND tenant_id = NEW.tenant_id + AND election_event_id = NEW.election_event_id; + + allowed_revotes := COALESCE(allowed_revotes, 1); + + IF allowed_revotes = 0 THEN + RETURN NEW; + ELSIF ( + SELECT COUNT(*) + FROM "sequent_backend"."cast_vote" cv + WHERE cv.election_id = NEW.election_id + AND cv.voter_id_string = NEW.voter_id_string + AND cv.tenant_id = NEW.tenant_id + AND cv.election_event_id = NEW.election_event_id + AND cv.status IN ('valid', 'in-progress') + ) >= allowed_revotes THEN + RAISE EXCEPTION 'insert_failed_exceeds_allowed_revotes'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/up.sql b/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/up.sql new file mode 100644 index 00000000000..160b42fdf30 --- /dev/null +++ b/hasura/migrations/backend-db/1788765000000_serialize_cast_vote_area_checks/up.sql @@ -0,0 +1,54 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +CREATE OR REPLACE FUNCTION check_revote_limit() +RETURNS TRIGGER AS $$ +DECLARE + allowed_revotes integer; + previous_votes bigint; + voted_in_another_area boolean; +BEGIN + -- Serialize the count-and-insert decision for one voter and election. Without + -- this lock two concurrent inserts can both observe the same count. + PERFORM pg_advisory_xact_lock( + hashtextextended( + NEW.tenant_id::text || ':' || NEW.election_event_id::text || ':' || + NEW.election_id::text || ':' || NEW.voter_id_string, + 0 + ) + ); + + -- Cross-area exclusivity is an integrity rule, including unlimited revotes. + -- Check after acquiring the existing lock and before the unlimited shortcut. + SELECT count(*), coalesce(bool_or( + cv.area_id IS NOT NULL AND cv.area_id IS DISTINCT FROM NEW.area_id + ), false) + INTO previous_votes, voted_in_another_area + FROM sequent_backend.cast_vote cv + WHERE cv.tenant_id = NEW.tenant_id + AND cv.election_event_id = NEW.election_event_id + AND cv.election_id = NEW.election_id + AND cv.voter_id_string = NEW.voter_id_string + AND cv.status IN ('valid', 'in-progress'); + + -- Count and cross-area eligibility share one scan under the existing lock. + IF voted_in_another_area THEN + RAISE EXCEPTION 'check_votes_in_other_areas_failed'; + END IF; + + SELECT num_allowed_revotes INTO allowed_revotes + FROM "sequent_backend"."election" + WHERE id = NEW.election_id + AND tenant_id = NEW.tenant_id + AND election_event_id = NEW.election_event_id; + + allowed_revotes := COALESCE(allowed_revotes, 1); + + IF allowed_revotes = 0 THEN + RETURN NEW; + ELSIF previous_votes >= allowed_revotes THEN + RAISE EXCEPTION 'insert_failed_exceeds_allowed_revotes'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/down.sql b/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/down.sql new file mode 100644 index 00000000000..66fdb25740c --- /dev/null +++ b/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/down.sql @@ -0,0 +1,4 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +ALTER TABLE sequent_backend.cast_vote ALTER COLUMN content SET STORAGE EXTENDED; diff --git a/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/up.sql b/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/up.sql new file mode 100644 index 00000000000..b3f4c71d647 --- /dev/null +++ b/hasura/migrations/backend-db/1788765000001_cast_vote_external_storage/up.sql @@ -0,0 +1,5 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +-- Future encrypted ballots skip compression; existing rows are not rewritten. +ALTER TABLE sequent_backend.cast_vote ALTER COLUMN content SET STORAGE EXTERNAL; diff --git a/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/down.sql b/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/down.sql new file mode 100644 index 00000000000..4e5d11314ff --- /dev/null +++ b/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/down.sql @@ -0,0 +1,7 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +ALTER TABLE sequent_backend.scheduled_event + DROP CONSTRAINT IF EXISTS scheduled_event_voting_period_valid; +DROP INDEX IF EXISTS sequent_backend.scheduled_event_active_voting_task_idx; +DROP INDEX IF EXISTS sequent_backend.scheduled_event_active_scope_task_idx; diff --git a/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/up.sql b/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/up.sql new file mode 100644 index 00000000000..9792a179266 --- /dev/null +++ b/hasura/migrations/backend-db/1788765000002_validate_voting_schedules/up.sql @@ -0,0 +1,53 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +-- Create the schedule indexes within the migration transaction. +CREATE INDEX IF NOT EXISTS scheduled_event_active_scope_task_idx + ON sequent_backend.scheduled_event (tenant_id, election_event_id, task_id) + WHERE archived_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS scheduled_event_active_voting_task_idx + ON sequent_backend.scheduled_event (tenant_id, election_event_id, task_id) + WHERE archived_at IS NULL + AND task_id ~ '^tenant_[0-9a-f-]{36}_event_[0-9a-f-]{36}_election_[0-9a-f-]{36}_(START|END)_VOTING_PERIOD$'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_index + WHERE indexrelid IN ( + 'sequent_backend.scheduled_event_active_scope_task_idx'::regclass, + 'sequent_backend.scheduled_event_active_voting_task_idx'::regclass + ) AND NOT indisvalid + ) THEN + RAISE EXCEPTION 'scheduled_event index is invalid; remove the invalid index before retrying this migration'; + END IF; +END; +$$; + +-- Hasura and imports can write schedules directly. Validate just the reserved +-- voting tasks; execution bookkeeping requires no trigger or additional lock. +ALTER TABLE sequent_backend.scheduled_event +ADD CONSTRAINT scheduled_event_voting_period_valid CHECK ( + CASE WHEN archived_at IS NULL AND task_id ~ '^tenant_[0-9a-f-]{36}_event_[0-9a-f-]{36}_election_[0-9a-f-]{36}_(START|END)_VOTING_PERIOD$' THEN + COALESCE( + task_id IN ( + 'tenant_' || tenant_id::text || '_event_' || election_event_id::text + || '_election_' || (event_payload ->> 'election_id') || '_START_VOTING_PERIOD', + 'tenant_' || tenant_id::text || '_event_' || election_event_id::text + || '_election_' || (event_payload ->> 'election_id') || '_END_VOTING_PERIOD' + ) + AND event_payload = jsonb_build_object('election_id', + substring(task_id FROM '_election_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_')) + AND (cron_config IS NULL OR ( + jsonb_typeof(cron_config) = 'object' + AND COALESCE(jsonb_typeof(cron_config -> 'cron'), 'null') IN ('string', 'null') + AND COALESCE(jsonb_typeof(cron_config -> 'scheduled_date'), 'null') IN ('string', 'null') + AND CASE WHEN cron_config ->> 'scheduled_date' IS NULL THEN true ELSE + (cron_config ->> 'scheduled_date') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt ]([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)([.][0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$' + AND (cron_config ->> 'scheduled_date')::timestamptz IS NOT NULL + END + )), false + ) + ELSE true END +); diff --git a/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/down.sql b/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/down.sql new file mode 100644 index 00000000000..9c89fecf7f6 --- /dev/null +++ b/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/down.sql @@ -0,0 +1,5 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +-- On populated deployments, drop concurrently with the standalone script first. +DROP INDEX IF EXISTS sequent_backend.ballot_style_voter_reference_idx; diff --git a/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/up.sql b/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/up.sql new file mode 100644 index 00000000000..64c10ee4466 --- /dev/null +++ b/hasura/migrations/backend-db/1788808561206_ballot_style_voter_reference_index/up.sql @@ -0,0 +1,19 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +-- On populated deployments, prebuild with scripts/postgres/ballot_style_voter_reference_index.sql. +-- IF NOT EXISTS then lets transactional migration bookkeeping reuse that index. +CREATE INDEX IF NOT EXISTS ballot_style_voter_reference_idx + ON sequent_backend.ballot_style (tenant_id, election_event_id, area_id, election_id) + INCLUDE (id, ballot_publication_id) + WHERE deleted_at IS NULL; + +-- An interrupted concurrent build leaves an unusable index under the same name. +DO $$ +BEGIN + IF NOT (SELECT indisvalid FROM pg_index + WHERE indexrelid = 'sequent_backend.ballot_style_voter_reference_idx'::regclass) THEN + RAISE EXCEPTION 'ballot_style_voter_reference_idx is invalid; drop it concurrently and rebuild before retrying'; + END IF; +END; +$$; diff --git a/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/down.sql b/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/down.sql new file mode 100644 index 00000000000..52ca433fd53 --- /dev/null +++ b/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/down.sql @@ -0,0 +1,4 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +DROP INDEX IF EXISTS sequent_backend.ballot_style_publication_page_idx; diff --git a/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/up.sql b/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/up.sql new file mode 100644 index 00000000000..7a50a3ebffa --- /dev/null +++ b/hasura/migrations/backend-db/1788909000000_ballot_publication_style_index/up.sql @@ -0,0 +1,14 @@ +-- SPDX-FileCopyrightText: 2026 Sequent Tech Inc +-- SPDX-License-Identifier: AGPL-3.0-only + +-- Create the publication paging index within the migration transaction. +CREATE INDEX IF NOT EXISTS ballot_style_publication_page_idx + ON sequent_backend.ballot_style (tenant_id, election_event_id, ballot_publication_id, id); +DO $$ +BEGIN + IF NOT (SELECT indisvalid FROM pg_index + WHERE indexrelid = 'sequent_backend.ballot_style_publication_page_idx'::regclass) THEN + RAISE EXCEPTION 'ballot_style_publication_page_idx is invalid; remove the invalid index before retrying this migration'; + END IF; +END; +$$; diff --git a/packages/admin-portal/src/resources/Publish/EditPreview.test.ts b/packages/admin-portal/src/resources/Publish/EditPreview.test.ts new file mode 100644 index 00000000000..b13af49d551 --- /dev/null +++ b/packages/admin-portal/src/resources/Publish/EditPreview.test.ts @@ -0,0 +1,150 @@ +/** @jest-environment jsdom */ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only +import React from "react" +import {act, fireEvent, render, screen, waitFor} from "@testing-library/react" +import {EditPreview} from "./EditPreview" + +const mockPrepare = jest.fn() +const mockQuery = jest.fn() +const mockClient = {query: mockQuery} +const mockNotify = jest.fn() +const mockAddWidget = jest.fn(() => ({identifier: "widget"})) +const mockSetTask = jest.fn() +const mockFailWidget = jest.fn() +const mockT = (key: string) => key +let mockTask: any +jest.mock("@apollo/client", () => ({ + gql: (parts: TemplateStringsArray) => parts.join(""), + useApolloClient: () => mockClient, + useMutation: () => [mockPrepare], + useQuery: (_query: any, options: any) => ({ + data: options.skip + ? undefined + : { + sequent_backend_tasks_execution: mockTask ? [mockTask] : [], + }, + }), +})) +jest.mock("react-admin", () => ({ + useNotify: () => mockNotify, + SimpleForm: ({children, onSubmit}: any) => + require("react").createElement( + "form", + { + onSubmit: (event: any) => { + event.preventDefault() + onSubmit() + }, + }, + children + ), + Toolbar: ({children}: any) => require("react").createElement("div", null, children), + SaveButton: ({disabled, label}: any) => + require("react").createElement("button", {type: "submit", disabled}, label), + Button: ({disabled, label, onClick}: any) => + require("react").createElement("button", {type: "button", disabled, onClick}, label), + AutocompleteInput: ({choices, onChange}: any) => + require("react").createElement( + "select", + {"aria-label": "area", "onChange": (event: any) => onChange(event.target.value)}, + require("react").createElement("option", {value: ""}, "Choose area"), + ...choices.map((area: any) => + require("react").createElement("option", {key: area.id, value: area.id}, area.name) + ) + ), +})) +jest.mock("react-i18next", () => ({useTranslation: () => ({t: mockT})})) +jest.mock("@/providers/TenantContextProvider", () => ({ + TenantContext: require("react").createContext({tenantId: "tenant"}), +})) +jest.mock("@/providers/SettingsContextProvider", () => ({ + SettingsContext: require("react").createContext({ + globalSettings: {VOTING_PORTAL_URL: "https://voting.example", QUERY_POLL_INTERVAL_MS: 100}, + }), +})) +jest.mock("@/providers/WidgetsContextProvider", () => ({ + useWidgetStore: () => [mockAddWidget, mockSetTask, mockFailWidget], +})) +jest.mock("@sequentech/ui-core", () => ({ + ETaskExecutionStatus: {SUCCESS: "SUCCESS", FAILED: "FAILED"}, +})) +const props = {publicationId: "publication", electionEventId: "event"} +beforeEach(() => { + jest.clearAllMocks() + mockTask = undefined + mockQuery.mockImplementation(({query}: {query: string}) => + Promise.resolve({ + data: query.includes("PublicationPreviewAreas(") + ? { + sequent_backend_ballot_style: Array.from({length: 60}, (_, i) => ({ + area_id: `area-${i}`, + })), + } + : { + sequent_backend_area: Array.from({length: 60}, (_, i) => ({ + id: `area-${i}`, + name: `Area ${i}`, + })), + }, + }) + ) + mockPrepare.mockResolvedValue({ + data: { + prepare_ballot_publication_preview: { + document_id: "document", + task_execution: {id: "task"}, + }, + }, + }) + jest.spyOn(window, "open").mockImplementation(() => null) +}) +afterEach(() => jest.restoreAllMocks()) +async function startPreview() { + await screen.findByText("Area 59") + fireEvent.change(screen.getByLabelText("area"), {target: {value: "area-59"}}) + await act(async () => fireEvent.click(screen.getByText("publish.preview.action"))) +} +it("offers areas beyond the diff limit and opens only after task success", async () => { + const view = render(require("react").createElement(EditPreview, props)) + await startPreview() + expect(window.open).not.toHaveBeenCalled() + mockTask = {id: "task", execution_status: "SUCCESS"} + view.rerender(require("react").createElement(EditPreview, {...props, close: jest.fn()})) + await waitFor(() => + expect(window.open).toHaveBeenCalledWith( + "https://voting.example/preview/tenant/document/area-59/publication", + "_blank" + ) + ) + expect(mockQuery.mock.calls[0][0].variables.publicationId).toBe("publication") +}) +it("recovers from enqueue errors even when the response contains a document id", async () => { + mockPrepare.mockResolvedValueOnce({ + data: { + prepare_ballot_publication_preview: { + document_id: "document", + error_msg: "Queue unavailable", + task_execution: {id: "task"}, + }, + }, + }) + render(require("react").createElement(EditPreview, props)) + await startPreview() + expect(screen.queryByRole("progressbar")).toBeNull() + expect(mockFailWidget).toHaveBeenCalled() + expect(window.open).not.toHaveBeenCalled() + await act(async () => fireEvent.click(screen.getByText("publish.preview.action"))) + expect(mockPrepare).toHaveBeenCalledTimes(2) +}) +it("recovers from a failed worker without opening the unready document", async () => { + const view = render(require("react").createElement(EditPreview, props)) + await startPreview() + mockTask = {id: "task", execution_status: "FAILED"} + view.rerender(require("react").createElement(EditPreview, {...props, close: jest.fn()})) + await screen.findByText("publish.preview.action") + expect(screen.queryByRole("progressbar")).toBeNull() + expect(window.open).not.toHaveBeenCalled() + await act(async () => fireEvent.click(screen.getByText("publish.preview.action"))) + expect(mockPrepare).toHaveBeenCalledTimes(2) +}) diff --git a/packages/admin-portal/src/resources/Publish/EditPreview.tsx b/packages/admin-portal/src/resources/Publish/EditPreview.tsx index 4b533fd7d8e..f698dace35f 100644 --- a/packages/admin-portal/src/resources/Publish/EditPreview.tsx +++ b/packages/admin-portal/src/resources/Publish/EditPreview.tsx @@ -14,21 +14,15 @@ import { } from "react-admin" import {Preview, ContentCopy} from "@mui/icons-material" import {useTranslation} from "react-i18next" -import { - GetBallotPublicationChangesOutput, - GetDocumentByNameQuery, - PrepareBallotPublicationPreviewMutation, - Sequent_Backend_Support_Material_Select_Column, -} from "@/gql/graphql" +import {PrepareBallotPublicationPreviewMutation, GetTaskByIdQuery} from "@/gql/graphql" import {SettingsContext} from "@/providers/SettingsContextProvider" -import {useLazyQuery, useMutation, useQuery} from "@apollo/client" +import {gql, useApolloClient, useMutation, useQuery} from "@apollo/client" import {PREPARE_BALLOT_PUBLICATION_PREVIEW} from "@/queries/PrepareBallotPublicationPreview" -import {GET_AREAS} from "@/queries/GetAreas" +import {GET_TASK_BY_ID} from "@/queries/GetTaskById" +import {ETaskExecutionStatus} from "@sequentech/ui-core" import {TenantContext} from "@/providers/TenantContextProvider" -import {GET_DOCUMENT_BY_NAME} from "@/queries/GetDocumentByName" import {CircularProgress} from "@mui/material" import {useWidgetStore} from "@/providers/WidgetsContextProvider" -import {WidgetProps} from "@/components/Widget" import {ETasksExecution} from "@/types/tasksExecution" enum ActionType { @@ -39,16 +33,17 @@ interface EditPreviewProps { publicationId?: string | Identifier | null electionEventId: Identifier | undefined close?: () => void - ballotData: GetBallotPublicationChangesOutput | null } export const EditPreview: React.FC = (props) => { - const {publicationId: publicationId, close, electionEventId, ballotData} = props + const {publicationId, close, electionEventId} = props const {t} = useTranslation() const notify = useNotify() const {globalSettings} = useContext(SettingsContext) const [addWidget, setWidgetTaskId, updateWidgetFail] = useWidgetStore() - const [sourceAreas, setSourceAreas] = useState([]) + const [sourceAreas, setSourceAreas] = useState>([]) + const client = useApolloClient() + const [taskId, setTaskId] = useState(null) const [preparePreview] = useMutation( PREPARE_BALLOT_PUBLICATION_PREVIEW ) @@ -57,81 +52,123 @@ export const EditPreview: React.FC = (props) => { const [areaId, setAreaId] = useState(null) const [documentId, setDocumentId] = useState(null) const [action, setAction] = useState(null) - const [getDocumentByName] = useLazyQuery(GET_DOCUMENT_BY_NAME) - const {data: areas} = useQuery(GET_AREAS, { - variables: { - electionEventId, - }, + const {data: taskData} = useQuery(GET_TASK_BY_ID, { + variables: {task_id: taskId}, + skip: !taskId, + pollInterval: taskId ? globalSettings.QUERY_POLL_INTERVAL_MS : 0, }) - //Show only relevant areas in dropdown - const areaIds = useMemo(() => { - const areaIds = - ballotData?.current?.ballot_styles?.map((style: any) => ({ - id: style.area_id, - })) || [] - - return areaIds - }, [ballotData]) - + // Load identifiers, not the truncated and potentially very large EML diff. useEffect(() => { - if (areas) { - const filtered = areas.sequent_backend_area.filter((area: any) => - areaIds.some((areaId: any) => areaId.id === area.id) - ) - setSourceAreas(filtered) - } - }, [areas, areaIds]) - - // This useEffect handles file upload - useEffect(() => { - const preparePreviewData = async () => { - let currWidget: WidgetProps = addWidget( - ETasksExecution.PREPARE_PUBLICATION_PREVIEW, - undefined - ) - try { - let {data} = await preparePreview({ - variables: { - electionEventId: electionEventId, - ballotPublicationId: publicationId, - }, + let active = true + setSourceAreas([]) + const loadAreas = async () => { + const result: Array<{id: string; name: string}> = [] + for (let offset = 0; ; offset += 1000) { + const {data} = await client.query({ + query: gql` + query PublicationPreviewAreas( + $publicationId: uuid! + $eventId: uuid! + $offset: Int! + ) { + sequent_backend_ballot_style( + where: { + ballot_publication_id: {_eq: $publicationId} + election_event_id: {_eq: $eventId} + } + distinct_on: area_id + order_by: {area_id: asc} + limit: 1000 + offset: $offset + ) { + area_id + } + } + `, + variables: {publicationId, eventId: electionEventId, offset}, + fetchPolicy: "network-only", }) - if (!data?.prepare_ballot_publication_preview?.document_id) { - console.log(data?.prepare_ballot_publication_preview?.error_msg) - updateWidgetFail(currWidget.identifier) - notifyActionError() - return + if (!active) return + const ids = data.sequent_backend_ballot_style.map( + (style: {area_id: string}) => style.area_id + ) + if (ids.length) { + const {data: areas} = await client.query({ + query: gql` + query PublicationPreviewAreaNames($ids: [uuid!]!, $eventId: uuid!) { + sequent_backend_area( + where: {id: {_in: $ids}, election_event_id: {_eq: $eventId}} + ) { + id + name + } + } + `, + variables: {ids, eventId: electionEventId}, + fetchPolicy: "network-only", + }) + result.push(...areas.sequent_backend_area) } - - const task_id = data?.prepare_ballot_publication_preview?.task_execution?.id - task_id - ? setWidgetTaskId(currWidget.identifier, task_id, () => - onSuccessPreparePreview() - ) - : updateWidgetFail(currWidget.identifier) - return data?.prepare_ballot_publication_preview?.document_id - } catch (_error) { - setIsUploading(false) - currWidget && updateWidgetFail(currWidget.identifier) - notifyActionError() - return + if (ids.length < 1000) break } + if (active) setSourceAreas(result) + } + if (publicationId && electionEventId) { + loadAreas().catch(() => { + if (active) notify(t("publish.dialog.error_preview"), {type: "error"}) + }) + } + return () => { + active = false } + }, [client, publicationId, electionEventId, notify, t]) - const handleDocumentProcess = async () => { - const docId = await preparePreviewData() - setDocumentId(docId) + const task = taskData?.sequent_backend_tasks_execution?.[0] + useEffect(() => { + if (!taskId || task?.id !== taskId) return + if (task.execution_status === ETaskExecutionStatus.SUCCESS) { + setTaskId(null) + setIsUploading(false) + } else if (task.execution_status === ETaskExecutionStatus.FAILED) { + setTaskId(null) + setDocumentId(null) + setAction(null) + setIsUploading(false) + notify(t("publish.dialog.error_preview"), {type: "error"}) } + }, [taskId, task, notify, t]) - if (isUploading && areaId && undefined !== Sequent_Backend_Support_Material_Select_Column) { - handleDocumentProcess() + useEffect(() => { + let active = true + if (!isUploading || taskId || documentId) return + const widget = addWidget(ETasksExecution.PREPARE_PUBLICATION_PREVIEW, undefined) + preparePreview({variables: {electionEventId, ballotPublicationId: publicationId}}) + .then(({data}) => { + const output = data?.prepare_ballot_publication_preview + if (output?.error_msg || !output?.document_id || !output.task_execution?.id) { + throw new Error(output?.error_msg || "Preview task was not created") + } + setWidgetTaskId(widget.identifier, output.task_execution.id) + if (active) { + setDocumentId(output.document_id) + setTaskId(output.task_execution.id) + } + }) + .catch(() => { + updateWidgetFail(widget.identifier) + if (active) { + setDocumentId(null) + setAction(null) + setIsUploading(false) + notify(t("publish.dialog.error_preview"), {type: "error"}) + } + }) + return () => { + active = false } - }, [isUploading, areaId]) + }, [isUploading]) - const onSuccessPreparePreview = () => { - setIsUploading(false) // This will trigger and validate the condition in useEffect for action (open or copy) - } const onPreviewClick = async (res: any) => { if (!documentId) { setIsUploading(true) @@ -146,14 +183,6 @@ export const EditPreview: React.FC = (props) => { setAction(ActionType.Copy) } - const notifyActionError = () => { - if (action === ActionType.Copy) { - notify(t("publish.preview.copy_error"), {type: "error"}) - } else if (action === ActionType.Open) { - notify(t("publish.dialog.error_preview"), {type: "error"}) - } - } - // This useEffect handles logic for action (open or copy) useEffect(() => { const openPreview = (previewUrl: string) => { @@ -176,7 +205,7 @@ export const EditPreview: React.FC = (props) => { } } - if (documentId && !isUploading) { + if (documentId && !isUploading && !taskId) { const previewUrl = getPreviewUrl(documentId) if (previewUrl && action === ActionType.Copy) { copyPreviewLink(previewUrl) @@ -184,12 +213,12 @@ export const EditPreview: React.FC = (props) => { openPreview(previewUrl) } } - }, [documentId, action, isUploading]) + }, [documentId, action, isUploading, taskId]) // Create preview url from data const previewUrlTemplate = useMemo(() => { return `${globalSettings.VOTING_PORTAL_URL}/preview/${tenantId}` - }, [globalSettings.VOTING_PORTAL_URL, publicationId]) + }, [globalSettings.VOTING_PORTAL_URL, tenantId]) const getPreviewUrl = useCallback( (documentId: string | undefined | null) => { diff --git a/packages/admin-portal/src/resources/Publish/Publish.progress.test.ts b/packages/admin-portal/src/resources/Publish/Publish.progress.test.ts new file mode 100644 index 00000000000..9bea4357dfc --- /dev/null +++ b/packages/admin-portal/src/resources/Publish/Publish.progress.test.ts @@ -0,0 +1,128 @@ +/** @jest-environment jsdom */ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only + +import React from "react" +import {act, fireEvent, render, screen, within, waitFor} from "@testing-library/react" +import {Publish} from "./Publish" +import {EPublishType} from "./EPublishType" + +const mockGenerate = jest.fn() +const mockPublish = jest.fn() +const mockOtherMutation = jest.fn() +const mockNotify = jest.fn() +const mockRefetch = jest.fn() +const mockRefresh = jest.fn() +let mockRecord = {id: "event", status: {voting_status: "CLOSED"}, presentation: {}} +let mockPublication: {id: string; is_generated: boolean} | undefined +const mockT = (key: string) => key +let mockTask: {id: string; execution_status: string; logs: Array<{log_text: string}>} | undefined + +jest.mock("@apollo/client", () => ({ + gql: (parts: TemplateStringsArray) => parts.join(""), + useMutation: (query: string) => [ + query.includes("mutation GenerateBallotPublication") + ? mockGenerate + : query.includes("mutation PublishBallot") + ? mockPublish + : mockOtherMutation, + {}, + ], + useQuery: (_query: string, options: {skip?: boolean}) => ({ + data: options.skip + ? undefined + : {sequent_backend_tasks_execution: mockTask ? [mockTask] : []}, + }), +})) +jest.mock("react-admin", () => ({ + useNotify: () => mockNotify, + useRefresh: () => mockRefresh, + useRecordContext: () => mockRecord, + useGetOne: () => ({refetch: mockRefetch, data: mockPublication}), + Button: ({label, children, ...props}: any) => + require("react").createElement("button", props, label, children), +})) +jest.mock("react-i18next", () => ({useTranslation: () => ({t: mockT})})) +jest.mock("@/providers/TenantContextProvider", () => ({useTenantStore: () => ["tenant"]})) +jest.mock("@/providers/AuthContextProvider", () => ({ + AuthContext: require("react").createContext({ + isAuthorized: () => true, + isGoldUser: () => false, + }), +})) +jest.mock("@/providers/SettingsContextProvider", () => ({ + SettingsContext: require("react").createContext({ + globalSettings: {QUERY_POLL_INTERVAL_MS: 100}, + }), +})) +jest.mock("@/lib/helpers", () => ({convertToNumber: () => undefined})) +jest.mock("@sequentech/ui-core", () => ({ + ETaskExecutionStatus: {SUCCESS: "SUCCESS", FAILED: "FAILED"}, + EVotingStatus: {NOT_STARTED: "NOT_STARTED"}, +})) +jest.mock("./PublishList", () => ({ + PublishList: ({onGenerate}: {onGenerate: () => void}) => + require("react").createElement("button", {onClick: onGenerate}, "Publish changes"), +})) +jest.mock("./PublishActions", () => ({PublishActions: () => null})) +jest.mock("./PublishExport", () => ({__esModule: true, default: () => null})) +jest.mock("./usePublishPermissions", () => ({ + usePublishPermissions: () => ({canWritePublish: true, showPublishButtonBack: true}), +})) +jest.mock("@sequentech/ui-essentials", () => ({Dialog: () => null})) +jest.mock("./EditPreview", () => ({EditPreview: () => null})) +jest.mock("@/components/FormDialog", () => ({__esModule: true, default: () => null})) + +const props = {electionEventId: "event", type: EPublishType.Event} + +beforeEach(() => { + jest.clearAllMocks() + sessionStorage.clear() + mockTask = undefined + mockPublication = undefined + mockGenerate.mockResolvedValue({ + data: { + generate_ballot_publication: { + ballot_publication_id: "publication", + task_execution: {id: "task"}, + }, + }, + }) + mockOtherMutation.mockResolvedValue({ + data: { + get_ballot_publication_changes: { + previous: {title: "before"}, + current: {title: "after"}, + }, + }, + }) +}) + +it.each(["CLOSED", "OPEN", "PAUSED", "NOT_STARTED"])( + "shows loading then enables publishing independently of voting status %s", + async (voting_status) => { + mockRecord = {id: "event", status: {voting_status}, presentation: {}} + const view = render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + expect(await screen.findByRole("progressbar")).toBeTruthy() + expect( + (screen.getByRole("button", {name: "publish.action.publish"}) as HTMLButtonElement) + .disabled + ).toBe(true) + // A refreshed event record must not reset the publication's progress. + mockRecord = {...mockRecord} + view.rerender(React.createElement(Publish, {...props, electionId: "refresh"})) + expect(screen.getByRole("progressbar")).toBeTruthy() + mockTask = {id: "task", execution_status: "SUCCESS", logs: []} + mockPublication = {id: "publication", is_generated: true} + view.rerender(React.createElement(Publish, {...props, electionId: "complete"})) + await waitFor(() => + expect( + (screen.getByRole("button", {name: "publish.action.publish"}) as HTMLButtonElement) + .disabled + ).toBe(false) + ) + await waitFor(() => expect(screen.queryByRole("progressbar")).toBeNull()) + expect(mockPublish).not.toHaveBeenCalled() + } +) diff --git a/packages/admin-portal/src/resources/Publish/Publish.test.ts b/packages/admin-portal/src/resources/Publish/Publish.test.ts new file mode 100644 index 00000000000..71002826670 --- /dev/null +++ b/packages/admin-portal/src/resources/Publish/Publish.test.ts @@ -0,0 +1,223 @@ +/** @jest-environment jsdom */ +// SPDX-FileCopyrightText: 2026 Sequent Tech Inc +// SPDX-License-Identifier: AGPL-3.0-only + +import React from "react" +import {act, fireEvent, render, screen, within} from "@testing-library/react" +import {Publish} from "./Publish" +import {EPublishType} from "./EPublishType" + +const mockGenerate = jest.fn() +const mockPublish = jest.fn() +const mockOtherMutation = jest.fn() +const mockNotify = jest.fn() +const mockRefetch = jest.fn() +const mockRefresh = jest.fn() +const mockRecord = {id: "event", status: {}, presentation: {}} +const mockT = (key: string) => key +let mockPublication: {id: string; is_generated: boolean} | undefined +let mockTask: {id: string; execution_status: string; logs: Array<{log_text: string}>} | undefined + +jest.mock("@apollo/client", () => ({ + gql: (parts: TemplateStringsArray) => parts.join(""), + useMutation: (query: string) => [ + query.includes("mutation GenerateBallotPublication") + ? mockGenerate + : query.includes("mutation PublishBallot") + ? mockPublish + : mockOtherMutation, + {}, + ], + useQuery: (_query: string, options: {skip?: boolean}) => ({ + data: options.skip + ? undefined + : {sequent_backend_tasks_execution: mockTask ? [mockTask] : []}, + }), +})) +jest.mock("react-admin", () => ({ + useNotify: () => mockNotify, + useRefresh: () => mockRefresh, + useRecordContext: () => mockRecord, + useGetOne: () => ({refetch: mockRefetch, data: mockPublication}), +})) +jest.mock("react-i18next", () => ({useTranslation: () => ({t: mockT})})) +jest.mock("@/providers/TenantContextProvider", () => ({useTenantStore: () => ["tenant"]})) +jest.mock("@/providers/AuthContextProvider", () => ({ + AuthContext: require("react").createContext({ + isAuthorized: () => true, + isGoldUser: () => false, + }), +})) +jest.mock("@/providers/SettingsContextProvider", () => ({ + SettingsContext: require("react").createContext({ + globalSettings: {QUERY_POLL_INTERVAL_MS: 100}, + }), +})) +jest.mock("@/lib/helpers", () => ({convertToNumber: () => undefined})) +jest.mock("@sequentech/ui-core", () => ({ + ETaskExecutionStatus: {SUCCESS: "SUCCESS", FAILED: "FAILED"}, + EVotingStatus: {NOT_STARTED: "NOT_STARTED"}, +})) +jest.mock("./PublishList", () => ({ + PublishList: ({onGenerate}: {onGenerate: () => void}) => + require("react").createElement("button", {onClick: onGenerate}, "Publish changes"), +})) +jest.mock("./PublishGenerate", () => ({ + PublishGenerate: ({onPublish, onGenerate, onBack, status, data}: any) => + require("react").createElement( + "div", + null, + require("react").createElement("button", {onClick: onPublish}, "Publish ballot"), + require("react").createElement("button", {onClick: onGenerate}, "Retry generation"), + require("react").createElement("button", {onClick: onBack}, "Back"), + require("react").createElement( + "span", + {"data-testid": "detail-state"}, + `${status}/${data?.current?.ballot_publication_id ?? "empty"}` + ) + ), +})) +jest.mock("./EditPreview", () => ({EditPreview: () => null})) +jest.mock("@/components/FormDialog", () => ({__esModule: true, default: () => null})) + +const props = {electionEventId: "event", type: EPublishType.Event} + +beforeEach(() => { + jest.clearAllMocks() + sessionStorage.clear() + mockTask = undefined + mockPublication = undefined + mockOtherMutation.mockReset() + mockGenerate.mockResolvedValue({ + data: { + generate_ballot_publication: { + ballot_publication_id: "publication", + task_execution: {id: "task"}, + }, + }, + }) +}) + +it("keeps an asynchronous generation failure visible on the publication details", async () => { + const view = render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + await screen.findByText("Publish ballot") + mockTask = { + id: "task", + execution_status: "FAILED", + logs: [{log_text: "Error: Inconsistent event presentation within publication"}], + } + view.rerender(React.createElement(Publish, {...props, electionId: "election"})) + await screen.findByText("Publish ballot") + expect(screen.queryByText("Publish changes")).toBeNull() + const alert = await screen.findByRole("alert") + expect(within(alert).getByText(mockTask.logs[0].log_text)).toBeTruthy() + view.rerender(React.createElement(Publish, {...props, electionId: "election2"})) + expect(screen.getByRole("alert")).toBeTruthy() + fireEvent.click(within(screen.getByRole("alert")).getByRole("button", {name: "Close"})) + expect(screen.queryByRole("alert")).toBeNull() +}) + +it("shows a synchronous generation error persistently and clears it on retry", async () => { + mockGenerate.mockRejectedValueOnce(new Error("Unable to queue generation\nPlease retry")) + render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + const alert = await screen.findByRole("alert") + expect(alert.textContent).toContain("Unable to queue generation\nPlease retry") + await act(async () => fireEvent.click(screen.getByText("Retry generation"))) + await screen.findByText("Publish ballot") + expect(screen.queryByRole("alert")).toBeNull() + expect(mockGenerate).toHaveBeenCalledTimes(2) +}) + +it("does not apply a previous task's failure to a new generation", async () => { + mockTask = {id: "older-task", execution_status: "FAILED", logs: [{log_text: "Old error"}]} + render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + expect(mockGenerate).toHaveBeenCalledTimes(1) + expect(screen.queryByRole("alert")).toBeNull() + expect(screen.getByText("Publish ballot")).toBeTruthy() +}) + +it("keeps synchronous publication failures in the same persistent panel", async () => { + mockPublish.mockRejectedValueOnce(new Error("Publication validation failed")) + render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + await act(async () => fireEvent.click(screen.getByText("Publish ballot"))) + const alert = await screen.findByRole("alert") + expect(within(alert).getByText("Publication validation failed")).toBeTruthy() +}) + +it("does not return to details when a generation finishes after Back", async () => { + let resolve: (value: any) => void = () => {} + mockGenerate.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + render(React.createElement(Publish, props)) + fireEvent.click(screen.getByText("Publish changes")) + fireEvent.click(screen.getByText("Back")) + await act(async () => + resolve({ + data: { + generate_ballot_publication: { + ballot_publication_id: "old", + task_execution: {id: "old-task"}, + }, + }, + }) + ) + expect(screen.getByText("Publish changes")).toBeTruthy() + expect(screen.queryByText("Publish ballot")).toBeNull() +}) + +it("ignores a diff response from before regeneration", async () => { + let resolve: (value: any) => void = () => {} + mockOtherMutation.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }) + ) + mockPublication = {id: "publication", is_generated: true} + render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + mockGenerate.mockResolvedValueOnce({ + data: { + generate_ballot_publication: { + ballot_publication_id: "new-publication", + task_execution: {id: "new-task"}, + }, + }, + }) + await act(async () => fireEvent.click(screen.getByText("Retry generation"))) + await act(async () => + resolve({ + data: { + get_ballot_publication_changes: { + current: {ballot_publication_id: "publication", ballot_styles: []}, + }, + }, + }) + ) + expect(screen.getByTestId("detail-state").textContent).toContain("/empty") +}) + +it("keeps a generated publication ready to retry after publishing fails", async () => { + mockPublication = {id: "publication", is_generated: true} + mockOtherMutation.mockResolvedValue({ + data: { + get_ballot_publication_changes: { + current: {ballot_publication_id: "publication", ballot_styles: []}, + }, + }, + }) + mockPublish.mockRejectedValueOnce(new Error("Publication validation failed")) + render(React.createElement(Publish, props)) + await act(async () => fireEvent.click(screen.getByText("Publish changes"))) + const readyState = screen.getByTestId("detail-state").textContent + await act(async () => fireEvent.click(screen.getByText("Publish ballot"))) + expect(screen.getByTestId("detail-state").textContent).toEqual(readyState) +}) diff --git a/packages/admin-portal/src/resources/Publish/Publish.tsx b/packages/admin-portal/src/resources/Publish/Publish.tsx index 756c79ae558..5482e733f65 100644 --- a/packages/admin-portal/src/resources/Publish/Publish.tsx +++ b/packages/admin-portal/src/resources/Publish/Publish.tsx @@ -2,8 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only -import React, {ComponentType, useCallback, useContext, useEffect, useState} from "react" -import {Box} from "@mui/material" +import React, {ComponentType, useCallback, useContext, useEffect, useRef, useState} from "react" +import {Alert, AlertTitle, Box} from "@mui/material" import {useMutation, useQuery} from "@apollo/client" import {useTranslation} from "react-i18next" import {useGetOne, useNotify, useRecordContext, Identifier, useRefresh} from "react-admin" @@ -80,7 +80,14 @@ const PublishMemo: React.MemoExoticComponent> = React.me const [viewMode, setViewMode] = useState(ViewMode.List) const [changingStatus, setChangingStatus] = useState(false) const [publishStatus, setPublishStatus] = useState(PublishStatus.Void) + const [publicationStatus, setPublicationStatus] = useState( + PublishStatus.Void + ) const [open, setOpen] = React.useState(false) + const [previewPublicationId, setPreviewPublicationId] = useState< + string | Identifier | null + >(null) + const requestEpoch = useRef(0) const [ballotPublicationId, setBallotPublicationId] = useState( null ) @@ -135,6 +142,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me }) const onPublish = async () => { + const epoch = requestEpoch.current try { if (!ballotPublicationId) { await onGenerate() @@ -142,7 +150,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me } setPublishError(null) - handleSetPublishStatus(PublishStatus.PublishedLoading) + setPublicationStatus(PublishStatus.PublishedLoading) const {data} = await publishBallot({ variables: { @@ -151,6 +159,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me }, }) + if (epoch !== requestEpoch.current) return if (data?.publish_ballot?.ballot_publication_id) { setBallotPublicationId(data?.publish_ballot?.ballot_publication_id) } @@ -163,8 +172,9 @@ const PublishMemo: React.MemoExoticComponent> = React.me }) setPublishError(null) - handleSetPublishStatus(PublishStatus.Void) + setPublicationStatus(PublishStatus.Void) } catch (e) { + if (epoch !== requestEpoch.current) return setPublishError( getGraphQLActionErrorMessage(e) ?? t("publish.dialog.error_publish") ) @@ -172,7 +182,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me type: "error", }) refresh() - handleSetPublishStatus(PublishStatus.Void) + setPublicationStatus(generateData ? PublishStatus.Generated : PublishStatus.Void) } } @@ -224,10 +234,14 @@ const PublishMemo: React.MemoExoticComponent> = React.me } const onGenerate = async () => { + const epoch = ++requestEpoch.current try { + setBallotPublicationId(null) setPublishError(null) + setGenerateData(null) + setTaskId(null) setViewMode(ViewMode.Edit) - handleSetPublishStatus(PublishStatus.GeneratedLoading) + setPublicationStatus(PublishStatus.GeneratedLoading) const {data} = await generateBallotPublication({ variables: { @@ -235,7 +249,8 @@ const PublishMemo: React.MemoExoticComponent> = React.me electionEventId, }, }) - handleSetPublishStatus(PublishStatus.GeneratedLoading) + if (epoch !== requestEpoch.current) return + setPublicationStatus(PublishStatus.GeneratedLoading) if (data?.generate_ballot_publication?.ballot_publication_id) { setBallotPublicationId(data?.generate_ballot_publication?.ballot_publication_id) @@ -244,11 +259,14 @@ const PublishMemo: React.MemoExoticComponent> = React.me throw "Publication Generation Error" } } catch (e) { + if (epoch !== requestEpoch.current) return + setPublishError(getGraphQLActionErrorMessage(e) ?? t("publish.dialog.error")) + setBallotPublicationId(null) notify(t("publish.dialog.error"), { type: "error", }) - handleSetPublishStatus(PublishStatus.Void) - setViewMode(ViewMode.List) + setPublicationStatus(PublishStatus.Void) + setViewMode(ViewMode.Edit) } } @@ -327,6 +345,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me } const fetchAllPublishChanges = useCallback(async () => { + const epoch = requestEpoch.current try { const { data: {get_ballot_publication_changes: data}, @@ -336,11 +355,13 @@ const PublishMemo: React.MemoExoticComponent> = React.me ballotPublicationId, }, })) as any + if (epoch !== requestEpoch.current) return setGenerateData(data) } catch (error) { + if (epoch !== requestEpoch.current) return setViewMode(ViewMode.List) setGenerateData(null) - handleSetPublishStatus(PublishStatus.Void) + setPublicationStatus(PublishStatus.Void) notify(t("publish.dialog.error"), { type: "error", }) @@ -348,6 +369,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me }, [ballotPublicationId, electionEventId, getBallotPublicationChanges]) const getPublishChanges = useCallback(async () => { + const epoch = requestEpoch.current try { const { data: {get_ballot_publication_changes: data}, @@ -358,11 +380,13 @@ const PublishMemo: React.MemoExoticComponent> = React.me limit: MAX_DIFF_LINES / 10, }, })) as any + if (epoch !== requestEpoch.current) return setGenerateData(data) } catch (error) { + if (epoch !== requestEpoch.current) return setViewMode(ViewMode.List) setGenerateData(null) - handleSetPublishStatus(PublishStatus.Void) + setPublicationStatus(PublishStatus.Void) notify(t("publish.dialog.error"), { type: "error", }) @@ -379,7 +403,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me ) const onPreview = (id: string | Identifier) => { - setBallotPublicationId(id) + setPreviewPublicationId(id) setOpen(true) } @@ -416,18 +440,26 @@ const PublishMemo: React.MemoExoticComponent> = React.me useEffect(() => { if (showList) { + requestEpoch.current++ + setTaskId(null) setViewMode(ViewMode.List) setBallotPublicationId(null) } }, [showList]) useEffect(() => { - if (electionEventId && ballotPublicationId && ballotPublication?.is_generated) { + if ( + electionEventId && + ballotPublicationId && + ballotPublication?.id === ballotPublicationId && + ballotPublication?.is_generated + ) { getPublishChanges() } }, [ ballotPublicationId, ballotPublication?.is_generated, + ballotPublication?.id, electionEventId, getPublishChanges, ]) @@ -436,11 +468,10 @@ const PublishMemo: React.MemoExoticComponent> = React.me // sequent_backend.tasks_execution) instead of polling // ballot_publication.is_generated forever - on SUCCESS it refetches // the publication once (which then flows into the effect above), on - // FAILED it surfaces the task's last log line (e.g. exceeding the - // ballot size limit) and resets back to the list. + // FAILED it keeps the task's error visible on the publication details. const generationTask = generationTaskData?.sequent_backend_tasks_execution?.[0] useEffect(() => { - if (!taskId || !generationTask) { + if (!taskId || !generationTask || generationTask.id !== taskId) { return } @@ -452,14 +483,17 @@ const PublishMemo: React.MemoExoticComponent> = React.me const message = logs[logs.length - 1]?.log_text setTaskId(null) + setPublishError( + getGraphQLActionErrorMessage({message}) ?? t("publish.dialog.error") + ) + setGenerateData(null) notify(t("publish.dialog.error_capacity", {message}), { type: "error", }) - handleSetPublishStatus(PublishStatus.Void) - setViewMode(ViewMode.List) - setBallotPublicationId(null) + setPublicationStatus(PublishStatus.Void) + setViewMode(ViewMode.Edit) } - }, [taskId, generationTask, notify, t, handleSetPublishStatus, refetch]) + }, [taskId, generationTask, notify, t, refetch]) useEffect(() => { if (ballotPublicationId) { @@ -469,7 +503,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me useEffect(() => { if (generateData) { - handleSetPublishStatus(PublishStatus.Generated) + setPublicationStatus(PublishStatus.Generated) if (!viewMode) { notify(t("publish.notifications.generated"), { @@ -477,7 +511,7 @@ const PublishMemo: React.MemoExoticComponent> = React.me }) } } - }, [t, notify, viewMode, handleSetPublishStatus, generateData]) + }, [t, notify, viewMode, generateData]) useEffect(() => { const status = record?.status as IElectionEventStatus | undefined @@ -499,6 +533,12 @@ const PublishMemo: React.MemoExoticComponent> = React.me return ( + {viewMode !== ViewMode.List && publishError ? ( + setPublishError(null)} sx={{mb: 2}}> + {t("publish.dialog.error_publish")} + {publishError} + + ) : null} {viewMode === ViewMode.List && ( > = React.me onChangeStatus={onChangeStatus} electionEventId={electionEventId} setBallotPublicationId={(id: Identifier) => { + requestEpoch.current++ + setTaskId(null) setViewMode(ViewMode.View) + setPublicationStatus(PublishStatus.GeneratedLoading) + setGenerateData(null) + setPublishError(null) setBallotPublicationId(id) }} onPreview={onPreview} @@ -526,21 +571,21 @@ const PublishMemo: React.MemoExoticComponent> = React.me {(viewMode === ViewMode.Edit || viewMode === ViewMode.View) && ( setPublishError(null)} publishType={type} onPublish={onPublish} electionId={electionId} onGenerate={onGenerate} onBack={() => { + requestEpoch.current++ + setTaskId(null) setPublishError(null) refetch() setViewMode(ViewMode.List) - handleSetPublishStatus(PublishStatus.Generated) + setPublicationStatus(PublishStatus.Generated) setGenerateData(null) setBallotPublicationId(null) }} @@ -558,12 +603,14 @@ const PublishMemo: React.MemoExoticComponent> = React.me onClose={handleCloseEditDrawer} title={String(t("publish.dialog.title"))} > - + {open && ( + + )} ) diff --git a/packages/admin-portal/src/resources/Publish/PublishGenerate.tsx b/packages/admin-portal/src/resources/Publish/PublishGenerate.tsx index 6b15bf832ba..f1c6446a0da 100644 --- a/packages/admin-portal/src/resources/Publish/PublishGenerate.tsx +++ b/packages/admin-portal/src/resources/Publish/PublishGenerate.tsx @@ -4,7 +4,7 @@ import React from "react" import {styled} from "@mui/material/styles" -import {Alert, AlertTitle, Box, CircularProgress} from "@mui/material" +import {Box, CircularProgress} from "@mui/material" import {Button, Identifier, useNotify} from "react-admin" import {useTranslation} from "react-i18next" import {ArrowBackIosNew, Publish} from "@mui/icons-material" @@ -63,8 +63,6 @@ const PublishGenerateStyled = { export type TPublishGenerate = { ballotPublicationId?: string | Identifier | null data: any - publishError?: string | null - onDismissPublishError?: () => void publishType: EPublishType.Election | EPublishType.Event readOnly: boolean status: PublishStatus @@ -86,8 +84,6 @@ export const PublishGenerate: React.FC = ({ ballotPublicationId, publishType, data, - publishError, - onDismissPublishError, status, changingStatus, readOnly, @@ -160,24 +156,21 @@ export const PublishGenerate: React.FC = ({ {readOnly && } - + {(data || status === PublishStatus.GeneratedLoading) && ( + + )} - {publishError ? ( - - {t("publish.dialog.error_publish")} - {publishError} - - ) : null} - {/* Left container for the back button */}
@@ -201,6 +194,7 @@ export const PublishGenerate: React.FC = ({ {showPublishPreview && showPublishView ? (