diff --git a/backend/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json b/backend/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json new file mode 100644 index 000000000..aaa90a7c0 --- /dev/null +++ b/backend/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT username FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "username", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e" +} diff --git a/backend/.sqlx/query-340567823a65df0bc39149b345d334fdf52aac79a926c429961fdc980338d84e.json b/backend/.sqlx/query-340567823a65df0bc39149b345d334fdf52aac79a926c429961fdc980338d84e.json new file mode 100644 index 000000000..f6c4beb27 --- /dev/null +++ b/backend/.sqlx/query-340567823a65df0bc39149b345d334fdf52aac79a926c429961fdc980338d84e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": " DELETE FROM users WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "340567823a65df0bc39149b345d334fdf52aac79a926c429961fdc980338d84e" +} diff --git a/backend/.sqlx/query-5ebc9c22d5c7c19f7781a215f90ae409cf58b5cb7e3ab7397ce025efa5d9a47e.json b/backend/.sqlx/query-5ebc9c22d5c7c19f7781a215f90ae409cf58b5cb7e3ab7397ce025efa5d9a47e.json new file mode 100644 index 000000000..d8fdfdb62 --- /dev/null +++ b/backend/.sqlx/query-5ebc9c22d5c7c19f7781a215f90ae409cf58b5cb7e3ab7397ce025efa5d9a47e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE users SET fullname = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5ebc9c22d5c7c19f7781a215f90ae409cf58b5cb7e3ab7397ce025efa5d9a47e" +} diff --git a/backend/.sqlx/query-b7831524cb4e52f2970f12032e90bab3c53effb879bf95592e52372ca8e857b0.json b/backend/.sqlx/query-b7831524cb4e52f2970f12032e90bab3c53effb879bf95592e52372ca8e857b0.json new file mode 100644 index 000000000..48bccb01c --- /dev/null +++ b/backend/.sqlx/query-b7831524cb4e52f2970f12032e90bab3c53effb879bf95592e52372ca8e857b0.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT password_hash FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "password_hash", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "b7831524cb4e52f2970f12032e90bab3c53effb879bf95592e52372ca8e857b0" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b47d303df..dcc8fc7ac 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "abacus" @@ -504,9 +504,9 @@ checksum = "7588475145507237ded760e52bf2f1085495245502033756d28ea72ade0e498b" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", @@ -514,7 +514,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -556,9 +556,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.30" +version = "4.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" +checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" dependencies = [ "clap_builder", "clap_derive", @@ -566,9 +566,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.30" +version = "4.5.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" +checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" dependencies = [ "anstyle", "clap_lex", @@ -4367,6 +4367,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + [[package]] name = "windows-registry" version = "0.2.0" @@ -4731,9 +4737,9 @@ dependencies = [ [[package]] name = "zip" -version = "2.2.2" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +checksum = "b280484c454e74e5fff658bbf7df8fdbe7a07c6b2de4a53def232c15ef138f3a" dependencies = [ "arbitrary", "chrono", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c9ae0143f..dcb69c58c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "abacus" version = "0.1.0" -edition = "2021" +edition = "2024" license = "EUPL-1.2" -rust-version = "1.75" +rust-version = "1.85" default-run = "abacus" [features] @@ -43,7 +43,7 @@ argon2 = "0.5.3" password-hash = { version = "0.5.0", features = ["getrandom"] } rand = "0.9.0" cookie = { version = "0.18.1", features = ["percent-encode"] } -zip = { version = "2.2.2", default-features = false, features = ["deflate", "chrono"] } +zip = { version = "2.2.3", default-features = false, features = ["deflate", "chrono"] } [dev-dependencies] test-log = "0.2.17" diff --git a/backend/fixtures/users.sql b/backend/fixtures/users.sql index 230d761bd..6d02ad7a6 100644 --- a/backend/fixtures/users.sql +++ b/backend/fixtures/users.sql @@ -1,3 +1,8 @@ INSERT INTO users (id, username, fullname, role, password_hash) --- password is 'password' -VALUES (1, 'user', 'Sanne Molenaar', 'administrator', '$argon2id$v=19$m=19456,t=2,p=1$frZGxFIhMHEsBJS4/VZr1A$zVIGEmiTFGy9jEy1Bphdq1ZO0lUngom8qu9PLsN6mZY'); +-- Passwords: +-- admin: 'AdminPassword01' +-- typist: 'TypistPassword01' +-- coordinator: 'CoordinatorPassword01' +VALUES (1, 'admin', 'Sanne Molenaar', 'administrator', '$argon2id$v=19$m=19456,t=2,p=1$QUKK7UVINt+ORMFA+7egeQ$iWQBzhaWH5NupuTSJA5jzxC20y/SH8j53rdz5YTema4'), + (2, 'typist', 'Sam Kuijpers', 'typist', '$argon2id$v=19$m=19456,t=2,p=1$Er+VXYLcGjIJL8i1aCUofA$fjT6Cp1tNr0HhI+LUE+hZG8GnvZI+m9qNXr6mcyJzQM'), + (3, 'coordinator', 'Mohammed van der Velden', 'coordinator', '$argon2id$v=19$m=19456,t=2,p=1$M3/ivnARZ5AHMGIAIc+hpA$AUNjzm2yEWIkMlaam8BKFxr4gv3TbU+nyiAcSZrmfoM'); diff --git a/backend/migrations/5_users.sql b/backend/migrations/5_users.sql index d4e315096..1dc8ec784 100644 --- a/backend/migrations/5_users.sql +++ b/backend/migrations/5_users.sql @@ -9,5 +9,5 @@ CREATE TABLE users last_activity_at DATETIME , updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(username) + UNIQUE(username COLLATE NOCASE) ); diff --git a/backend/migrations/6_sessions.sql b/backend/migrations/6_sessions.sql index 8fe0d17b0..0fbeedaf1 100644 --- a/backend/migrations/6_sessions.sql +++ b/backend/migrations/6_sessions.sql @@ -5,6 +5,6 @@ CREATE TABLE sessions expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, UNIQUE(session_key) ); diff --git a/backend/openapi.json b/backend/openapi.json index e25f4eb63..22faeb73b 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -28,6 +28,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -77,6 +87,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -121,6 +141,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -175,6 +205,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -243,6 +283,16 @@ "application/pdf": {} } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -293,6 +343,16 @@ "text/xml": {} } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -351,6 +411,16 @@ "application/zip": {} } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -405,6 +475,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Election not found", "content": { @@ -467,6 +547,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Election not found", "content": { @@ -542,6 +632,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Polling station not found", "content": { @@ -608,6 +708,16 @@ "200": { "description": "Polling station updated successfully" }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Polling station not found", "content": { @@ -664,6 +774,16 @@ "200": { "description": "Polling station deleted successfully" }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Polling station not found", "content": { @@ -718,6 +838,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -783,6 +913,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -856,6 +996,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -932,6 +1082,16 @@ "204": { "description": "Data entry deleted successfully" }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1000,6 +1160,16 @@ "200": { "description": "Data entry finalised successfully" }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -1061,6 +1231,16 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1100,6 +1280,26 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "409": { + "description": "Conflict (username already exists)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "Internal server error", "content": { @@ -1113,18 +1313,18 @@ } } }, - "/api/user/change-password": { - "post": { + "/api/user/account": { + "put": { "tags": [ "authentication" ], - "summary": "Change password endpoint, updates a user password", - "operationId": "change_password", + "summary": "Update the user's account with a new password and optionally new fullname", + "operationId": "account_update", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" + "$ref": "#/components/schemas/AccountUpdateRequest" } } }, @@ -1132,7 +1332,7 @@ }, "responses": { "200": { - "description": "The current user name and id", + "description": "The logged in user", "content": { "application/json": { "schema": { @@ -1141,16 +1341,6 @@ } } }, - "401": { - "description": "Invalid credentials", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, "500": { "description": "Internal server error", "content": { @@ -1372,6 +1562,60 @@ } } }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "User not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + }, + "delete": { + "tags": [ + "authentication" + ], + "summary": "Delete a user", + "operationId": "user_delete", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, "404": { "description": "User not found", "content": { @@ -1398,12 +1642,52 @@ }, "components": { "schemas": { + "AbsoluteMajorityChange": { + "type": "object", + "description": "Contains information about the enactment of article P 9 of the Kieswet.", + "required": [ + "pg_retracted_seat", + "pg_assigned_seat" + ], + "properties": { + "pg_assigned_seat": { + "type": "integer", + "format": "int32", + "description": "Political group number which the residual seat is assigned to", + "minimum": 0 + }, + "pg_retracted_seat": { + "type": "integer", + "format": "int32", + "description": "Political group number which the residual seat is retracted from", + "minimum": 0 + } + } + }, + "AccountUpdateRequest": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "fullname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "ApportionmentResult": { "type": "object", - "description": "The result of the apportionment procedure. This contains the number of seats and the quota\nthat was used. It then contains the initial standing after whole seats were assigned,\nand each of the changes and intermediate standings. The final standing contains the\nnumber of seats per political group that was assigned after all seats were assigned.", + "description": "The result of the apportionment procedure. This contains the number of seats and the quota\nthat was used. It then contains the initial standing after full seats were assigned,\nand each of the changes and intermediate standings. The final standing contains the\nnumber of seats per political group that was assigned after all seats were assigned.", "required": [ "seats", - "whole_seats", + "full_seats", "residual_seats", "quota", "steps", @@ -1416,6 +1700,11 @@ "$ref": "#/components/schemas/PoliticalGroupSeatAssignment" } }, + "full_seats": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, "quota": { "$ref": "#/components/schemas/Fraction" }, @@ -1434,11 +1723,6 @@ "items": { "$ref": "#/components/schemas/ApportionmentStep" } - }, - "whole_seats": { - "type": "integer", - "format": "int64", - "minimum": 0 } } }, @@ -1472,7 +1756,28 @@ { "allOf": [ { - "$ref": "#/components/schemas/HighestAverageAssignedSeat" + "$ref": "#/components/schemas/LargestAverageAssignedSeat" + }, + { + "type": "object", + "required": [ + "assigned_by" + ], + "properties": { + "assigned_by": { + "type": "string", + "enum": [ + "LargestAverage" + ] + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/LargestRemainderAssignedSeat" }, { "type": "object", @@ -1483,7 +1788,7 @@ "assigned_by": { "type": "string", "enum": [ - "HighestAverage" + "LargestRemainder" ] } } @@ -1493,7 +1798,7 @@ { "allOf": [ { - "$ref": "#/components/schemas/HighestSurplusAssignedSeat" + "$ref": "#/components/schemas/AbsoluteMajorityChange" }, { "type": "object", @@ -1504,7 +1809,7 @@ "assigned_by": { "type": "string", "enum": [ - "HighestSurplus" + "AbsoluteMajorityChange" ] } } @@ -1580,25 +1885,6 @@ } } }, - "ChangePasswordRequest": { - "type": "object", - "required": [ - "username", - "password", - "new_password" - ], - "properties": { - "new_password": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, "CreateUserRequest": { "type": "object", "required": [ @@ -2021,7 +2307,10 @@ "PollingStationResultsAlreadyFinalised", "PollingStationSecondEntryAlreadyFinalised", "PollingStationValidationErrors", - "UserNotFound" + "UserNotFound", + "UsernameNotUnique", + "Unauthorized", + "PasswordRejection" ] }, "ErrorResponse": { @@ -2100,15 +2389,25 @@ } } }, - "HighestAverageAssignedSeat": { + "LargestAverageAssignedSeat": { "type": "object", - "description": "Contains the details for an assigned seat, assigned through the highest average method.", + "description": "Contains the details for an assigned seat, assigned through the largest average method.", "required": [ "selected_pg_number", "pg_options", + "pg_assigned", "votes_per_seat" ], "properties": { + "pg_assigned": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "The list of political groups with the same average, that have been assigned a seat" + }, "pg_options": { "type": "array", "items": { @@ -2116,7 +2415,7 @@ "format": "int32", "minimum": 0 }, - "description": "The list from which the political group was selected, all of them having the same votes per seat" + "description": "The list of political groups with the same average, that have not been assigned a seat" }, "selected_pg_number": { "type": "integer", @@ -2130,15 +2429,25 @@ } } }, - "HighestSurplusAssignedSeat": { + "LargestRemainderAssignedSeat": { "type": "object", - "description": "Contains the details for an assigned seat, assigned through the highest surplus method.", + "description": "Contains the details for an assigned seat, assigned through the largest remainder method.", "required": [ "selected_pg_number", "pg_options", - "surplus_votes" + "pg_assigned", + "remainder_votes" ], "properties": { + "pg_assigned": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "description": "The list of political groups with the same remainder, that have been assigned a seat" + }, "pg_options": { "type": "array", "items": { @@ -2146,17 +2455,17 @@ "format": "int32", "minimum": 0 }, - "description": "The list from which the political group was selected, all of them having the same number of surplus votes" + "description": "The list of political groups with the same remainder, that have not been assigned a seat" + }, + "remainder_votes": { + "$ref": "#/components/schemas/Fraction", + "description": "The number of remainder votes achieved by the selected political group" }, "selected_pg_number": { "type": "integer", "format": "int32", "description": "The political group that was selected for this seat has this political group number", "minimum": 0 - }, - "surplus_votes": { - "$ref": "#/components/schemas/Fraction", - "description": "The number of surplus votes achieved by the selected political group" } } }, @@ -2164,9 +2473,20 @@ "type": "object", "required": [ "user_id", - "username" + "username", + "role", + "needs_password_change" ], "properties": { + "fullname": { + "type": "string" + }, + "needs_password_change": { + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, "user_id": { "type": "integer", "format": "int32", @@ -2208,16 +2528,22 @@ "required": [ "pg_number", "votes_cast", - "surplus_votes", - "meets_surplus_threshold", - "whole_seats", + "remainder_votes", + "meets_remainder_threshold", + "full_seats", "residual_seats", "total_seats" ], "properties": { - "meets_surplus_threshold": { + "full_seats": { + "type": "integer", + "format": "int64", + "description": "The number of full seats assigned to this group", + "minimum": 0 + }, + "meets_remainder_threshold": { "type": "boolean", - "description": "Whether this group met the threshold for surplus seat assignment" + "description": "Whether this group met the threshold for largest remainder seat assignment" }, "pg_number": { "type": "integer", @@ -2225,16 +2551,16 @@ "description": "Political group number for which this assignment applies", "minimum": 0 }, + "remainder_votes": { + "$ref": "#/components/schemas/Fraction", + "description": "The remainder votes that were not used to get full seats assigned to this political group" + }, "residual_seats": { "type": "integer", "format": "int64", "description": "The number of residual seats assigned to this group", "minimum": 0 }, - "surplus_votes": { - "$ref": "#/components/schemas/Fraction", - "description": "The surplus votes that were not used to get whole seats assigned to this political group" - }, "total_seats": { "type": "integer", "format": "int64", @@ -2246,12 +2572,6 @@ "format": "int64", "description": "The number of votes cast for this group", "minimum": 0 - }, - "whole_seats": { - "type": "integer", - "format": "int64", - "description": "The number of whole seats assigned to this group", - "minimum": 0 } } }, @@ -2261,16 +2581,22 @@ "required": [ "pg_number", "votes_cast", - "surplus_votes", - "meets_surplus_threshold", + "remainder_votes", + "meets_remainder_threshold", "next_votes_per_seat", - "whole_seats", + "full_seats", "residual_seats" ], "properties": { - "meets_surplus_threshold": { + "full_seats": { + "type": "integer", + "format": "int64", + "description": "The number of full seats this political group got assigned", + "minimum": 0 + }, + "meets_remainder_threshold": { "type": "boolean", - "description": "Whether the surplus votes meet the threshold to be applicable for surplus seat assignment" + "description": "Whether the remainder votes meet the threshold to be applicable for largest remainder seat assignment" }, "next_votes_per_seat": { "$ref": "#/components/schemas/Fraction", @@ -2282,27 +2608,21 @@ "description": "Political group number for which this standing applies", "minimum": 0 }, + "remainder_votes": { + "$ref": "#/components/schemas/Fraction", + "description": "The remainder of votes that was not used to get full seats (does not have to be a whole number of votes)" + }, "residual_seats": { "type": "integer", "format": "int64", - "description": "The current number of residual seats this political group got", + "description": "The current number of residual seats this political group got assigned", "minimum": 0 }, - "surplus_votes": { - "$ref": "#/components/schemas/Fraction", - "description": "The surplus of votes that was not used to get whole seats (does not have to be a whole number of votes)" - }, "votes_cast": { "type": "integer", "format": "int64", "description": "The number of votes cast for this group", "minimum": 0 - }, - "whole_seats": { - "type": "integer", - "format": "int64", - "description": "The number of whole seats this political group got", - "minimum": 0 } } }, @@ -2566,7 +2886,8 @@ "temp_password": { "type": "string" } - } + }, + "additionalProperties": false }, "User": { "type": "object", diff --git a/backend/src/apportionment/api.rs b/backend/src/apportionment/api.rs index 3de902e0a..a73e393c3 100644 --- a/backend/src/apportionment/api.rs +++ b/backend/src/apportionment/api.rs @@ -1,12 +1,14 @@ use axum::{ - extract::{Path, State}, Json, + extract::{Path, State}, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::{ - apportionment::{seat_allocation, ApportionmentError, ApportionmentResult}, + APIError, ErrorResponse, + apportionment::{ApportionmentError, ApportionmentResult, apportionment}, + authentication::Coordinator, data_entry::{ repository::{PollingStationDataEntries, PollingStationResultsEntries}, status::DataEntryStatusName, @@ -14,7 +16,6 @@ use crate::{ election::repository::Elections, polling_station::repository::PollingStations, summary::ElectionSummary, - APIError, ErrorResponse, }; /// Election details response, including the election's candidate list (political groups) and its polling stations @@ -30,6 +31,7 @@ pub struct ElectionApportionmentResponse { path = "/api/elections/{election_id}/apportionment", responses( (status = 200, description = "Election Apportionment", body = ElectionApportionmentResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 422, description = "Drawing of lots is required", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), @@ -39,6 +41,7 @@ pub struct ElectionApportionmentResponse { ), )] pub async fn election_apportionment( + _user: Coordinator, State(elections_repo): State, State(data_entry_repo): State, State(polling_stations_repo): State, @@ -56,7 +59,7 @@ pub async fn election_apportionment( .list_with_polling_stations(polling_stations_repo, election.id) .await?; let election_summary = ElectionSummary::from_results(&election, &results)?; - let apportionment = seat_allocation(election.number_of_seats.into(), &election_summary)?; + let apportionment = apportionment(election.number_of_seats.into(), &election_summary)?; Ok(Json(ElectionApportionmentResponse { apportionment, election_summary, diff --git a/backend/src/apportionment/fraction.rs b/backend/src/apportionment/fraction.rs index 55b2c9ad2..9befb0365 100644 --- a/backend/src/apportionment/fraction.rs +++ b/backend/src/apportionment/fraction.rs @@ -5,8 +5,8 @@ use std::{ ops::{Add, Div, Mul, Sub}, }; use utoipa::{ - openapi::{schema::Schema, RefOr}, PartialSchema, ToSchema, + openapi::{RefOr, schema::Schema}, }; use crate::data_entry::Count; diff --git a/backend/src/apportionment/mod.rs b/backend/src/apportionment/mod.rs index 430aec377..22d0c1fa9 100644 --- a/backend/src/apportionment/mod.rs +++ b/backend/src/apportionment/mod.rs @@ -2,8 +2,7 @@ use serde::{Deserialize, Serialize}; use tracing::{debug, info}; use utoipa::ToSchema; -use crate::election::PGNumber; -use crate::{data_entry::PoliticalGroupVotes, summary::ElectionSummary}; +use crate::{data_entry::PoliticalGroupVotes, election::PGNumber, summary::ElectionSummary}; pub use self::{api::*, fraction::*}; @@ -11,19 +10,30 @@ mod api; mod fraction; /// The result of the apportionment procedure. This contains the number of seats and the quota -/// that was used. It then contains the initial standing after whole seats were assigned, +/// that was used. It then contains the initial standing after full seats were assigned, /// and each of the changes and intermediate standings. The final standing contains the /// number of seats per political group that was assigned after all seats were assigned. #[derive(Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct ApportionmentResult { pub seats: u64, - pub whole_seats: u64, + pub full_seats: u64, pub residual_seats: u64, pub quota: Fraction, pub steps: Vec, pub final_standing: Vec, } +/// Contains information about the enactment of article P 9 of the Kieswet. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] +pub struct AbsoluteMajorityChange { + /// Political group number which the residual seat is retracted from + #[schema(value_type = u32)] + pg_retracted_seat: PGNumber, + /// Political group number which the residual seat is assigned to + #[schema(value_type = u32)] + pg_assigned_seat: PGNumber, +} + /// Contains information about the final assignment of seats for a specific political group. #[derive(Debug, PartialEq, Serialize, Deserialize, ToSchema)] pub struct PoliticalGroupSeatAssignment { @@ -32,12 +42,12 @@ pub struct PoliticalGroupSeatAssignment { pg_number: PGNumber, /// The number of votes cast for this group votes_cast: u64, - /// The surplus votes that were not used to get whole seats assigned to this political group - surplus_votes: Fraction, - /// Whether this group met the threshold for surplus seat assignment - meets_surplus_threshold: bool, - /// The number of whole seats assigned to this group - whole_seats: u64, + /// The remainder votes that were not used to get full seats assigned to this political group + remainder_votes: Fraction, + /// Whether this group met the threshold for largest remainder seat assignment + meets_remainder_threshold: bool, + /// The number of full seats assigned to this group + full_seats: u64, /// The number of residual seats assigned to this group residual_seats: u64, /// The total number of seats assigned to this group @@ -49,9 +59,9 @@ impl From for PoliticalGroupSeatAssignment { PoliticalGroupSeatAssignment { pg_number: pg.pg_number, votes_cast: pg.votes_cast, - surplus_votes: pg.surplus_votes, - meets_surplus_threshold: pg.meets_surplus_threshold, - whole_seats: pg.whole_seats, + remainder_votes: pg.remainder_votes, + meets_remainder_threshold: pg.meets_remainder_threshold, + full_seats: pg.full_seats, residual_seats: pg.residual_seats, total_seats: pg.total_seats(), } @@ -67,15 +77,15 @@ pub struct PoliticalGroupStanding { pg_number: PGNumber, /// The number of votes cast for this group votes_cast: u64, - /// The surplus of votes that was not used to get whole seats (does not have to be a whole number of votes) - surplus_votes: Fraction, - /// Whether the surplus votes meet the threshold to be applicable for surplus seat assignment - meets_surplus_threshold: bool, + /// The remainder of votes that was not used to get full seats (does not have to be a whole number of votes) + remainder_votes: Fraction, + /// Whether the remainder votes meet the threshold to be applicable for largest remainder seat assignment + meets_remainder_threshold: bool, /// The number of votes per seat if a new seat would be added to the current residual seats next_votes_per_seat: Fraction, - /// The number of whole seats this political group got - whole_seats: u64, - /// The current number of residual seats this political group got + /// The number of full seats this political group got assigned + full_seats: u64, + /// The current number of residual seats this political group got assigned residual_seats: u64, } @@ -89,19 +99,19 @@ impl PoliticalGroupStanding { pg_seats = (votes_cast / quota).integer_part(); } - let surplus_votes = votes_cast - (Fraction::from(pg_seats) * quota); + let remainder_votes = votes_cast - (Fraction::from(pg_seats) * quota); debug!( - "Political group {} has {pg_seats} whole seats with {} votes", + "Political group {} has {pg_seats} full seats with {} votes", pg.number, pg.total ); PoliticalGroupStanding { votes_cast: pg.total.into(), - surplus_votes, - meets_surplus_threshold: votes_cast >= quota * Fraction::new(3, 4), + remainder_votes, + meets_remainder_threshold: votes_cast >= quota * Fraction::new(3, 4), next_votes_per_seat: votes_cast / Fraction::from(pg_seats + 1), pg_number: pg.number, - whole_seats: pg_seats, + full_seats: pg_seats, residual_seats: 0, } } @@ -119,12 +129,12 @@ impl PoliticalGroupStanding { /// Returns the total number of seats assigned to this political group fn total_seats(&self) -> u64 { - self.whole_seats + self.residual_seats + self.full_seats + self.residual_seats } } /// Initial construction of the data required per political group -fn initial_whole_seats_per_political_group( +fn initial_full_seats_per_political_group( pg_votes: &[PoliticalGroupVotes], quota: Fraction, ) -> Vec { @@ -138,16 +148,16 @@ fn initial_whole_seats_per_political_group( /// This is determined based on seeing what would happen to the average votes /// per seat if one additional seat would be assigned to each political group. /// -/// It then returns all the political groups for which this fraction is the highest. -/// If there are more political groups than there are remaining seats to be assigned, +/// It then returns all the political groups for which this fraction is the largest. +/// If there are more political groups than there are residual seats to be assigned, /// a drawing of lots is required. /// /// This function will always return at least one group. fn political_groups_with_largest_average<'a>( assigned_seats: impl IntoIterator, - remaining_seats: u64, + residual_seats: u64, ) -> Result, ApportionmentError> { - // We are now going to find the political groups that have the highest average + // We are now going to find the political groups that have the largest average // votes per seat if we would were to add one additional seat to them let (max_average, political_groups) = assigned_seats.into_iter().fold( (Fraction::ZERO, vec![]), @@ -175,10 +185,10 @@ fn political_groups_with_largest_average<'a>( ); // Check if we can actually assign all these political groups a seat, otherwise we would need to draw lots - if political_groups.len() as u64 > remaining_seats { - // TODO: #788 if multiple political groups have the same highest average and not enough remaining seats are available, use drawing of lots + if political_groups.len() as u64 > residual_seats { + // TODO: #788 if multiple political groups have the same largest average and not enough residual seats are available, use drawing of lots debug!( - "Drawing of lots is required for political groups: {:?}, only {remaining_seats} seats available", + "Drawing of lots is required for political groups: {:?}, only {residual_seats} seat(s) available", political_group_numbers(&political_groups) ); Err(ApportionmentError::DrawingOfLotsNotImplemented) @@ -187,21 +197,28 @@ fn political_groups_with_largest_average<'a>( } } -fn political_groups_with_highest_surplus<'a>( +/// Compute the political groups with the largest votes remainder. +/// +/// It returns all the political groups for which this remainder fraction is the largest. +/// If there are more political groups than there are residual seats to be assigned, +/// a drawing of lots is required. +/// +/// This function will always return at least one group. +fn political_groups_with_largest_remainder<'a>( assigned_seats: impl IntoIterator, - remaining_seats: u64, + residual_seats: u64, ) -> Result, ApportionmentError> { - // We are now going to find the political groups that have the highest surplus - let (max_surplus, political_groups) = assigned_seats.into_iter().fold( + // We are now going to find the political groups that have the largest remainder + let (max_remainder, political_groups) = assigned_seats.into_iter().fold( (Fraction::ZERO, vec![]), |(current_max, mut max_groups), pg| { - // If this surplus is higher than any previously seen, we reset the list of groups matching - if pg.surplus_votes > current_max { - (pg.surplus_votes, vec![pg]) + // If this remainder is higher than any previously seen, we reset the list of groups matching + if pg.remainder_votes > current_max { + (pg.remainder_votes, vec![pg]) } else { - // If the surplus for this political group is the same as the + // If the remainder for this political group is the same as the // max we add it to the list of groups that have that current maximum - if pg.surplus_votes == current_max { + if pg.remainder_votes == current_max { max_groups.push(pg); } (current_max, max_groups) @@ -213,15 +230,15 @@ fn political_groups_with_highest_surplus<'a>( debug_assert!(!political_groups.is_empty()); debug!( - "Found {max_surplus} surplus votes as the maximum for political groups: {:?}", + "Found {max_remainder} remainder votes as the maximum for political groups: {:?}", political_group_numbers(&political_groups) ); // Check if we can actually assign all these political groups - if political_groups.len() as u64 > remaining_seats { - // TODO: #788 if multiple political groups have the same highest surplus and not enough remaining seats are available, use drawing of lots + if political_groups.len() as u64 > residual_seats { + // TODO: #788 if multiple political groups have the same largest remainder and not enough residual seats are available, use drawing of lots debug!( - "Drawing of lots is required for political groups: {:?}, only {remaining_seats} seats available", + "Drawing of lots is required for political groups: {:?}, only {residual_seats} seat(s) available", political_group_numbers(&political_groups) ); Err(ApportionmentError::DrawingOfLotsNotImplemented) @@ -230,39 +247,93 @@ fn political_groups_with_highest_surplus<'a>( } } +/// If a political group got the absolute majority of votes but not the absolute majority of seats, +/// re-assign the last residual seat to the political group with the absolute majority. +/// This re-assignment is done according to article P 9 of the Kieswet. +fn reallocate_residual_seat_for_absolute_majority( + seats: u64, + totals: &ElectionSummary, + pgs_last_residual_seat: &[PGNumber], + standing: Vec, +) -> Result<(Vec, Option), ApportionmentError> { + let half_of_votes_count: Fraction = + Fraction::from(totals.votes_counts.votes_candidates_count) * Fraction::new(1, 2); + + // Find political group with an absolute majority of votes. Return early if we find none + let Some(majority_pg_votes) = totals + .political_group_votes + .iter() + .find(|pg| Fraction::from(pg.total) > half_of_votes_count) + else { + return Ok((standing, None)); + }; + + let half_of_seats_count: Fraction = Fraction::from(seats) * Fraction::new(1, 2); + let pg_final_standing_majority_votes = standing + .iter() + .find(|pg_standing| pg_standing.pg_number == majority_pg_votes.number) + .expect("PG exists"); + + let pg_seats = Fraction::from(pg_final_standing_majority_votes.total_seats()); + + if pg_seats <= half_of_seats_count { + if pgs_last_residual_seat.len() > 1 { + debug!( + "Drawing of lots is required for political groups: {:?} to pick a political group which the residual seat gets retracted from", + pgs_last_residual_seat + ); + return Err(ApportionmentError::DrawingOfLotsNotImplemented); + } + + // Do the reassignment of the seat + let mut standing = standing.clone(); + standing[pgs_last_residual_seat[0] as usize - 1].residual_seats -= 1; + standing[majority_pg_votes.number as usize - 1].residual_seats += 1; + + info!( + "Residual seat first allocated to list {} has been re-allocated to list {} in accordance with Article P 9 Kieswet", + pgs_last_residual_seat[0], majority_pg_votes.number + ); + Ok(( + standing, + Some(AssignedSeat::AbsoluteMajorityChange( + AbsoluteMajorityChange { + pg_retracted_seat: pgs_last_residual_seat[0], + pg_assigned_seat: majority_pg_votes.number, + }, + )), + )) + } else { + Ok((standing, None)) + } +} + /// Apportionment -pub fn seat_allocation( +pub fn apportionment( seats: u64, totals: &ElectionSummary, ) -> Result { - info!("Seat allocation"); - debug!("Totals {:#?}", totals); + info!("Apportionment"); info!("Seats: {}", seats); // Article P 5 Kieswet - // Calculate quota (kiesdeler) as a proper fraction + // Calculate electoral quota (kiesdeler) as a proper fraction let quota = Fraction::from(totals.votes_counts.votes_candidates_count) / Fraction::from(seats); info!("Quota: {}", quota); // Article P 6 Kieswet let initial_standing = - initial_whole_seats_per_political_group(&totals.political_group_votes, quota); - let whole_seats_count = initial_standing - .iter() - .map(|pg| pg.whole_seats) - .sum::(); - let remaining_seats = seats - whole_seats_count; + initial_full_seats_per_political_group(&totals.political_group_votes, quota); + let full_seats = initial_standing.iter().map(|pg| pg.full_seats).sum::(); + let residual_seats = seats - full_seats; - let (steps, final_standing) = if remaining_seats > 0 { - allocate_remainder(&initial_standing, seats, remaining_seats)? + let (steps, final_standing) = if residual_seats > 0 { + allocate_remainder(&initial_standing, totals, seats, residual_seats)? } else { info!("All seats have been allocated without any residual seats"); - (vec![], initial_standing.clone()) + (vec![], initial_standing) }; - // TODO: #785 Article P 9 Kieswet check for absolute majority - // (list with absolute majority should have absolute majority of votes) - // TODO: #797 Article P 19a Kieswet mark deceased candidates // TODO: #787 Article P 10 Kieswet check for list exhaustion @@ -273,38 +344,43 @@ pub fn seat_allocation( Ok(ApportionmentResult { seats, - whole_seats: whole_seats_count, - residual_seats: remaining_seats, + full_seats, + residual_seats, quota, steps, final_standing: final_standing.into_iter().map(Into::into).collect(), }) } -/// This function allocates the residual seats that remain after whole seat allocation is finished. +/// This function allocates the residual seats that remain after full seat allocation is finished. /// These residual seats are assigned through two different procedures, /// depending on how many total seats are available in the election. fn allocate_remainder( initial_standing: &[PoliticalGroupStanding], + totals: &ElectionSummary, seats: u64, - total_remaining_seats: u64, + total_residual_seats: u64, ) -> Result<(Vec, Vec), ApportionmentError> { let mut steps = vec![]; let mut residual_seat_number = 0; let mut current_standing = initial_standing.to_vec(); - while residual_seat_number != total_remaining_seats { - let remaining_seats = total_remaining_seats - residual_seat_number; + while residual_seat_number != total_residual_seats { + let residual_seats = total_residual_seats - residual_seat_number; residual_seat_number += 1; let step = if seats >= 19 { // Article P 7 Kieswet - step_allocate_remainder_using_highest_averages(¤t_standing, remaining_seats)? + step_allocate_remainder_using_largest_averages( + ¤t_standing, + residual_seats, + &steps, + )? } else { // Article P 8 Kieswet - step_allocate_remainder_using_highest_surplus( + step_allocate_remainder_using_largest_remainder( ¤t_standing, - remaining_seats, + residual_seats, &steps, )? }; @@ -332,52 +408,103 @@ fn allocate_remainder( }); } + // Apply Article P 9 Kieswet + let (current_standing, assigned_seat) = if let Some(last_step) = steps.last() { + reallocate_residual_seat_for_absolute_majority( + seats, + totals, + &last_step.change.pg_assigned(), + current_standing, + )? + } else { + (current_standing, None) + }; + + if let Some(assigned_seat) = assigned_seat { + // add the absolute majority change to the remainder assignment steps + steps.push(ApportionmentStep { + standing: current_standing.clone(), + residual_seat_number, + change: assigned_seat, + }); + } + Ok((steps, current_standing)) } +/// Get a vector with the political group number that was assigned the last residual seat. +/// If the last residual seat was assigned to a political group with the same +/// remainder/votes per seat as political groups assigned a seat in previous steps, +/// return all political group numbers that had the same remainder/votes per seat. +fn pg_assigned_from_previous_step( + selected_pg: &PoliticalGroupStanding, + previous: &[ApportionmentStep], + matcher: fn(&AssignedSeat) -> bool, +) -> Vec { + let mut pg_assigned = Vec::new(); + if let Some(previous_step) = previous.last() { + if matcher(&previous_step.change) + && previous_step + .change + .pg_options() + .contains(&selected_pg.pg_number) + { + pg_assigned = previous_step.change.pg_assigned() + } + } + pg_assigned.push(selected_pg.pg_number); + pg_assigned +} + /// Assign the next residual seat, and return which group that seat was assigned to. /// This assignment is done according to the rules for elections with 19 seats or more. -fn step_allocate_remainder_using_highest_averages( +fn step_allocate_remainder_using_largest_averages( standing: &[PoliticalGroupStanding], - remaining_seats: u64, + residual_seats: u64, + previous: &[ApportionmentStep], ) -> Result { - let selected_pgs = political_groups_with_largest_average(standing, remaining_seats)?; + let selected_pgs = political_groups_with_largest_average(standing, residual_seats)?; let selected_pg = selected_pgs[0]; - Ok(AssignedSeat::HighestAverage(HighestAverageAssignedSeat { + Ok(AssignedSeat::LargestAverage(LargestAverageAssignedSeat { selected_pg_number: selected_pg.pg_number, + pg_assigned: pg_assigned_from_previous_step( + selected_pg, + previous, + AssignedSeat::is_assigned_by_largest_average, + ), pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), votes_per_seat: selected_pg.next_votes_per_seat, })) } /// Get an iterator that lists all the parties that qualify for getting a seat through -/// the highest surplus process. This checks the previously assigned seats to make sure +/// the largest remainder process. This checks the previously assigned seats to make sure /// that only parties that didn't previously get a seat assigned are allowed to still -/// get a seat through the surplus process. Additionally only political parties that +/// get a seat through the remainder process. Additionally only political parties that /// met the threshold are considered for this process. -fn political_groups_qualifying_for_highest_surplus<'a>( +fn political_groups_qualifying_for_largest_remainder<'a>( standing: &'a [PoliticalGroupStanding], previous: &'a [ApportionmentStep], ) -> impl Iterator { standing.iter().filter(move |p| { - p.meets_surplus_threshold + p.meets_remainder_threshold && !previous.iter().any(|prev| { - prev.change.is_assigned_by_surplus() + prev.change.is_assigned_by_largest_remainder() && prev.change.political_group_number() == p.pg_number }) }) } -/// Get an iterator that lists all the parties that qualify for unique highest average. +/// Get an iterator that lists all the parties that qualify for unique largest average. /// This checks the previously assigned seats to make sure that every group that already -/// got a residual seat through the highest average procedure does not qualify. -fn political_groups_qualifying_for_unique_highest_average<'a>( +/// got a residual seat through the largest average procedure does not qualify. +fn political_groups_qualifying_for_unique_largest_average<'a>( assigned_seats: &'a [PoliticalGroupStanding], previous: &'a [ApportionmentStep], ) -> impl Iterator { assigned_seats.iter().filter(|p| { !previous.iter().any(|prev| { - prev.change.is_assigned_by_highest_average() + prev.change.is_assigned_by_largest_average() && prev.change.political_group_number() == p.pg_number }) }) @@ -385,49 +512,51 @@ fn political_groups_qualifying_for_unique_highest_average<'a>( /// Assign the next residual seat, and return which group that seat was assigned to. /// This assignment is done according to the rules for elections with less than 19 seats. -fn step_allocate_remainder_using_highest_surplus( +fn step_allocate_remainder_using_largest_remainder( assigned_seats: &[PoliticalGroupStanding], - remaining_seats: u64, + residual_seats: u64, previous: &[ApportionmentStep], ) -> Result { - // first we check if there are any political groups that still qualify for a highest surplus allocated seat - let mut qualifying_for_surplus = - political_groups_qualifying_for_highest_surplus(assigned_seats, previous).peekable(); + // first we check if there are any political groups that still qualify for a largest remainder allocated seat + let mut qualifying_for_remainder = + political_groups_qualifying_for_largest_remainder(assigned_seats, previous).peekable(); - // If there is at least one element in the iterator, we know we can still do a highest surplus allocation - if qualifying_for_surplus.peek().is_some() { + // If there is at least one element in the iterator, we know we can still do a largest remainder allocation + if qualifying_for_remainder.peek().is_some() { let selected_pgs = - political_groups_with_highest_surplus(qualifying_for_surplus, remaining_seats)?; + political_groups_with_largest_remainder(qualifying_for_remainder, residual_seats)?; let selected_pg = selected_pgs[0]; - Ok(AssignedSeat::HighestSurplus(HighestSurplusAssignedSeat { - selected_pg_number: selected_pg.pg_number, - pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), - surplus_votes: selected_pg.surplus_votes, - })) + Ok(AssignedSeat::LargestRemainder( + LargestRemainderAssignedSeat { + selected_pg_number: selected_pg.pg_number, + pg_assigned: pg_assigned_from_previous_step( + selected_pg, + previous, + AssignedSeat::is_assigned_by_largest_remainder, + ), + pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), + remainder_votes: selected_pg.remainder_votes, + }, + )) } else { - // We've now exhausted the highest surplus seats, we now do unique highest average instead: + // We've now exhausted the largest remainder seats, we now do unique largest average instead: // we allow every group to get a seat, not allowing any group to get a second residual seat // while there are still parties that did not get a residual seat. - let mut qualifying_for_unique_highest_average = - political_groups_qualifying_for_unique_highest_average(assigned_seats, previous) + let mut qualifying_for_unique_largest_average = + political_groups_qualifying_for_unique_largest_average(assigned_seats, previous) .peekable(); - if qualifying_for_unique_highest_average.peek().is_some() { - let selected_pgs = political_groups_with_largest_average( - qualifying_for_unique_highest_average, - remaining_seats, - )?; - let selected_pg = selected_pgs[0]; - Ok(AssignedSeat::HighestAverage(HighestAverageAssignedSeat { - selected_pg_number: selected_pg.pg_number, - pg_options: selected_pgs.iter().map(|pg| pg.pg_number).collect(), - votes_per_seat: selected_pg.next_votes_per_seat, - })) + if let Some(&&assigned_seats) = qualifying_for_unique_largest_average.peek() { + step_allocate_remainder_using_largest_averages( + &[assigned_seats], + residual_seats, + previous, + ) } else { - // We've now even exhausted unique highest average seats: every group that qualified - // got a highest surplus seat, and every group also had at least a single residual seat - // assigned to them. We now allow any remaining seats to be assigned using the highest + // We've now even exhausted unique largest average seats: every group that qualified + // got a largest remainder seat, and every group also had at least a single residual seat + // assigned to them. We now allow any residual seats to be assigned using the largest // averages procedure - step_allocate_remainder_using_highest_averages(assigned_seats, remaining_seats) + step_allocate_remainder_using_largest_averages(assigned_seats, residual_seats, previous) } } } @@ -445,54 +574,91 @@ pub struct ApportionmentStep { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] #[serde(tag = "assigned_by")] pub enum AssignedSeat { - HighestAverage(HighestAverageAssignedSeat), - HighestSurplus(HighestSurplusAssignedSeat), + LargestAverage(LargestAverageAssignedSeat), + LargestRemainder(LargestRemainderAssignedSeat), + AbsoluteMajorityChange(AbsoluteMajorityChange), } impl AssignedSeat { - /// Get the political group number for the group this step has assigned a seat + /// Get the political group number for the group this step has assigned a seat to fn political_group_number(&self) -> PGNumber { match self { - AssignedSeat::HighestAverage(highest_average) => highest_average.selected_pg_number, - AssignedSeat::HighestSurplus(highest_surplus) => highest_surplus.selected_pg_number, + AssignedSeat::LargestAverage(largest_average) => largest_average.selected_pg_number, + AssignedSeat::LargestRemainder(largest_remainder) => { + largest_remainder.selected_pg_number + } + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), } } - /// Returns true if the seat was assigned through a surplus - pub fn is_assigned_by_surplus(&self) -> bool { - matches!(self, AssignedSeat::HighestSurplus(_)) + /// Get the list of political groups with the same average, that have not been assigned a seat + fn pg_options(&self) -> Vec { + match self { + AssignedSeat::LargestAverage(largest_average) => largest_average.pg_options.clone(), + AssignedSeat::LargestRemainder(largest_remainder) => { + largest_remainder.pg_options.clone() + } + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), + } } - /// Returns true if the seat was assigned through the highest average - pub fn is_assigned_by_highest_average(&self) -> bool { - matches!(self, AssignedSeat::HighestAverage(_)) + /// Get the list of political groups with the same average, that have been assigned a seat + fn pg_assigned(&self) -> Vec { + match self { + AssignedSeat::LargestAverage(largest_average) => largest_average.pg_assigned.clone(), + AssignedSeat::LargestRemainder(largest_remainder) => { + largest_remainder.pg_assigned.clone() + } + AssignedSeat::AbsoluteMajorityChange(_) => unimplemented!(), + } + } + + /// Returns true if the seat was assigned through the largest remainder + pub fn is_assigned_by_largest_remainder(&self) -> bool { + matches!(self, AssignedSeat::LargestRemainder(_)) + } + + /// Returns true if the seat was assigned through the largest average + pub fn is_assigned_by_largest_average(&self) -> bool { + matches!(self, AssignedSeat::LargestAverage(_)) + } + + /// Returns true if the seat was reassigned through the absolute majority change + pub fn is_assigned_by_absolute_majority_change(&self) -> bool { + matches!(self, AssignedSeat::AbsoluteMajorityChange(_)) } } -/// Contains the details for an assigned seat, assigned through the highest average method. +/// Contains the details for an assigned seat, assigned through the largest average method. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] -pub struct HighestAverageAssignedSeat { +pub struct LargestAverageAssignedSeat { /// The political group that was selected for this seat has this political group number #[schema(value_type = u32)] selected_pg_number: PGNumber, - /// The list from which the political group was selected, all of them having the same votes per seat + /// The list of political groups with the same average, that have not been assigned a seat #[schema(value_type = Vec)] pg_options: Vec, + /// The list of political groups with the same average, that have been assigned a seat + #[schema(value_type = Vec)] + pg_assigned: Vec, /// This is the votes per seat achieved by the selected political group votes_per_seat: Fraction, } -/// Contains the details for an assigned seat, assigned through the highest surplus method. +/// Contains the details for an assigned seat, assigned through the largest remainder method. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] -pub struct HighestSurplusAssignedSeat { +pub struct LargestRemainderAssignedSeat { /// The political group that was selected for this seat has this political group number #[schema(value_type = u32)] selected_pg_number: PGNumber, - /// The list from which the political group was selected, all of them having the same number of surplus votes + /// The list of political groups with the same remainder, that have not been assigned a seat #[schema(value_type = Vec)] pg_options: Vec, - /// The number of surplus votes achieved by the selected political group - surplus_votes: Fraction, + /// The list of political groups with the same remainder, that have been assigned a seat + #[schema(value_type = Vec)] + pg_assigned: Vec, + /// The number of remainder votes achieved by the selected political group + remainder_votes: Fraction, } /// Errors that can occur during apportionment @@ -518,25 +684,15 @@ pub fn get_total_seats_from_apportionment_result(result: ApportionmentResult) -> #[cfg(test)] mod tests { use crate::{ - apportionment::{ - get_total_seats_from_apportionment_result, seat_allocation, ApportionmentError, - }, data_entry::{Count, PoliticalGroupVotes, VotersCounts, VotesCounts}, election::PGNumber, summary::{ElectionSummary, SummaryDifferencesCounts}, }; - use test_log::test; - fn get_election_summary(pg_votes: Vec) -> ElectionSummary { - let total_votes = pg_votes.iter().sum(); - let mut political_group_votes: Vec = vec![]; - for (index, votes) in pg_votes.iter().enumerate() { - political_group_votes.push(PoliticalGroupVotes::from_test_data_auto( - PGNumber::try_from(index + 1).unwrap(), - *votes, - &[], - )) - } + fn get_election_summary( + total_votes: Count, + political_group_votes: Vec, + ) -> ElectionSummary { ElectionSummary { voters_counts: VotersCounts { poll_card_count: total_votes, @@ -556,104 +712,419 @@ mod tests { } } - #[test] - fn test_seat_allocation_less_than_19_seats_without_remaining_seats() { - let totals = get_election_summary(vec![480, 160, 160, 160, 80, 80, 80]); - let result = seat_allocation(15, &totals).unwrap(); - assert_eq!(result.steps.len(), 0); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![6, 2, 2, 2, 1, 1, 1]); + fn get_election_summary_with_default_50_candidates(pg_votes: Vec) -> ElectionSummary { + let total_votes = pg_votes.iter().sum(); + let mut political_group_votes: Vec = vec![]; + for (index, votes) in pg_votes.iter().enumerate() { + // Create list with 50 candidates with 0 votes + let mut candidate_votes: Vec = vec![0; 50]; + // Set votes to first candidate + candidate_votes[0] = *votes; + political_group_votes.push(PoliticalGroupVotes::from_test_data_auto( + PGNumber::try_from(index + 1).unwrap(), + *votes, + &candidate_votes, + )) + } + get_election_summary(total_votes, political_group_votes) } - #[test] - fn test_seat_allocation_less_than_19_seats_with_remaining_seats_assigned_with_surplus_system() { - let totals = get_election_summary(vec![540, 160, 160, 80, 80, 80, 60, 40]); - let result = seat_allocation(15, &totals).unwrap(); - assert_eq!(result.steps.len(), 2); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![7, 2, 2, 1, 1, 1, 1, 0]); - } + /// Tests apportionment for councils with less than 19 seats + mod lt_19_seats { + use crate::apportionment::{ + ApportionmentError, apportionment, get_total_seats_from_apportionment_result, + tests::get_election_summary_with_default_50_candidates, + }; + use test_log::test; + + /// Apportionment without remainder seats + /// + /// Full seats: [6, 2, 2, 2, 1, 1, 1] - Remainder seats: 0 + #[test] + fn test_without_remainder_seats() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 480, 160, 160, 160, 80, 80, 80, + ]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 0); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![6, 2, 2, 2, 1, 1, 1]); + } - #[test] - fn test_seat_allocation_less_than_19_seats_with_remaining_seats_assigned_with_surplus_and_averages_system_only_1_surplus_meets_threshold( - ) { - let totals = get_election_summary(vec![808, 59, 58, 57, 56, 55, 54, 53]); - let result = seat_allocation(15, &totals).unwrap(); - assert_eq!(result.steps.len(), 5); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![12, 1, 1, 1, 0, 0, 0, 0]); - } + /// Apportionment with residual seats assigned with remainder system + /// + /// Full seats: [6, 2, 2, 1, 1, 1, 0, 0] - Remainder seats: 2 + /// Remainders: [60, 0/15, 0/15, 0/15, 0/15, 0/15, 60, 40] + /// 1 - largest remainder: seat assigned to list 1 + /// 2 - largest remainder: seat assigned to list 7 + #[test] + fn test_with_residual_seats_assigned_with_remainder_system() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 540, 160, 160, 80, 80, 80, 60, 40, + ]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 2); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 7); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![7, 2, 2, 1, 1, 1, 1, 0]); + } - #[test] - fn test_seat_allocation_less_than_19_seats_with_0_votes_assigned_with_surplus_and_averages_system( - ) { - let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(10, &totals).unwrap(); - assert_eq!(result.steps.len(), 10); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![2, 2, 2, 2, 2]); - } + /// Apportionment with residual seats assigned with remainder and averages system + /// + /// Full seats: [10, 0, 0, 0, 0, 0, 0, 0] - Remainder seats: 5 + /// Remainders: [8, 59, 58, 57, 56, 55, 54, 53], only votes of list 1 meet the threshold of 75% of the quota + /// 1 - largest remainder: seat assigned to list 1 + /// 1st round of largest averages system (assignment to unique political groups): + /// 2 - largest average: [67 4/12, 59, 58, 57, 56, 55, 54, 53] seat assigned to list 1 + /// 3 - largest average: [62 2/13, 59, 58, 57, 56, 55, 54, 53] seat assigned to list 2, + /// 4 - largest average: [62 2/13, 29 1/2, 58, 57, 56, 55, 54, 53] seat assigned to list 3 + /// 5 - largest average: [62 2/13, 29 1/2, 29, 57, 56, 55, 54, 53] seat assigned to list 4 + #[test] + fn test_with_1_list_that_meets_threshold() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 808, 59, 58, 57, 56, 55, 54, 53, + ]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 5); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 1); + assert_eq!(result.steps[2].change.political_group_number(), 2); + assert_eq!(result.steps[3].change.political_group_number(), 3); + assert_eq!(result.steps[4].change.political_group_number(), 4); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![12, 1, 1, 1, 0, 0, 0, 0]); + } - #[test] - fn test_seat_allocation_less_than_19_seats_with_0_votes_assigned_with_surplus_and_averages_system_drawing_of_lots_error_in_2nd_round_averages_system( - ) { - let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(15, &totals); - assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); - } + /// Apportionment with residual seats assigned with remainder system + /// + /// Full seats: [6, 3, 3, 0, 0, 0, 0] - Remainder seats: 3 + /// Remainders: [0/15, 0/15, 0/15, 55, 50, 45, 45, 45], only votes of lists [1, 2, 3] meet the threshold of 75% of the quota + /// 1 - largest remainder: seat assigned to list 1 + /// 2 - largest remainder: seat assigned to list 2 + /// 3 - largest remainder: seat assigned to list 3 + #[test] + fn test_with_3_lists_that_meet_threshold_0_remainders() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 480, 240, 240, 55, 50, 45, 45, 45, + ]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 3); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 2); + assert_eq!(result.steps[2].change.political_group_number(), 3); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![7, 4, 4, 0, 0, 0, 0, 0]); + } - #[test] - fn test_seat_allocation_less_than_19_seats_with_drawing_of_lots_error_with_0_surpluses() { - let totals = get_election_summary(vec![540, 160, 160, 80, 80, 80, 55, 45]); - let result = seat_allocation(15, &totals); - assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); - } + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - Remainder seats: 3 + /// Remainders: [8, 7, 6, 5, 4, 3, 2, 1, 1, 1], no lists meet the threshold of 75% of the quota + /// 1st round of largest averages system (assignment to unique political groups): + /// 1 - largest average: [8, 7, 6, 5, 4, 3, 2, 1, 1, 1] seat assigned to list 1 + /// 2 - largest average: [4, 7, 6, 5, 4, 3, 2, 1, 1, 1] seat assigned to list 2 + /// 3 - largest average: [4, 3 1/2, 6, 5, 4, 3, 2, 1, 1, 1] seat assigned to list 3 + #[test] + fn test_with_0_lists_that_meet_threshold() { + let totals = + get_election_summary_with_default_50_candidates(vec![8, 7, 6, 5, 4, 3, 2, 1, 1, 1]); + let result = apportionment(3, &totals).unwrap(); + assert_eq!(result.steps.len(), 3); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 2); + assert_eq!(result.steps[2].change.political_group_number(), 3); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![1, 1, 1, 0, 0, 0, 0, 0, 0, 0]); + } - #[test] - fn test_seat_allocation_less_than_19_seats_with_drawing_of_lots_error() { - let totals = get_election_summary(vec![500, 140, 140, 140, 140, 140]); - let result = seat_allocation(15, &totals); - assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); - } + /// Apportionment with residual seats assigned with remainder and averages system + /// + /// Full seats: [0, 0, 0, 0, 0, 0, 0, 0] - Remainder seats: 10 + /// Remainders: [0/10, 0/10, 0/10, 0/10, 0/10] + /// 1 - largest remainder: seat assigned to list 1 + /// 2 - largest remainder: seat assigned to list 2 + /// 3 - largest remainder: seat assigned to list 3 + /// 4 - largest remainder: seat assigned to list 4 + /// 5 - largest remainder: seat assigned to list 5 + /// 1st round of largest averages system (assignment to unique political groups): + /// 6 - largest average: [0/2, 0/2, 0/2, 0/2, 0/2] seat assigned to list 1 + /// 7 - largest average: [0/2, 0/2, 0/2, 0/2, 0/2] seat assigned to list 2 + /// 8 - largest average: [0/2, 0/2, 0/2, 0/2, 0/2] seat assigned to list 3 + /// 9 - largest average: [0/2, 0/2, 0/2, 0/2, 0/2] seat assigned to list 4 + /// 10 - largest average: [0/2, 0/2, 0/2, 0/2, 0/2] seat assigned to list 5 + #[test] + fn test_with_0_votes() { + let totals = get_election_summary_with_default_50_candidates(vec![0, 0, 0, 0, 0]); + let result = apportionment(10, &totals).unwrap(); + assert_eq!(result.steps.len(), 10); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 2); + assert_eq!(result.steps[2].change.political_group_number(), 3); + assert_eq!(result.steps[3].change.political_group_number(), 4); + assert_eq!(result.steps[4].change.political_group_number(), 5); + assert_eq!(result.steps[5].change.political_group_number(), 1); + assert_eq!(result.steps[6].change.political_group_number(), 2); + assert_eq!(result.steps[7].change.political_group_number(), 3); + assert_eq!(result.steps[8].change.political_group_number(), 4); + assert_eq!(result.steps[9].change.political_group_number(), 5); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![2, 2, 2, 2, 2]); + } - #[test] - fn test_seat_allocation_19_or_more_seats_without_remaining_seats() { - let totals = get_election_summary(vec![576, 288, 96, 96, 96, 48]); - let result = seat_allocation(25, &totals).unwrap(); - assert_eq!(result.steps.len(), 0); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![12, 6, 2, 2, 2, 1]); - } + /// Apportionment with residual seats assigned with remainder system + /// + /// This test triggers Kieswet Article P 9 (Actual case from GR2022) + /// Full seats: [7, 2, 1, 1, 1] - Remainder seats: 3 + /// Remainders: [189 2/15, 296 7/15, 226 11/15, 195 11/15, 112 11/15] + /// 1 - largest remainder: seat assigned to list 2 + /// 2 - largest remainder: seat assigned to list 3 + /// 3 - largest remainder: seat assigned to list 4 + /// 4 - Residual seat first allocated to list 4 has been re-allocated to list 1 in accordance with Article P 9 Kieswet + #[test] + fn test_with_absolute_majority_of_votes_but_not_seats() { + let totals = + get_election_summary_with_default_50_candidates(vec![2571, 977, 567, 536, 453]); + let result = apportionment(15, &totals).unwrap(); + assert_eq!(result.steps.len(), 4); + assert_eq!(result.steps[0].change.political_group_number(), 2); + assert_eq!(result.steps[1].change.political_group_number(), 3); + assert_eq!(result.steps[2].change.political_group_number(), 4); + assert!( + result.steps[3] + .change + .is_assigned_by_absolute_majority_change() + ); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![8, 3, 2, 1, 1]); + } - #[test] - fn test_seat_allocation_19_or_more_seats_with_remaining_seats() { - let totals = get_election_summary(vec![600, 302, 98, 99, 101]); - let result = seat_allocation(23, &totals).unwrap(); - assert_eq!(result.steps.len(), 4); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![12, 6, 1, 2, 2]); - } + /// Apportionment with residual seats assigned with remainder system + /// This test triggers Kieswet Article P 9 + /// + /// Full seats: [7, 1, 1, 1, 1, 1] - Remainder seats: 3 + /// Remainders: [170 9/15, 170 12/15, 170 12/15, 170 12/15, 168 12/15, 168 12/15] + /// 1 - largest remainder: seat assigned to list 2 + /// 2 - largest remainder: seat assigned to list 3 + /// 3 - largest remainder: seat assigned to list 4 + /// 4 - Drawing of lots is required for political groups: [2, 3, 4] to pick a political group which the residual seat gets retracted from + #[test] + fn test_with_absolute_majority_of_votes_but_not_seats_with_drawing_of_lots_error() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 2552, 511, 511, 511, 509, 509, + ]); + let result = apportionment(15, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } - #[test] - fn test_seat_allocation_19_or_more_seats_with_0_votes() { - let totals = get_election_summary(vec![0]); - let result = seat_allocation(19, &totals).unwrap(); - assert_eq!(result.steps.len(), 19); - let total_seats = get_total_seats_from_apportionment_result(result); - assert_eq!(total_seats, vec![19]); - } + /// Apportionment with residual seats assigned with remainder and 2 rounds of averages system + /// + /// Full seats: [0, 0, 0, 0, 0] - Remainder seats: 15 + /// Remainders: [0/10, 0/10, 0/10, 0/10, 0/10] + /// 1 - largest remainder: seat assigned to list 1 + /// 2 - largest remainder: seat assigned to list 2 + /// 3 - largest remainder: seat assigned to list 3 + /// 4 - largest remainder: seat assigned to list 4 + /// 5 - largest remainder: seat assigned to list 5 + /// 1st round of largest averages system (assignment to unique political groups): + /// 6 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 1 + /// 7 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 2 + /// 8 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 3 + /// 9 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 4 + /// 10 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 5 + /// 2nd round of largest averages system (assignment to any political group): + /// 11 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 1 + /// 12 - Drawing of lots is required for political groups: [1, 2, 3, 4, 5], only 4 seats available + #[test] + fn test_with_0_votes_with_drawing_of_lots_error_in_2nd_round_averages_system() { + let totals = get_election_summary_with_default_50_candidates(vec![0, 0, 0, 0, 0]); + let result = apportionment(15, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } - #[test] - fn test_seat_allocation_19_or_more_seats_with_0_votes_with_drawing_of_lots_error() { - let totals = get_election_summary(vec![0, 0, 0, 0, 0]); - let result = seat_allocation(19, &totals); - assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + /// Apportionment with residual seats assigned with remainder system + /// + /// Full seats: [6, 2, 2, 1, 1, 1, 0, 0] - Remainder seats: 2 + /// Remainders: [60, 0/15, 0/15, 0/15, 0/15, 0/15, 55, 45] + /// 1 - largest remainder: seat assigned to list 1 + /// 2 - Drawing of lots is required for political groups: [2, 3, 4, 5, 6], only 1 seat available + #[test] + fn test_with_0_remainders_drawing_of_lots_error() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 540, 160, 160, 80, 80, 80, 55, 45, + ]); + let result = apportionment(15, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } + + /// Apportionment with residual seats assigned with remainder system + /// + /// Full seats: [6, 1, 1, 1, 1, 1] - Remainder seats: 4 + /// Remainders: [20, 60, 60, 60, 60, 60] + /// 1 - Drawing of lots is required for political groups: [2, 3, 4, 5, 6], only 4 seats available + #[test] + fn test_with_drawing_of_lots_error() { + let totals = + get_election_summary_with_default_50_candidates(vec![500, 140, 140, 140, 140, 140]); + let result = apportionment(15, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } } - #[test] - fn test_seat_allocation_19_or_more_seats_with_drawing_of_lots_error() { - let totals = get_election_summary(vec![500, 140, 140, 140, 140, 140]); - let result = seat_allocation(23, &totals); - assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + /// Tests apportionment for councils with 19 or more seats + mod gte_19_seats { + use crate::apportionment::{ + ApportionmentError, apportionment, get_total_seats_from_apportionment_result, + tests::get_election_summary_with_default_50_candidates, + }; + use test_log::test; + + /// Apportionment without remainder seats + /// + /// Full seats: [12, 6, 2, 2, 2, 1] - Remainder seats: 0 + #[test] + fn test_without_remainder_seats() { + let totals = + get_election_summary_with_default_50_candidates(vec![576, 288, 96, 96, 96, 48]); + let result = apportionment(25, &totals).unwrap(); + assert_eq!(result.steps.len(), 0); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![12, 6, 2, 2, 2, 1]); + } + + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [11, 5, 1, 1, 1] - Remainder seats: 4 + /// 1 - largest average: [50, 50 2/6, 49, 49 1/2, 50 1/2] seat assigned to list 5 + /// 2 - largest average: [50, 50 2/6, 49, 49 1/2, 33 2/3] seat assigned to list 2 + /// 3 - largest average: [50, 43 1/7, 49, 49 1/2, 33 2/3] seat assigned to list 1 + /// 4 - largest average: [46 2/13, 43 1/7, 49, 49 1/2, 33 2/3] seat assigned to list 4 + #[test] + fn test_with_remainder_seats() { + let totals = + get_election_summary_with_default_50_candidates(vec![600, 302, 98, 99, 101]); + let result = apportionment(23, &totals).unwrap(); + assert_eq!(result.steps.len(), 4); + assert_eq!(result.steps[0].change.political_group_number(), 5); + assert_eq!(result.steps[1].change.political_group_number(), 2); + assert_eq!(result.steps[2].change.political_group_number(), 1); + assert_eq!(result.steps[3].change.political_group_number(), 4); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![12, 6, 1, 2, 2]); + } + + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [15, 0, 0, 0, 0, 0, 0, 0, 0] - Remainder seats: 7 + /// 1 - largest average: [62 2/13, 57, 56, 55, 54, 53, 52, 51, 14] seat assigned to list 1 + /// 2 - largest average: [57 10/14, 57, 56, 55, 54, 53, 52, 51, 14] seat assigned to list 1 + /// 3 - largest average: [53 13/15, 57, 56, 55, 54, 53, 52, 51, 14] seat assigned to list 2 + /// 4 - largest average: [53 13/15, 28 1/2, 56, 55, 54, 53, 52, 51, 14] seat assigned to list 3 + /// 5 - largest average: [53 13/15, 28 1/2, 28, 55, 54, 53, 52, 51, 14] seat assigned to list 4 + /// 6 - largest average: [53 13/15, 28 1/2, 28, 27 1/2, 54, 53, 52, 51, 14] seat assigned to list 5 + /// 7 - largest average: [53 13/15, 28 1/2, 28, 27 1/2, 27, 53, 52, 51, 14] seat assigned to list 1 + #[test] + fn test_with_multiple_remainder_seats_assigned_to_one_list() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 808, 57, 56, 55, 54, 53, 52, 51, 14, + ]); + let result = apportionment(19, &totals).unwrap(); + assert_eq!(result.steps.len(), 7); + assert_eq!(result.steps[0].change.political_group_number(), 1); + assert_eq!(result.steps[1].change.political_group_number(), 1); + assert_eq!(result.steps[2].change.political_group_number(), 2); + assert_eq!(result.steps[3].change.political_group_number(), 3); + assert_eq!(result.steps[4].change.political_group_number(), 4); + assert_eq!(result.steps[5].change.political_group_number(), 5); + assert_eq!(result.steps[6].change.political_group_number(), 1); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![15, 1, 1, 1, 1, 0, 0, 0, 0]); + } + + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [0] - Remainder seats: 19 + /// 1-19 - largest average: [0/1] seat assigned to list 1 + #[test] + fn test_with_0_votes() { + let totals = get_election_summary_with_default_50_candidates(vec![0]); + let result = apportionment(19, &totals).unwrap(); + assert_eq!(result.steps.len(), 19); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![19]); + } + + /// Apportionment with residual seats assigned with averages system + /// This test triggers Kieswet Article P 9 + /// + /// Full seats: [12, 1, 1, 1, 1, 1, 1, 0] - Remainder seats: 6 + /// 1 - largest average: [577, 624 1/2, 624 1/2, 624 1/2, 624 1/2, 624 1/2, 624, 7] seat assigned to list 2 + /// 2 - largest average: [577, 416 1/3, 624 1/2, 624 1/2, 624 1/2, 624 1/2, 624, 7] seat assigned to list 3 + /// 3 - largest average: [577, 416 1/3, 416 1/3, 624 1/2, 624 1/2, 624 1/2, 624, 7] seat assigned to list 4 + /// 4 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 624 1/2, 624 1/2, 624, 7] seat assigned to list 5 + /// 5 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 416 1/3, 624 1/2, 624, 7] seat assigned to list 6 + /// 6 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 416 1/3, 416 1/3, 624, 7] seat assigned to list 7 + /// 7 - Residual seat first allocated to list 7 has been re-allocated to list 1 in accordance with Article P 9 Kieswet + #[test] + fn test_with_absolute_majority_of_votes_but_not_seats() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 7501, 1249, 1249, 1249, 1249, 1249, 1248, 7, + ]); + let result = apportionment(24, &totals).unwrap(); + assert_eq!(result.steps.len(), 7); + assert_eq!(result.steps[0].change.political_group_number(), 2); + assert_eq!(result.steps[1].change.political_group_number(), 3); + assert_eq!(result.steps[2].change.political_group_number(), 4); + assert_eq!(result.steps[3].change.political_group_number(), 5); + assert_eq!(result.steps[4].change.political_group_number(), 6); + assert_eq!(result.steps[5].change.political_group_number(), 7); + let total_seats = get_total_seats_from_apportionment_result(result); + assert_eq!(total_seats, vec![13, 2, 2, 2, 2, 2, 1, 0]); + } + + /// Apportionment with residual seats assigned with averages system + /// This test triggers Kieswet Article P 9 + /// + /// Full seats: [12, 1, 1, 1, 1, 1, 1, 0] - Remainder seats: 6 + /// 1 - largest average: [577, 624 1/2, 624 1/2, 624 1/2, 624 1/2, 624, 624, 8] seat assigned to list 2 + /// 2 - largest average: [577, 416 1/3, 624 1/2, 624 1/2, 624 1/2, 624, 624, 8] seat assigned to list 3 + /// 3 - largest average: [577, 416 1/3, 416 1/3, 624 1/2, 624 1/2, 624, 624, 8] seat assigned to list 4 + /// 4 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 624 1/2, 624, 624, 8] seat assigned to list 5 + /// 5 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 416 1/3, 624, 624, 8] seat assigned to list 6 + /// 6 - largest average: [577, 416 1/3, 416 1/3, 416 1/3, 416 1/3, 416, 624, 8] seat assigned to list 7 + /// 7 - Drawing of lots is required for political groups: [6, 7] to pick a political group which the residual seat gets retracted from + #[test] + fn test_with_absolute_majority_of_votes_but_not_seats_with_drawing_of_lots_error() { + let totals = get_election_summary_with_default_50_candidates(vec![ + 7501, 1249, 1249, 1249, 1249, 1248, 1248, 8, + ]); + let result = apportionment(24, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } + + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [0, 0, 0, 0, 0] - Remainder seats: 19 + /// 1-15 - largest average: [0/1, 0/1, 0/1, 0/1, 0/1] seat assigned to list 1 + /// 16 - Drawing of lots is required for political groups: [1, 2, 3, 4, 5], only 4 seats available + #[test] + fn test_with_0_votes_with_drawing_of_lots_error() { + let totals = get_election_summary_with_default_50_candidates(vec![0, 0, 0, 0, 0]); + let result = apportionment(19, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } + + /// Apportionment with residual seats assigned with averages system + /// + /// Full seats: [9, 2, 2, 2, 2, 2] - Remainder seats: 4 + /// 1 - largest average: [50, 46 2/3, 46 2/3, 46 2/3, 46 2/3, 46 2/3] seat assigned to list 1 + /// 2 - Drawing of lots is required for political groups: [2, 3, 4, 5, 6], only 3 seats available + #[test] + fn test_with_drawing_of_lots_error() { + let totals = + get_election_summary_with_default_50_candidates(vec![500, 140, 140, 140, 140, 140]); + let result = apportionment(23, &totals); + assert_eq!(result, Err(ApportionmentError::DrawingOfLotsNotImplemented)); + } } } diff --git a/backend/src/authentication/api.rs b/backend/src/authentication/api.rs index 296b7593e..2ca2631e2 100644 --- a/backend/src/authentication/api.rs +++ b/backend/src/authentication/api.rs @@ -1,9 +1,9 @@ use super::{ + Admin, SECURE_COOKIES, SESSION_COOKIE_NAME, SESSION_LIFE_TIME, error::AuthenticationError, role::Role, session::Sessions, user::{User, Users}, - SECURE_COOKIES, SESSION_COOKIE_NAME, SESSION_LIFE_TIME, }; use axum::{ extract::{Path, Request, State}, @@ -12,7 +12,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; use cookie::{Cookie, SameSite}; -use hyper::{header::SET_COOKIE, StatusCode}; +use hyper::{StatusCode, header::SET_COOKIE}; use serde::{Deserialize, Serialize}; use sqlx::{Error, SqlitePool}; use tracing::debug; @@ -28,14 +28,22 @@ pub struct Credentials { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct LoginResponse { pub user_id: u32, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = String, nullable = false)] + pub fullname: Option, pub username: String, + pub role: Role, + pub needs_password_change: bool, } impl From<&User> for LoginResponse { fn from(user: &User) -> Self { Self { user_id: user.id(), + fullname: user.fullname().map(|u| u.to_string()), username: user.username().to_string(), + role: user.role(), + needs_password_change: user.needs_password_change(), } } } @@ -92,10 +100,12 @@ pub async fn login( } #[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ChangePasswordRequest { +pub struct AccountUpdateRequest { pub username: String, pub password: String, - pub new_password: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = String, nullable = false)] + pub fullname: Option, } /// Get current logged-in user endpoint @@ -114,41 +124,40 @@ pub async fn whoami(user: Option) -> Result { Ok(Json(LoginResponse::from(&user))) } -/// Change password endpoint, updates a user password +/// Update the user's account with a new password and optionally new fullname #[utoipa::path( - post, - path = "/api/user/change-password", - request_body = ChangePasswordRequest, + put, + path = "/api/user/account", + request_body = AccountUpdateRequest, responses( - (status = 200, description = "The current user name and id", body = LoginResponse), - (status = 401, description = "Invalid credentials", body = ErrorResponse), + (status = 200, description = "The logged in user", body = LoginResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] -pub async fn change_password( - State(users): State, +pub async fn account_update( user: User, - Json(credentials): Json, + State(users): State, + Json(account): Json, ) -> Result { - if user.username() != credentials.username { + if user.username() != account.username { return Err(AuthenticationError::UserNotFound.into()); } - // Check the username + password combination - let authenticated = users - .authenticate(&credentials.username, &credentials.password) + // Update the password + users + .update_password(user.id(), &account.username, &account.password) .await?; - if authenticated.id() != user.id() { - return Err(AuthenticationError::InvalidPassword.into()); + // Update the fullname + if let Some(fullname) = account.fullname { + users.update_fullname(user.id(), &fullname).await?; } - // Update the password - users - .update_password(user.id(), &credentials.new_password) - .await?; + let Some(updated_user) = users.get_by_username(user.username()).await? else { + return Err(AuthenticationError::UserNotFound.into()); + }; - Ok(Json(LoginResponse::from(&user))) + Ok(Json(LoginResponse::from(&updated_user))) } /// Logout endpoint, deletes the session cookie @@ -235,44 +244,6 @@ pub async fn development_create_user( Ok(StatusCode::CREATED) } -/// Development endpoint: login as a user (unauthenticated) -#[cfg(debug_assertions)] -#[utoipa::path( - get, - path = "/api/user/development/login", - responses( - (status = 200, description = "The logged in user id and user name", body = LoginResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ), -)] -pub async fn development_login( - State(users): State, - State(sessions): State, - jar: CookieJar, -) -> Result { - // Get or create the test user - - use super::role::Role; - let user = match users.get_by_username("user").await? { - Some(u) => u, - None => { - users - .create("user", Some("Full Name"), "password", Role::Administrator) - .await? - } - }; - - // Create a new session and cookie - let session = sessions.create(user.id(), SESSION_LIFE_TIME).await?; - - // Add the session cookie to the response - let mut cookie = session.get_cookie(); - set_default_cookie_properties(&mut cookie); - let updated_jar = jar.add(cookie); - - Ok((updated_jar, Json(LoginResponse::from(&user)))) -} - #[derive(Serialize, Deserialize, ToSchema)] pub struct UserListResponse { pub users: Vec, @@ -284,10 +255,12 @@ pub struct UserListResponse { path = "/api/user", responses( (status = 200, description = "User list", body = UserListResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] pub async fn user_list( + _user: Admin, State(users_repo): State, ) -> Result, APIError> { Ok(Json(UserListResponse { @@ -306,6 +279,7 @@ pub struct CreateUserRequest { } #[derive(Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] pub struct UpdateUserRequest { #[serde(skip_serializing_if = "Option::is_none")] #[schema(nullable = false)] @@ -322,10 +296,13 @@ pub struct UpdateUserRequest { request_body = CreateUserRequest, responses( (status = 201, description = "User created", body = User), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 409, description = "Conflict (username already exists)", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] pub async fn user_create( + _user: Admin, State(users_repo): State, Json(create_user_req): Json, ) -> Result<(StatusCode, Json), APIError> { @@ -354,6 +331,7 @@ pub async fn user_create( ), )] pub async fn user_get( + _user: Admin, State(users_repo): State, Path(user_id): Path, ) -> Result, APIError> { @@ -368,11 +346,13 @@ pub async fn user_get( request_body = UpdateUserRequest, responses( (status = 200, description = "User updated", body = User), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "User not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] pub async fn user_update( + _user: Admin, State(users_repo): State, Path(user_id): Path, Json(update_user_req): Json, @@ -386,3 +366,26 @@ pub async fn user_update( .await?; Ok(Json(user)) } + +/// Delete a user +#[utoipa::path( + delete, + path = "/api/user/{user_id}", + responses( + (status = 200, description = "User deleted successfully"), + (status = 404, description = "User not found", body = ErrorResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ), +)] +pub async fn user_delete( + State(users_repo): State, + Path(user_id): Path, +) -> Result { + let deleted = users_repo.delete(user_id).await?; + + if deleted { + Ok(StatusCode::OK) + } else { + Ok(StatusCode::NOT_FOUND) + } +} diff --git a/backend/src/authentication/error.rs b/backend/src/authentication/error.rs index 8efc481bf..1a12cf7b5 100644 --- a/backend/src/authentication/error.rs +++ b/backend/src/authentication/error.rs @@ -4,11 +4,14 @@ pub enum AuthenticationError { InvalidUsernameOrPassword, InvalidPassword, InvalidSessionDuration, + UsernameAlreadyExists, SessionKeyNotFound, NoSessionCookie, Database(sqlx::Error), HashPassword(password_hash::Error), BackwardTimeTravel, + Unauthorized, + PasswordRejection, } impl From for AuthenticationError { diff --git a/backend/src/authentication/mod.rs b/backend/src/authentication/mod.rs index cdf8b8f42..d56ab1618 100644 --- a/backend/src/authentication/mod.rs +++ b/backend/src/authentication/mod.rs @@ -1,12 +1,12 @@ use chrono::TimeDelta; use serde::{Deserialize, Serialize}; -use user::User; use utoipa::ToSchema; -pub use self::api::*; - -#[cfg(test)] -pub use self::session::Sessions; +pub use self::{ + api::*, + role::{Admin, AdminOrCoordinator, Coordinator, Role, Typist}, + user::User, +}; mod api; pub mod error; @@ -52,23 +52,23 @@ impl From<&User> for LoginResponse { #[cfg(test)] mod tests { use super::role::Role; - use api::{ChangePasswordRequest, Credentials, LoginResponse, UserListResponse}; + use api::{AccountUpdateRequest, Credentials, LoginResponse, UserListResponse}; use axum::{ + Router, body::Body, - http::{Request, StatusCode}, + http::{HeaderValue, Request, StatusCode}, middleware, routing::{get, post, put}, - Router, }; use http_body_util::BodyExt; - use hyper::{header::CONTENT_TYPE, Method}; + use hyper::{Method, header::CONTENT_TYPE}; use sqlx::SqlitePool; use test_log::test; use tower::ServiceExt; use crate::{ - authentication::{session::Sessions, *}, AppState, + authentication::{session::Sessions, *}, }; fn create_app(pool: SqlitePool) -> Router { @@ -80,20 +80,40 @@ mod tests { .route("/api/user/login", post(api::login)) .route("/api/user/logout", post(api::logout)) .route("/api/user/whoami", get(api::whoami)) - .route("/api/user/change-password", post(api::change_password)) + .route("/api/user/account", put(api::account_update)) .layer(middleware::from_fn_with_state(pool, extend_session)); #[cfg(debug_assertions)] - let router = router - .route( - "/api/user/development/create", - post(api::development_create_user), - ) - .route("/api/user/development/login", get(api::development_login)); + let router = router.route( + "/api/user/development/create", + post(api::development_create_user), + ); router.with_state(state) } + async fn login(app: Router) -> HeaderValue { + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/user/login") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&Credentials { + username: "admin".to_string(), + password: "AdminPassword01".to_string(), + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + response.headers().get("set-cookie").unwrap().clone() + } + #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] async fn test_login_success(pool: SqlitePool) { let app = create_app(pool); @@ -106,8 +126,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "password".to_string(), + username: "admin".to_string(), + password: "AdminPassword01".to_string(), }) .unwrap(), )) @@ -123,7 +143,7 @@ mod tests { let result: LoginResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(result.user_id, 1); - assert_eq!(result.username, "user"); + assert_eq!(result.username, "admin"); } #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] @@ -138,7 +158,7 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), + username: "admin".to_string(), password: "wrong".to_string(), }) .unwrap(), @@ -164,8 +184,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "password".to_string(), + username: "admin".to_string(), + password: "AdminPassword01".to_string(), }) .unwrap(), )) @@ -187,7 +207,7 @@ mod tests { let result: LoginResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(result.user_id, 1); - assert_eq!(result.username, "user"); + assert_eq!(result.username, "admin"); let response = app .clone() @@ -233,8 +253,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "password".to_string(), + username: "admin".to_string(), + password: "AdminPassword01".to_string(), }) .unwrap(), )) @@ -256,7 +276,7 @@ mod tests { let result: LoginResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(result.user_id, 1); - assert_eq!(result.username, "user"); + assert_eq!(result.username, "admin"); let response = app .clone() @@ -277,7 +297,7 @@ mod tests { let result: LoginResponse = serde_json::from_slice(&body).unwrap(); assert_eq!(result.user_id, 1); - assert_eq!(result.username, "user"); + assert_eq!(result.username, "admin"); // logout the current user let response = app @@ -376,32 +396,6 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } - #[cfg(debug_assertions)] - #[test(sqlx::test)] - async fn test_development_login(pool: SqlitePool) { - let app = create_app(pool); - - // test login - let response = app - .oneshot( - Request::builder() - .method(Method::GET) - .uri("/api/user/development/login") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - assert!(response.headers().get("set-cookie").is_some()); - - let body = response.into_body().collect().await.unwrap().to_bytes(); - let result: LoginResponse = serde_json::from_slice(&body).unwrap(); - - assert_eq!(result.username, "user"); - } - #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] async fn test_update_password(pool: SqlitePool) { let app = create_app(pool); @@ -415,8 +409,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "password".to_string(), + username: "admin".to_string(), + password: "AdminPassword01".to_string(), }) .unwrap(), )) @@ -436,20 +430,20 @@ mod tests { .unwrap() .to_string(); - // Call the change password endpoint + // Call the account update endpoint let response = app .clone() .oneshot( Request::builder() - .method(Method::POST) - .uri("/api/user/change-password") + .method(Method::PUT) + .uri("/api/user/account") .header(CONTENT_TYPE, "application/json") .header("cookie", &cookie) .body(Body::from( - serde_json::to_vec(&ChangePasswordRequest { - username: "user".to_string(), - password: "password".to_string(), - new_password: "new_password".to_string(), + serde_json::to_vec(&AccountUpdateRequest { + username: "admin".to_string(), + password: "TotallyValidNewP4ssW0rd".to_string(), + fullname: None, }) .unwrap(), )) @@ -462,7 +456,7 @@ mod tests { let body = response.into_body().collect().await.unwrap().to_bytes(); let result: LoginResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(result.username, "user"); + assert_eq!(result.username, "admin"); let response = app .oneshot( @@ -472,8 +466,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "new_password".to_string(), + username: "admin".to_string(), + password: "TotallyValidNewP4ssW0rd".to_string(), }) .unwrap(), )) @@ -498,8 +492,8 @@ mod tests { .header(CONTENT_TYPE, "application/json") .body(Body::from( serde_json::to_vec(&Credentials { - username: "user".to_string(), - password: "password".to_string(), + username: "admin".to_string(), + password: "AdminPassword01".to_string(), }) .unwrap(), )) @@ -519,44 +513,20 @@ mod tests { .unwrap() .to_string(); - // Call the change password endpoint with incorrect password + // Call the account update endpoint with incorrect user let response = app .clone() .oneshot( Request::builder() - .method(Method::POST) - .uri("/api/user/change-password") - .header(CONTENT_TYPE, "application/json") - .header("cookie", &cookie) - .body(Body::from( - serde_json::to_vec(&ChangePasswordRequest { - username: "user".to_string(), - password: "wrong_password".to_string(), - new_password: "new_password".to_string(), - }) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - // Call the change password endpoint with incorrect ucer - let response = app - .clone() - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/user/change-password") + .method(Method::PUT) + .uri("/api/user/account") .header(CONTENT_TYPE, "application/json") .header("cookie", &cookie) .body(Body::from( - serde_json::to_vec(&ChangePasswordRequest { + serde_json::to_vec(&AccountUpdateRequest { username: "wrong_user".to_string(), - password: "password".to_string(), - new_password: "new_password".to_string(), + password: "new_password".to_string(), + fullname: Some("Wrong User".to_string()), }) .unwrap(), )) @@ -570,14 +540,18 @@ mod tests { #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] async fn test_list(pool: SqlitePool) { - let app = create_app(pool); - + let app = create_app(pool.clone()); + let sessions = Sessions::new(pool); + let session = sessions.create(1, SESSION_LIFE_TIME).await.unwrap(); + let mut cookie = session.get_cookie(); + set_default_cookie_properties(&mut cookie); let response = app .clone() .oneshot( Request::builder() .method(Method::GET) .uri("/api/user") + .header("cookie", &cookie.encoded().to_string()) .body(Body::empty()) .unwrap(), ) @@ -587,7 +561,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let result: UserListResponse = serde_json::from_slice(&body).unwrap(); - assert_eq!(result.users.len(), 1); + assert_eq!(result.users.len(), 3); } #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] @@ -647,8 +621,8 @@ mod tests { #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] async fn test_create(pool: SqlitePool) { - let app = create_app(pool); - + let app = create_app(pool.clone()); + let cookie = login(app.clone()).await; let response = app .clone() .oneshot( @@ -656,11 +630,12 @@ mod tests { .method(Method::POST) .uri("/api/user") .header(CONTENT_TYPE, "application/json") + .header("cookie", cookie) .body(Body::from( serde_json::to_vec(&CreateUserRequest { username: "test_user".to_string(), fullname: None, - temp_password: "temp pass".to_string(), + temp_password: "TotallyValidP4ssW0rd".to_string(), role: Role::Administrator, }) .unwrap(), @@ -679,9 +654,9 @@ mod tests { } #[test(sqlx::test(fixtures("../../fixtures/users.sql")))] - async fn test_update(pool: SqlitePool) { - let app = create_app(pool); - + async fn test_update_user(pool: SqlitePool) { + let app = create_app(pool.clone()); + let cookie = login(app.clone()).await; let response = app .clone() .oneshot( @@ -689,6 +664,7 @@ mod tests { .method(Method::PUT) .uri("/api/user/1") .header(CONTENT_TYPE, "application/json") + .header("cookie", cookie) .body(Body::from( serde_json::to_vec(&UpdateUserRequest { fullname: Some("Test Full Name".to_string()), @@ -704,7 +680,7 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let result: user::User = serde_json::from_slice(&body).unwrap(); - assert_eq!(result.username(), "user"); + assert_eq!(result.username(), "admin"); assert_eq!(result.fullname().unwrap(), "Test Full Name".to_string()); assert_eq!(result.role(), Role::Administrator); } diff --git a/backend/src/authentication/password.rs b/backend/src/authentication/password.rs index ac45bd4c9..e0879e9b5 100644 --- a/backend/src/authentication/password.rs +++ b/backend/src/authentication/password.rs @@ -1,25 +1,86 @@ +use std::ops::Deref; + use argon2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Algorithm, Argon2, Params, Version, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; +use serde::Deserialize; +use sqlx::Type; use super::error::AuthenticationError; +/// Helper newtype for password validation. Makes sure that password rules are followed when constructed with `new()`. +pub(super) struct ValidatedPassword<'a>(&'a str); + +/// Minimum length of a password +const MIN_PASSWORD_LEN: usize = 13; + +impl<'pw> ValidatedPassword<'pw> { + pub fn new( + username: &str, + password: &'pw str, + old_password: Option<&HashedPassword>, + ) -> Result { + // Total length + if password.len() < MIN_PASSWORD_LEN { + return Err(AuthenticationError::PasswordRejection); + } + + // Password cannot be the same as the username + if username == password { + return Err(AuthenticationError::PasswordRejection); + } + + // Password cannot be the same as the old password + match old_password { + Some(old_pw) if verify_password(password, old_pw) => { + Err(AuthenticationError::PasswordRejection) + } + Some(_) | None => Ok(Self(password)), + } + } +} + +/// Helper newtype indicating the containing string is hashed with `hash_password`. +/// Note that this newtype doesn't give any guarantees, as it is easily constructible +/// because of the From impl. +#[derive(Deserialize, Default, PartialEq, Eq, Clone, Debug, Hash, Type)] +#[sqlx(transparent)] +pub(super) struct HashedPassword(String); + +impl Deref for HashedPassword { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for HashedPassword { + fn from(value: String) -> Self { + Self(value) + } +} + /// Hash a string password with Argon2id v19 and return the string representation of the hash/salt/params -pub(super) fn hash_password(password: &str) -> Result { +pub(super) fn hash_password( + password: ValidatedPassword, +) -> Result { let salt = SaltString::generate(&mut OsRng); // Argon2id v19 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); - Ok(argon2 - .hash_password(password.as_bytes(), &salt)? - .to_string()) + Ok(HashedPassword( + argon2 + .hash_password(password.0.as_bytes(), &salt)? + .to_string(), + )) } /// Verify a password against a password hash created with hash_password -pub(super) fn verify_password(password: &str, password_hash: &str) -> bool { - let Ok(parsed_hash) = PasswordHash::new(password_hash) else { +pub(super) fn verify_password(password: &str, password_hash: &HashedPassword) -> bool { + let Ok(parsed_hash) = PasswordHash::new(&password_hash.0) else { return false; }; @@ -39,23 +100,62 @@ mod tests { #[test] fn test_hash_password_and_verify() { let password = "password"; - let hash = hash_password(password).unwrap(); + let hash = hash_password(ValidatedPassword(password)).unwrap(); assert!(verify_password(password, &hash)); assert!(!verify_password("wrong_password", &hash)); assert!(!verify_password( password, - &hash.replace("$argon2id$v=19", "$argon2id$v=16") + &HashedPassword(hash.replace("$argon2id$v=19", "$argon2id$v=16")) )); } #[test] fn test_password_hash_format() { - let password = "password"; + let password = ValidatedPassword("CoordinatorPassword01"); let hash = hash_password(password).unwrap(); dbg!(&hash); assert!(hash.starts_with("$argon2id$v=19$m=")); } + + #[test] + fn test_password_too_short_error() { + assert!(ValidatedPassword::new("test_user", "too_short", None).is_err()); + } + + #[test] + fn test_password_same_error() { + let unhashed = "TotallyValidP4ssW0rd"; + let hashed = hash_password(ValidatedPassword(unhashed)).unwrap(); + assert!(ValidatedPassword::new("test_user", unhashed, Some(&hashed)).is_err()); + } + + #[test] + fn test_password_valid() { + assert!(ValidatedPassword::new("test_user", "TotallyValidP4ssW0rd", None).is_ok()); + } + + #[test] + fn test_password_not_same_valid() { + let old_password = hash_password(ValidatedPassword("TotallyValidP4ssW0rd")).unwrap(); + assert!( + ValidatedPassword::new("test_user", "TotallyValidNewP4ssW0rd", Some(&old_password)) + .is_ok() + ); + } + + #[test] + fn test_password_same_as_username_error() { + let old_password = hash_password(ValidatedPassword("TotallyValidP4ssW0rd")).unwrap(); + assert!( + ValidatedPassword::new( + "UsernameButAlsoValidPassword01", + "UsernameButAlsoValidPassword01", + Some(&old_password) + ) + .is_err() + ); + } } diff --git a/backend/src/authentication/role.rs b/backend/src/authentication/role.rs index 18a733166..bd7b31021 100644 --- a/backend/src/authentication/role.rs +++ b/backend/src/authentication/role.rs @@ -1,7 +1,18 @@ +use axum::{ + extract::{FromRef, FromRequestParts}, + http::request::Parts, +}; use serde::{Deserialize, Serialize}; use sqlx::Type; use utoipa::ToSchema; +use crate::APIError; + +use super::{ + error::AuthenticationError, + user::{User, Users}, +}; + #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, ToSchema, Type)] #[serde(rename_all = "lowercase")] #[sqlx(rename_all = "snake_case")] @@ -21,3 +32,119 @@ impl From for Role { } } } + +/// A user with the admin role +#[allow(unused)] +pub struct Admin(pub User); + +/// A user with the coordinator role +#[allow(unused)] +pub struct Coordinator(pub User); + +/// A user with the typist role +#[allow(unused)] +pub struct Typist(pub User); + +/// A user with the admin or coordinator role +#[allow(unused)] +pub struct AdminOrCoordinator(pub User); + +impl TryFrom for Admin { + type Error = (); + + fn try_from(user: User) -> Result { + match user.role() { + Role::Administrator => Ok(Self(user)), + _ => Err(()), + } + } +} + +impl TryFrom for Coordinator { + type Error = (); + + fn try_from(user: User) -> Result { + match user.role() { + Role::Coordinator => Ok(Self(user)), + _ => Err(()), + } + } +} + +impl TryFrom for Typist { + type Error = (); + + fn try_from(user: User) -> Result { + match user.role() { + Role::Typist => Ok(Self(user)), + _ => Err(()), + } + } +} + +impl TryFrom for AdminOrCoordinator { + type Error = (); + + fn try_from(user: User) -> Result { + match user.role() { + Role::Administrator | Role::Coordinator => Ok(Self(user)), + _ => Err(()), + } + } +} + +impl FromRequestParts for Admin +where + Users: FromRef, + S: Send + Sync, +{ + type Rejection = APIError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = >::from_request_parts(parts, state).await?; + + Admin::try_from(user).map_err(|_| AuthenticationError::Unauthorized.into()) + } +} + +impl FromRequestParts for Coordinator +where + Users: FromRef, + S: Send + Sync, +{ + type Rejection = APIError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = >::from_request_parts(parts, state).await?; + + Coordinator::try_from(user).map_err(|_| AuthenticationError::Unauthorized.into()) + } +} + +impl FromRequestParts for Typist +where + Users: FromRef, + S: Send + Sync, +{ + type Rejection = APIError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = >::from_request_parts(parts, state).await?; + + Typist::try_from(user).map_err(|_| AuthenticationError::Unauthorized.into()) + } +} + +impl FromRequestParts for AdminOrCoordinator +where + Users: FromRef, + S: Send + Sync, +{ + type Rejection = APIError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = >::from_request_parts(parts, state).await?; + + AdminOrCoordinator::try_from(user).map_err(|_| AuthenticationError::Unauthorized.into()) + } +} diff --git a/backend/src/authentication/session.rs b/backend/src/authentication/session.rs index 0e3b8f096..580854fcf 100644 --- a/backend/src/authentication/session.rs +++ b/backend/src/authentication/session.rs @@ -8,9 +8,9 @@ use sqlx::{FromRow, SqlitePool}; use crate::AppState; use super::{ + SESSION_COOKIE_NAME, SESSION_LIFE_TIME, SESSION_MIN_LIFE_TIME, error::AuthenticationError, util::{create_new_session_key, get_expires_at}, - SESSION_COOKIE_NAME, SESSION_LIFE_TIME, SESSION_MIN_LIFE_TIME, }; /// A session object, corresponds to a row in the sessions table diff --git a/backend/src/authentication/user.rs b/backend/src/authentication/user.rs index 15d5ba5ad..4bf96b2d8 100644 --- a/backend/src/authentication/user.rs +++ b/backend/src/authentication/user.rs @@ -5,17 +5,17 @@ use axum::{ use axum_extra::extract::CookieJar; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{query, query_as, Error, FromRow, SqlitePool}; +use sqlx::{Error, FromRow, SqlitePool, query, query_as}; use utoipa::ToSchema; use crate::{APIError, AppState}; use super::{ + SESSION_COOKIE_NAME, error::AuthenticationError, - password::{hash_password, verify_password}, + password::{HashedPassword, ValidatedPassword, hash_password, verify_password}, role::Role, session::Sessions, - SESSION_COOKIE_NAME, }; const MIN_UPDATE_LAST_ACTIVITY_AT_SECS: i64 = 60; // 1 minute @@ -26,13 +26,13 @@ pub struct User { id: u32, username: String, #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] + #[schema(value_type = String, nullable = false)] fullname: Option, role: Role, #[serde(skip_deserializing)] needs_password_change: bool, #[serde(skip)] - password_hash: String, + password_hash: HashedPassword, #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = String, nullable = false)] last_activity_at: Option>, @@ -78,15 +78,35 @@ impl User { } } - #[cfg(test)] pub fn fullname(&self) -> Option<&str> { self.fullname.as_deref() } - #[cfg(test)] pub fn role(&self) -> Role { self.role } + + pub fn needs_password_change(&self) -> bool { + self.needs_password_change + } + + #[cfg(test)] + pub fn test_user(role: Role) -> Self { + Self { + id: 1, + username: "test_user_1".to_string(), + fullname: Some("Full Name".to_string()), + role, + needs_password_change: false, + password_hash: hash_password( + ValidatedPassword::new("test_user_1", "TotallyValidP4ssW0rd", None).unwrap(), + ) + .unwrap(), + last_activity_at: None, + updated_at: chrono::Utc::now(), + created_at: chrono::Utc::now(), + } + } } /// Implement the FromRequestParts trait for User, this allows us to extract a User from a request @@ -194,7 +214,8 @@ impl Users { password: &str, role: Role, ) -> Result { - let password_hash = hash_password(password)?; + let password_hash: HashedPassword = + hash_password(ValidatedPassword::new(username, password, None)?)?; let user = sqlx::query_as!( User, @@ -217,7 +238,17 @@ impl Users { role, ) .fetch_one(&self.0) - .await?; + .await + .map_err(|e| { + if e.as_database_error() + .map(|e| e.is_unique_violation()) + .unwrap_or(false) + { + AuthenticationError::UsernameAlreadyExists + } else { + e.into() + } + })?; Ok(user) } @@ -261,19 +292,59 @@ impl Users { Ok(updated_user) } + /// Delete a user + pub async fn delete(&self, user_id: u32) -> Result { + let rows_affected = sqlx::query_as!(User, r#" DELETE FROM users WHERE id = ?"#, user_id) + .execute(&self.0) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } + /// Update a user's password pub async fn update_password( &self, user_id: u32, + username: &str, new_password: &str, ) -> Result<(), AuthenticationError> { - let password_hash = hash_password(new_password)?; + let mut tx = self.0.begin().await?; + let old_password = sqlx::query!("SELECT password_hash FROM users WHERE id = ?", user_id) + .fetch_one(tx.as_mut()) + .await? + .password_hash + .into(); + + let password_hash = hash_password(ValidatedPassword::new( + username, + new_password, + Some(&old_password), + )?)?; sqlx::query!( r#"UPDATE users SET password_hash = ?, needs_password_change = FALSE WHERE id = ?"#, password_hash, user_id ) + .execute(tx.as_mut()) + .await?; + + tx.commit().await?; + Ok(()) + } + + /// Update a user's fullname + pub async fn update_fullname( + &self, + user_id: u32, + fullname: &str, + ) -> Result<(), AuthenticationError> { + sqlx::query!( + r#"UPDATE users SET fullname = ? WHERE id = ?"#, + fullname, + user_id + ) .execute(&self.0) .await?; @@ -286,7 +357,8 @@ impl Users { user_id: u32, temp_password: &str, ) -> Result<(), AuthenticationError> { - let password_hash = hash_password(temp_password)?; + let username = self.username_by_id(user_id).await?; + let password_hash = hash_password(ValidatedPassword::new(&username, temp_password, None)?)?; sqlx::query!( r#"UPDATE users SET password_hash = ?, needs_password_change = TRUE WHERE id = ?"#, password_hash, @@ -371,6 +443,15 @@ impl Users { Ok(users) } + pub async fn username_by_id(&self, user_id: u32) -> Result { + Ok( + sqlx::query!("SELECT username FROM users WHERE id = ?", user_id) + .fetch_one(&self.0) + .await? + .username, + ) + } + pub async fn update_last_activity_at(&self, user_id: u32) -> Result<(), Error> { query!( r#"UPDATE users SET last_activity_at = CURRENT_TIMESTAMP WHERE id = ?"#, @@ -396,6 +477,7 @@ mod tests { use crate::authentication::{ error::AuthenticationError, + password, role::Role, session::Sessions, user::{User, Users}, @@ -406,7 +488,7 @@ mod tests { let users = Users::new(pool.clone()); let user = users - .create("test_user", None, "password", Role::Typist) + .create("test_user", None, "TotallyValidP4ssW0rd", Role::Typist) .await .unwrap(); @@ -421,6 +503,28 @@ mod tests { assert_eq!(user, fetched_user); } + #[test(sqlx::test)] + async fn test_create_user_duplicate_username(pool: SqlitePool) { + let users = Users::new(pool.clone()); + + let user = users + .create("test_user", None, "TotallyValidP4ssW0rd", Role::Typist) + .await + .unwrap(); + + assert_eq!(user.username, "test_user"); + + // Try to create a user with the same username, case-insensitive + let error = users + .create("test_User", None, "TotallyValidP4ssW0rd", Role::Typist) + .await; + + assert!(matches!( + error, + Err(AuthenticationError::UsernameAlreadyExists) + )); + } + #[test(sqlx::test)] async fn test_authenticate_user(pool: SqlitePool) { let users = Users::new(pool.clone()); @@ -429,13 +533,16 @@ mod tests { .create( "test_user", Some("Full Name"), - "password", + "TotallyValidP4ssW0rd", Role::Coordinator, ) .await .unwrap(); - let authenticated_user = users.authenticate("test_user", "password").await.unwrap(); + let authenticated_user = users + .authenticate("test_user", "TotallyValidP4ssW0rd") + .await + .unwrap(); assert_eq!(user, authenticated_user); @@ -450,7 +557,7 @@ mod tests { )); let authenticated_user = users - .authenticate("other_user", "password") + .authenticate("other_user", "TotallyValidP4ssW0rd") .await .unwrap_err(); @@ -469,7 +576,7 @@ mod tests { .create( "test_user", Some("Full Name"), - "password", + "TotallyValidP4ssW0rd", Role::Administrator, ) .await @@ -503,30 +610,30 @@ mod tests { async fn test_change_password(pool: SqlitePool) { let users = Users::new(pool.clone()); + let old_password = "TotallyValidP4ssW0rd"; + let new_password = "TotallyValidNewP4ssW0rd"; + let user = users .create( "test_user", Some("Full Name"), - "password", + old_password, Role::Administrator, ) .await .unwrap(); users - .update_password(user.id(), "new_password") + .update_password(user.id(), "test_user", new_password) .await .unwrap(); - let authenticated_user = users - .authenticate("test_user", "new_password") - .await - .unwrap(); + let authenticated_user = users.authenticate("test_user", new_password).await.unwrap(); assert_eq!(user.id(), authenticated_user.id()); let authenticated_user = users - .authenticate("test_user", "password") + .authenticate("test_user", old_password) .await .unwrap_err(); @@ -545,7 +652,7 @@ mod tests { .create( "test_user", Some("Full Name"), - "password", + "TotallyValidP4ssW0rd", Role::Administrator, ) .await @@ -555,7 +662,7 @@ mod tests { assert!(user.needs_password_change); users - .update_password(user.id(), "temp_password") + .update_password(user.id(), "test_user", "temp_password") .await .unwrap(); @@ -581,7 +688,7 @@ mod tests { .create( "test_user", Some("Full Name"), - "password", + "TotallyValidP4ssW0rd", Role::Administrator, ) .await @@ -600,7 +707,11 @@ mod tests { fullname: Some("Full Name".to_string()), role: Role::Typist, needs_password_change: false, - password_hash: "h4sh".to_string(), + password_hash: password::hash_password( + password::ValidatedPassword::new("test_user_1", "TotallyValidP4ssW0rd", None) + .unwrap(), + ) + .unwrap(), last_activity_at: None, updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), diff --git a/backend/src/authentication/util.rs b/backend/src/authentication/util.rs index b71cc0134..1153407f3 100644 --- a/backend/src/authentication/util.rs +++ b/backend/src/authentication/util.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, TimeDelta, Utc}; -use rand::{distr::Alphanumeric, Rng}; +use rand::{Rng, distr::Alphanumeric}; use super::error::AuthenticationError; diff --git a/backend/src/bin/abacus.rs b/backend/src/bin/abacus.rs index b70e4dd33..fa4c3dd41 100644 --- a/backend/src/bin/abacus.rs +++ b/backend/src/bin/abacus.rs @@ -3,7 +3,7 @@ use abacus::fixtures; use abacus::router; use axum::serve::ListenerExt; use clap::Parser; -use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; use std::{ error::Error, net::{Ipv4Addr, SocketAddr}, diff --git a/backend/src/data_entry/mod.rs b/backend/src/data_entry/mod.rs index c0981b991..e813a1c4d 100644 --- a/backend/src/data_entry/mod.rs +++ b/backend/src/data_entry/mod.rs @@ -1,14 +1,15 @@ use crate::{ + authentication::{Typist, User}, data_entry::repository::PollingStationDataEntries, election::repository::Elections, error::{APIError, ErrorReference, ErrorResponse}, polling_station::{repository::PollingStations, structs::PollingStation}, }; use axum::{ + Json, extract::{FromRequest, Path, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use chrono::{DateTime, Utc}; use entry_number::EntryNumber; @@ -42,6 +43,7 @@ pub struct GetDataEntryResponse { path = "/api/polling_stations/{polling_station_id}/data_entries/{entry_number}", responses( (status = 200, description = "Data entry retrieved successfully", body = GetDataEntryResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -51,6 +53,7 @@ pub struct GetDataEntryResponse { ), )] pub async fn polling_station_data_entry_get( + _user: Typist, State(polling_station_data_entries): State, State(polling_stations): State, State(elections): State, @@ -113,6 +116,7 @@ impl IntoResponse for SaveDataEntryResponse { request_body = DataEntry, responses( (status = 200, description = "Data entry saved successfully", body = SaveDataEntryResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 409, description = "Request cannot be completed", body = ErrorResponse), (status = 422, description = "JSON error or invalid data (Unprocessable Content)", body = ErrorResponse), @@ -124,6 +128,7 @@ impl IntoResponse for SaveDataEntryResponse { ), )] pub async fn polling_station_data_entry_save( + _user: Typist, Path((id, entry_number)): Path<(u32, EntryNumber)>, State(polling_station_data_entries): State, State(polling_stations_repo): State, @@ -178,6 +183,7 @@ pub async fn polling_station_data_entry_save( path = "/api/polling_stations/{polling_station_id}/data_entries/{entry_number}", responses( (status = 204, description = "Data entry deleted successfully"), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 409, description = "Request cannot be completed", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), @@ -188,6 +194,7 @@ pub async fn polling_station_data_entry_save( ), )] pub async fn polling_station_data_entry_delete( + _user: Typist, State(polling_station_data_entries): State, Path((id, entry_number)): Path<(u32, EntryNumber)>, ) -> Result { @@ -207,6 +214,7 @@ pub async fn polling_station_data_entry_delete( path = "/api/polling_stations/{polling_station_id}/data_entries/{entry_number}/finalise", responses( (status = 200, description = "Data entry finalised successfully"), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 409, description = "Request cannot be completed", body = ErrorResponse), (status = 422, description = "JSON error or invalid data (Unprocessable Content)", body = ErrorResponse), @@ -218,6 +226,7 @@ pub async fn polling_station_data_entry_delete( ), )] pub async fn polling_station_data_entry_finalise( + _user: Typist, State(polling_station_data_entries): State, State(elections_repo): State, State(polling_stations_repo): State, @@ -284,6 +293,7 @@ pub struct ElectionStatusResponseEntry { path = "/api/elections/{election_id}/status", responses( (status = 200, description = "Election", body = ElectionStatusResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -292,6 +302,7 @@ pub struct ElectionStatusResponseEntry { ), )] pub async fn election_status( + _user: User, State(data_entry_repo): State, Path(id): Path, ) -> Result, APIError> { @@ -302,9 +313,11 @@ pub async fn election_status( #[cfg(test)] pub mod tests { use axum::http::StatusCode; - use sqlx::{query, SqlitePool}; + use sqlx::{SqlitePool, query}; use test_log::test; + use crate::authentication::Role; + use super::*; pub fn example_data_entry() -> DataEntry { @@ -382,6 +395,7 @@ pub mod tests { entry_number: EntryNumber, ) -> Response { polling_station_data_entry_save( + Typist(User::test_user(Role::Typist)), Path((1, entry_number)), State(PollingStationDataEntries::new(pool.clone())), State(PollingStations::new(pool.clone())), @@ -394,6 +408,7 @@ pub mod tests { async fn delete(pool: SqlitePool, entry_number: EntryNumber) -> Response { polling_station_data_entry_delete( + Typist(User::test_user(Role::Typist)), State(PollingStationDataEntries::new(pool.clone())), Path((1, entry_number)), ) @@ -403,6 +418,7 @@ pub mod tests { async fn finalise(pool: SqlitePool, entry_number: EntryNumber) -> Response { polling_station_data_entry_finalise( + Typist(User::test_user(Role::Typist)), State(PollingStationDataEntries::new(pool.clone())), State(Elections::new(pool.clone())), State(PollingStations::new(pool.clone())), @@ -596,6 +612,7 @@ pub mod tests { async fn test_polling_station_data_entry_delete_nonexistent(pool: SqlitePool) { // check that deleting a non-existing data entry returns 404 let response = polling_station_data_entry_delete( + Typist(User::test_user(Role::Typist)), State(PollingStationDataEntries::new(pool.clone())), Path((1, EntryNumber::FirstEntry)), ) diff --git a/backend/src/data_entry/repository.rs b/backend/src/data_entry/repository.rs index c25cd5b87..949c05523 100644 --- a/backend/src/data_entry/repository.rs +++ b/backend/src/data_entry/repository.rs @@ -1,13 +1,13 @@ use axum::extract::FromRef; -use sqlx::{query, query_as, types::Json, SqlitePool}; +use sqlx::{SqlitePool, query, query_as, types::Json}; use super::{ - status::DataEntryStatus, PollingStation, PollingStationDataEntry, PollingStationResults, + PollingStation, PollingStationDataEntry, PollingStationResults, status::DataEntryStatus, }; use crate::{ + AppState, data_entry::{ElectionStatusResponseEntry, PollingStationResultsEntry}, polling_station::repository::PollingStations, - AppState, }; pub struct PollingStationDataEntries(SqlitePool); diff --git a/backend/src/data_entry/status.rs b/backend/src/data_entry/status.rs index 797556db9..fcd842a31 100644 --- a/backend/src/data_entry/status.rs +++ b/backend/src/data_entry/status.rs @@ -6,12 +6,12 @@ use sqlx::Type; use utoipa::ToSchema; use crate::{ - data_entry::{entry_number::EntryNumber, PollingStationResults}, + data_entry::{PollingStationResults, entry_number::EntryNumber}, election::Election, polling_station::PollingStation, }; -use super::{validate_polling_station_results, DataError, ValidationResults}; +use super::{DataError, ValidationResults, validate_polling_station_results}; #[derive(Debug, PartialEq, Eq)] pub enum DataEntryTransitionError { diff --git a/backend/src/data_entry/structs.rs b/backend/src/data_entry/structs.rs index 05b9b372d..6fbdbad98 100644 --- a/backend/src/data_entry/structs.rs +++ b/backend/src/data_entry/structs.rs @@ -1,12 +1,12 @@ use crate::{ + APIError, data_entry::status::DataEntryStatus, election::{CandidateNumber, PGNumber}, error::ErrorReference, - APIError, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{types::Json, FromRow}; +use sqlx::{FromRow, types::Json}; use std::ops::AddAssign; use utoipa::ToSchema; @@ -187,7 +187,10 @@ impl PoliticalGroupVotes { .find(|c| c.number == cv.number) else { return Err(APIError::AddError( - format!("Attempted to add candidate '{}' votes in group '{}', but no such candidate exists", cv.number, self.number), + format!( + "Attempted to add candidate '{}' votes in group '{}', but no such candidate exists", + cv.number, self.number + ), ErrorReference::InvalidVoteCandidate, )); }; diff --git a/backend/src/data_entry/validation.rs b/backend/src/data_entry/validation.rs index c02b9cacd..6363d07c8 100644 --- a/backend/src/data_entry/validation.rs +++ b/backend/src/data_entry/validation.rs @@ -350,18 +350,22 @@ impl Validate for PollingStationResults { != self.differences_counts.more_ballots_count { validation_results.errors.push(ValidationResult { - fields: vec![differences_counts_path - .field("more_ballots_count") - .to_string()], + fields: vec![ + differences_counts_path + .field("more_ballots_count") + .to_string(), + ], code: ValidationResultCode::F301, }); } // F.302 validate that fewer ballots counted is empty if self.differences_counts.fewer_ballots_count != 0 { validation_results.errors.push(ValidationResult { - fields: vec![differences_counts_path - .field("fewer_ballots_count") - .to_string()], + fields: vec![ + differences_counts_path + .field("fewer_ballots_count") + .to_string(), + ], code: ValidationResultCode::F302, }); } @@ -373,18 +377,22 @@ impl Validate for PollingStationResults { != self.differences_counts.fewer_ballots_count { validation_results.errors.push(ValidationResult { - fields: vec![differences_counts_path - .field("fewer_ballots_count") - .to_string()], + fields: vec![ + differences_counts_path + .field("fewer_ballots_count") + .to_string(), + ], code: ValidationResultCode::F303, }); } // F.304 validate that more ballots counted is empty if self.differences_counts.more_ballots_count != 0 { validation_results.errors.push(ValidationResult { - fields: vec![differences_counts_path - .field("more_ballots_count") - .to_string()], + fields: vec![ + differences_counts_path + .field("more_ballots_count") + .to_string(), + ], code: ValidationResultCode::F304, }); } diff --git a/backend/src/election/mod.rs b/backend/src/election/mod.rs index 8e6fda334..3ca89b006 100644 --- a/backend/src/election/mod.rs +++ b/backend/src/election/mod.rs @@ -1,9 +1,7 @@ -#[cfg(feature = "dev-database")] -use axum::http::StatusCode; use axum::{ + Json, extract::{Path, State}, response::{IntoResponse, Response}, - Json, }; use axum_extra::response::Attachment; use serde::{Deserialize, Serialize}; @@ -13,17 +11,24 @@ use zip::{result::ZipError, write::SimpleFileOptions}; use self::repository::Elections; pub use self::structs::*; use crate::{ - data_entry::{repository::PollingStationResultsEntries, PollingStationResults}, - eml::{axum::Eml, eml_document_hash, EMLDocument, EML510}, + APIError, ErrorResponse, + authentication::{Coordinator, User}, + data_entry::{PollingStationResults, repository::PollingStationResultsEntries}, + eml::{EML510, EMLDocument, axum::Eml, eml_document_hash}, pdf_gen::{ generate_pdf, models::{ModelNa31_2Input, PdfModel}, }, polling_station::{repository::PollingStations, structs::PollingStation}, summary::ElectionSummary, - APIError, ErrorResponse, }; +#[cfg(feature = "dev-database")] +use axum::http::StatusCode; + +#[cfg(feature = "dev-database")] +use crate::authentication::Admin; + pub(crate) mod repository; pub mod structs; @@ -54,10 +59,12 @@ impl IntoResponse for Election { path = "/api/elections", responses( (status = 200, description = "Election list", body = ElectionListResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] pub async fn election_list( + _user: User, State(elections_repo): State, ) -> Result, APIError> { let elections = elections_repo.list().await?; @@ -70,6 +77,7 @@ pub async fn election_list( path = "/api/elections/{election_id}", responses( (status = 200, description = "Election", body = ElectionDetailsResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -78,6 +86,7 @@ pub async fn election_list( ), )] pub async fn election_details( + _user: User, State(elections_repo): State, State(polling_stations): State, Path(id): Path, @@ -98,11 +107,13 @@ pub async fn election_details( responses( (status = 201, description = "Election created", body = Election), (status = 400, description = "Bad request", body = ErrorResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), )] #[cfg(feature = "dev-database")] pub async fn election_create( + _user: Admin, State(elections_repo): State, Json(new_election): Json, ) -> Result<(StatusCode, Election), APIError> { @@ -205,6 +216,7 @@ impl ResultsInput { ("Content-Disposition", description = "attachment; filename=\"filename.zip\"") ) ), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -213,6 +225,7 @@ impl ResultsInput { ), )] pub async fn election_download_zip_results( + _user: Coordinator, State(elections_repo): State, State(polling_stations_repo): State, State(polling_station_results_entries_repo): State, @@ -272,6 +285,7 @@ pub async fn election_download_zip_results( ("Content-Disposition", description = "attachment; filename=\"filename.pdf\"") ) ), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -280,6 +294,7 @@ pub async fn election_download_zip_results( ), )] pub async fn election_download_pdf_results( + _user: Coordinator, State(elections_repo): State, State(polling_stations_repo): State, State(polling_station_results_entries_repo): State, @@ -313,6 +328,7 @@ pub async fn election_download_pdf_results( description = "XML", content_type = "text/xml", ), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -321,6 +337,7 @@ pub async fn election_download_pdf_results( ), )] pub async fn election_download_xml_results( + _user: Coordinator, State(elections_repo): State, State(polling_stations_repo): State, State(polling_station_results_entries_repo): State, diff --git a/backend/src/election/repository.rs b/backend/src/election/repository.rs index 0fa28c238..f6e8ec373 100644 --- a/backend/src/election/repository.rs +++ b/backend/src/election/repository.rs @@ -2,7 +2,7 @@ use crate::AppState; use axum::extract::FromRef; #[cfg(feature = "dev-database")] use sqlx::types::Json; -use sqlx::{query_as, Error, SqlitePool}; +use sqlx::{Error, SqlitePool, query_as}; use super::Election; #[cfg(feature = "dev-database")] diff --git a/backend/src/eml/axum.rs b/backend/src/eml/axum.rs index 8db8633b2..abad12391 100644 --- a/backend/src/eml/axum.rs +++ b/backend/src/eml/axum.rs @@ -1,5 +1,5 @@ use axum::{http::HeaderValue, response::IntoResponse}; -use hyper::{header, StatusCode}; +use hyper::{StatusCode, header}; use crate::eml::EMLDocument; diff --git a/backend/src/eml/base.rs b/backend/src/eml/base.rs index eb2d7ea8d..d41554103 100644 --- a/backend/src/eml/base.rs +++ b/backend/src/eml/base.rs @@ -1,10 +1,10 @@ use std::io::BufRead; use quick_xml::{ - se::{Serializer, WriteResult}, DeError, SeError, + se::{Serializer, WriteResult}, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; /// Base EML XML document that contains all the mostly irrelevant for our logic /// XML tags and setup. diff --git a/backend/src/error.rs b/backend/src/error.rs index a80aa05c4..3fa9ba10e 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -3,13 +3,13 @@ use std::error::Error; use crate::{ apportionment::ApportionmentError, authentication::error::AuthenticationError, - data_entry::{status::DataEntryTransitionError, DataError}, + data_entry::{DataError, status::DataEntryTransitionError}, }; use axum::{ + Json, extract::rejection::JsonRejection, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use hyper::header::InvalidHeaderValue; use quick_xml::SeError; @@ -49,6 +49,9 @@ pub enum ErrorReference { PollingStationSecondEntryAlreadyFinalised, PollingStationValidationErrors, UserNotFound, + UsernameNotUnique, + Unauthorized, + PasswordRejection, } /// Response structure for errors @@ -196,6 +199,14 @@ impl IntoResponse for APIError { false, ), ), + AuthenticationError::UsernameAlreadyExists => ( + StatusCode::CONFLICT, + to_error( + "Username already exists", + ErrorReference::UsernameNotUnique, + false, + ), + ), AuthenticationError::UserNotFound => ( StatusCode::UNAUTHORIZED, to_error("User not found", ErrorReference::UserNotFound, false), @@ -213,6 +224,14 @@ impl IntoResponse for APIError { StatusCode::UNAUTHORIZED, to_error("Invalid session", ErrorReference::InvalidSession, false), ), + AuthenticationError::Unauthorized => ( + StatusCode::UNAUTHORIZED, + to_error("Unauthorized", ErrorReference::Unauthorized, false), + ), + AuthenticationError::PasswordRejection => ( + StatusCode::BAD_REQUEST, + to_error("Invalid password", ErrorReference::PasswordRejection, false), + ), // server errors AuthenticationError::Database(_) | AuthenticationError::HashPassword(_) diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 790e26200..84c1a4b85 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,10 +1,10 @@ #[cfg(feature = "memory-serve")] use axum::http::StatusCode; use axum::{ + Router, extract::FromRef, middleware, - routing::{get, post}, - Router, + routing::{get, post, put}, }; #[cfg(feature = "memory-serve")] use memory_serve::MemoryServe; @@ -91,24 +91,24 @@ pub fn router(pool: SqlitePool) -> Result> { ) .route( "/{user_id}", - get(authentication::user_get).put(authentication::user_update), + get(authentication::user_get) + .put(authentication::user_update) + .delete(authentication::user_delete), ) .route("/login", post(authentication::login)) .route("/logout", post(authentication::logout)) .route("/whoami", get(authentication::whoami)) - .route("/change-password", post(authentication::change_password)) + .route("/account", put(authentication::account_update)) .layer(middleware::from_fn_with_state( pool.clone(), authentication::extend_session, )); #[cfg(debug_assertions)] - let user_router = user_router - .route( - "/development/create", - post(authentication::development_create_user), - ) - .route("/development/login", get(authentication::development_login)); + let user_router = user_router.route( + "/development/create", + post(authentication::development_create_user), + ); let app = Router::new() .nest("/api/user", user_router) @@ -161,11 +161,12 @@ pub fn create_openapi() -> utoipa::openapi::OpenApi { authentication::login, authentication::logout, authentication::whoami, - authentication::change_password, + authentication::account_update, authentication::user_list, authentication::user_create, authentication::user_get, authentication::user_update, + authentication::user_delete, election::election_list, election::election_create, election::election_details, @@ -191,11 +192,11 @@ pub fn create_openapi() -> utoipa::openapi::OpenApi { apportionment::PoliticalGroupStanding, apportionment::ApportionmentStep, apportionment::AssignedSeat, - apportionment::HighestAverageAssignedSeat, - apportionment::HighestSurplusAssignedSeat, + apportionment::LargestAverageAssignedSeat, + apportionment::LargestRemainderAssignedSeat, authentication::Credentials, authentication::LoginResponse, - authentication::ChangePasswordRequest, + authentication::AccountUpdateRequest, authentication::UserListResponse, authentication::UpdateUserRequest, authentication::CreateUserRequest, diff --git a/backend/src/pdf_gen/mod.rs b/backend/src/pdf_gen/mod.rs index dd6afd994..976066c92 100644 --- a/backend/src/pdf_gen/mod.rs +++ b/backend/src/pdf_gen/mod.rs @@ -59,7 +59,7 @@ pub(crate) mod tests { use super::*; use crate::{ - election::{tests::election_fixture, Election, ElectionCategory, ElectionStatus}, + election::{Election, ElectionCategory, ElectionStatus, tests::election_fixture}, polling_station::{PollingStation, PollingStationType}, summary::ElectionSummary, }; diff --git a/backend/src/pdf_gen/world.rs b/backend/src/pdf_gen/world.rs index 681763693..34f0b9dd9 100644 --- a/backend/src/pdf_gen/world.rs +++ b/backend/src/pdf_gen/world.rs @@ -2,12 +2,12 @@ use std::collections::HashMap; use super::models::PdfModel; use typst::{ + Library, World, diag::{FileError, FileResult}, foundations::{Bytes, Datetime}, syntax::{FileId, Source, VirtualPath}, text::{Font, FontBook}, utils::LazyHash, - Library, World, }; /// Contains the context for rendering PDFs. @@ -127,18 +127,14 @@ impl World for PdfWorld { /// In a debug build, this is done at runtime, for a release build this is /// done at compile time. macro_rules! include_filedata { - ($path:literal) => {{ - include_bytes!(concat!("../../templates/", $path)) as &'static [u8] - }}; + ($path:literal) => {{ include_bytes!(concat!("../../templates/", $path)) as &'static [u8] }}; } /// Macro that loads data as a string from a file /// In a debug build, this is done at runtime, for a release build this is /// done at compile time. macro_rules! include_strdata { - ($path:literal) => {{ - include_str!(concat!("../../templates/", $path)) as &'static str - }}; + ($path:literal) => {{ include_str!(concat!("../../templates/", $path)) as &'static str }}; } /// Load all sources available from the `templates/` directory (i.e. all typst diff --git a/backend/src/polling_station/mod.rs b/backend/src/polling_station/mod.rs index 34d082e4b..2252b5124 100644 --- a/backend/src/polling_station/mod.rs +++ b/backend/src/polling_station/mod.rs @@ -1,15 +1,19 @@ use axum::{ + Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use self::repository::PollingStations; pub use self::structs::*; -use crate::{election::repository::Elections, APIError, ErrorResponse}; +use crate::{ + APIError, ErrorResponse, + authentication::{AdminOrCoordinator, User}, + election::repository::Elections, +}; pub mod repository; pub mod structs; @@ -32,6 +36,7 @@ impl IntoResponse for PollingStationListResponse { path = "/api/elections/{election_id}/polling_stations", responses( (status = 200, description = "Polling station listing successful", body = PollingStationListResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Election not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -40,6 +45,7 @@ impl IntoResponse for PollingStationListResponse { ), )] pub async fn polling_station_list( + _user: User, State(polling_stations): State, State(elections): State, Path(election_id): Path, @@ -59,6 +65,7 @@ pub async fn polling_station_list( request_body = PollingStationRequest, responses( (status = 201, description = "Polling station created successfully", body = PollingStation), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Election not found", body = ErrorResponse), (status = 409, description = "Polling station already exists", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), @@ -68,6 +75,7 @@ pub async fn polling_station_list( ), )] pub async fn polling_station_create( + _user: AdminOrCoordinator, State(polling_stations): State, State(elections): State, Path(election_id): Path, @@ -90,6 +98,7 @@ pub async fn polling_station_create( path = "/api/elections/{election_id}/polling_stations/{polling_station_id}", responses( (status = 200, description = "Polling station found", body = PollingStation), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Polling station not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -99,6 +108,7 @@ pub async fn polling_station_create( ), )] pub async fn polling_station_get( + _user: User, State(polling_stations): State, Path((election_id, polling_station_id)): Path<(u32, u32)>, ) -> Result<(StatusCode, PollingStation), APIError> { @@ -117,6 +127,7 @@ pub async fn polling_station_get( request_body = PollingStationRequest, responses( (status = 200, description = "Polling station updated successfully"), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Polling station not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -126,6 +137,7 @@ pub async fn polling_station_get( ), )] pub async fn polling_station_update( + _user: AdminOrCoordinator, State(polling_stations): State, Path((election_id, polling_station_id)): Path<(u32, u32)>, polling_station_update: PollingStationRequest, @@ -146,6 +158,7 @@ pub async fn polling_station_update( path = "/api/elections/{election_id}/polling_stations/{polling_station_id}", responses( (status = 200, description = "Polling station deleted successfully"), + (status = 401, description = "Unauthorized", body = ErrorResponse), (status = 404, description = "Polling station not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ), @@ -155,6 +168,7 @@ pub async fn polling_station_update( ), )] pub async fn polling_station_delete( + _user: AdminOrCoordinator, State(polling_stations): State, Path((election_id, polling_station_id)): Path<(u32, u32)>, ) -> Result { @@ -171,7 +185,7 @@ pub async fn polling_station_delete( #[cfg(test)] mod tests { - use sqlx::{query, SqlitePool}; + use sqlx::{SqlitePool, query}; use test_log::test; #[test(sqlx::test(fixtures(path = "../../fixtures", scripts("election_2", "election_3"))))] diff --git a/backend/src/polling_station/repository.rs b/backend/src/polling_station/repository.rs index b084d32e6..afe281260 100644 --- a/backend/src/polling_station/repository.rs +++ b/backend/src/polling_station/repository.rs @@ -1,9 +1,9 @@ use axum::extract::FromRef; -use sqlx::{query, query_as, SqlitePool}; +use sqlx::{SqlitePool, query, query_as}; use crate::{ - polling_station::structs::{PollingStation, PollingStationRequest}, AppState, + polling_station::structs::{PollingStation, PollingStationRequest}, }; pub struct PollingStations(SqlitePool); diff --git a/backend/src/polling_station/structs.rs b/backend/src/polling_station/structs.rs index ed73ead2d..cd3379106 100644 --- a/backend/src/polling_station/structs.rs +++ b/backend/src/polling_station/structs.rs @@ -1,7 +1,7 @@ use axum::{ + Json, extract::FromRequest, response::{IntoResponse, Response}, - Json, }; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Type}; diff --git a/backend/src/summary/mod.rs b/backend/src/summary/mod.rs index fa13f9469..f9bc8a161 100644 --- a/backend/src/summary/mod.rs +++ b/backend/src/summary/mod.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use crate::{ + APIError, data_entry::{ CandidateVotes, Count, DifferencesCounts, PoliticalGroupVotes, PollingStationResults, Validate, ValidationResults, VotersCounts, VotesCounts, @@ -9,7 +10,6 @@ use crate::{ election::Election, error::ErrorReference, polling_station::PollingStation, - APIError, }; /// Contains a summary of the election results, added up from the votes of all polling stations. @@ -351,11 +351,13 @@ mod tests { // this field should not have any recorded polling stations assert_eq!(totals.differences_counts.no_explanation_count.count, 0); - assert!(totals - .differences_counts - .no_explanation_count - .polling_stations - .is_empty()); + assert!( + totals + .differences_counts + .no_explanation_count + .polling_stations + .is_empty() + ); // tests for voters counts assert_eq!(totals.voters_counts.total_admitted_voters_count, 85); diff --git a/backend/tests/account_integration_test.rs b/backend/tests/account_integration_test.rs new file mode 100644 index 000000000..59b3f2dc6 --- /dev/null +++ b/backend/tests/account_integration_test.rs @@ -0,0 +1,40 @@ +#![cfg(test)] + +use hyper::StatusCode; +use serde_json::{Value, json}; +use sqlx::SqlitePool; +use test_log::test; +use utils::serve_api; + +pub mod shared; +pub mod utils; + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_account_update(pool: SqlitePool) { + let addr = serve_api(pool).await; + let url = format!("http://{addr}/api/user/account"); + let admin_cookie = shared::admin_login(&addr).await; + + let response = reqwest::Client::new() + .put(&url) + .json(&json!({ + "username": "admin", + "fullname": "Saartje Molenaar", + "password": "MyLongPassword13" + })) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::OK, + "Unexpected response status" + ); + + let body: Value = response.json().await.unwrap(); + assert_eq!(body["username"], "admin"); + assert_eq!(body["fullname"], "Saartje Molenaar"); + assert_eq!(body["needs_password_change"], false); +} diff --git a/backend/tests/apportionment_integration_test.rs b/backend/tests/apportionment_integration_test.rs index d37c33561..6dd127743 100644 --- a/backend/tests/apportionment_integration_test.rs +++ b/backend/tests/apportionment_integration_test.rs @@ -11,29 +11,35 @@ use crate::{ utils::serve_api, }; use abacus::{ + ErrorResponse, apportionment::{ - get_total_seats_from_apportionment_result, ElectionApportionmentResponse, Fraction, + ElectionApportionmentResponse, Fraction, get_total_seats_from_apportionment_result, }, data_entry::{ - status::ClientState, CandidateVotes, DataEntry, DifferencesCounts, PoliticalGroupVotes, - PollingStationResults, VotersCounts, VotesCounts, + CandidateVotes, DataEntry, DifferencesCounts, PoliticalGroupVotes, PollingStationResults, + VotersCounts, VotesCounts, status::ClientState, }, election::Election, - ErrorResponse, }; pub mod shared; pub mod utils; -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_election_apportionment_works_for_less_than_19_seats(pool: SqlitePool) { let addr = serve_api(pool).await; - - create_result(&addr, 1, 2).await; - create_result(&addr, 2, 2).await; + let coordinator_cookie = shared::coordinator_login(&addr).await; + let typist_cookie = shared::typist_login(&addr).await; + create_result(&addr, typist_cookie.clone(), 1, 2).await; + create_result(&addr, typist_cookie.clone(), 2, 2).await; let url = format!("http://{addr}/api/elections/2/apportionment"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::OK); @@ -45,14 +51,20 @@ async fn test_election_apportionment_works_for_less_than_19_seats(pool: SqlitePo assert_eq!(total_seats, vec![9, 6]); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3", "users"))))] async fn test_election_apportionment_works_for_19_or_more_seats(pool: SqlitePool) { let addr = serve_api(pool).await; - - create_result(&addr, 3, 3).await; + let coordinator_cookie: axum::http::HeaderValue = shared::coordinator_login(&addr).await; + let typist_cookie = shared::typist_login(&addr).await; + create_result(&addr, typist_cookie.clone(), 3, 3).await; let url = format!("http://{addr}/api/elections/3/apportionment"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::OK); @@ -64,10 +76,11 @@ async fn test_election_apportionment_works_for_19_or_more_seats(pool: SqlitePool assert_eq!(total_seats, vec![17, 12]); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3", "users"))))] async fn test_election_apportionment_error_drawing_of_lots_not_implemented(pool: SqlitePool) { let addr = serve_api(pool).await; - + let typist_cookie = shared::typist_login(&addr).await; + let coordinator_cookie = shared::coordinator_login(&addr).await; let data_entry = DataEntry { progress: 60, data: PollingStationResults { @@ -128,10 +141,15 @@ async fn test_election_apportionment_error_drawing_of_lots_not_implemented(pool: client_state: ClientState::new_from_str(None).unwrap(), }; - create_result_with_non_example_data_entry(&addr, 3, 3, data_entry).await; + create_result_with_non_example_data_entry(&addr, typist_cookie, 3, 3, data_entry).await; let url = format!("http://{addr}/api/elections/3/apportionment"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); @@ -139,15 +157,17 @@ async fn test_election_apportionment_error_drawing_of_lots_not_implemented(pool: assert_eq!(body.error, "Drawing of lots is required"); } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_election_apportionment_error_apportionment_not_available_no_polling_stations( pool: SqlitePool, ) { - let addr = serve_api(pool).await; + let addr: std::net::SocketAddr = serve_api(pool).await; + let cookie = shared::admin_login(&addr).await; // Create election without polling stations let response = reqwest::Client::new() .post(format!("http://{addr}/api/elections")) + .header("cookie", cookie.clone()) .json(&serde_json::json!({ "name": "Test Election", "location": "Test Location", @@ -188,11 +208,17 @@ async fn test_election_apportionment_error_apportionment_not_available_no_pollin assert_eq!(response.status(), StatusCode::CREATED); let election: Election = response.json().await.unwrap(); + let coordinator_cookie = shared::coordinator_login(&addr).await; let url = format!( "http://{}/api/elections/{}/apportionment", addr, election.id ); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); @@ -203,17 +229,24 @@ async fn test_election_apportionment_error_apportionment_not_available_no_pollin ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users", "election_3"))))] async fn test_election_apportionment_error_apportionment_not_available_until_data_entries_finalised( pool: SqlitePool, ) { let addr = serve_api(pool).await; + let typist_cookie: axum::http::HeaderValue = shared::typist_login(&addr).await; + let coordinator_cookie: axum::http::HeaderValue = shared::coordinator_login(&addr).await; // Add and finalise first data entry - create_and_finalise_data_entry(&addr, 3, 1).await; + create_and_finalise_data_entry(&addr, typist_cookie, 3, 1).await; let url = format!("http://{addr}/api/elections/3/apportionment"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); @@ -224,12 +257,18 @@ async fn test_election_apportionment_error_apportionment_not_available_until_dat ); } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_election_apportionment_election_not_found(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/1/apportionment"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::NOT_FOUND); diff --git a/backend/tests/authorization_test.rs b/backend/tests/authorization_test.rs new file mode 100644 index 000000000..ce70f8909 --- /dev/null +++ b/backend/tests/authorization_test.rs @@ -0,0 +1,63 @@ +#![cfg(test)] +#![cfg(feature = "openapi")] + +use hyper::{Method, StatusCode}; +use sqlx::SqlitePool; +use test_log::test; + +use crate::utils::serve_api; + +pub mod utils; + +fn expected_response_code(path: &str) -> StatusCode { + match path { + "/api/user/login" => StatusCode::UNSUPPORTED_MEDIA_TYPE, + "/api/user/logout" => StatusCode::OK, + _ => StatusCode::UNAUTHORIZED, + } +} + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] +async fn test_route_authorization(pool: SqlitePool) { + let openapi = abacus::create_openapi(); + let addr = serve_api(pool).await; + + // loop through all the paths in the openapi spec + for (path, item) in openapi.paths.paths.iter() { + let operations = [ + (Method::GET, &item.get), + (Method::POST, &item.post), + (Method::PUT, &item.put), + (Method::PATCH, &item.patch), + ]; + + // loop through all the operations for each path + for (method, operation) in operations.into_iter() { + if let Some(operation) = operation { + let mut path = path.to_string(); + + // replace path parameters with (dummy) values + if let Some(parameters) = operation.parameters.as_ref() { + for param in parameters.iter() { + path = path.replace(&format!("{{{}}}", ¶m.name), "1"); + } + } + + // make a request, given the path and a method + let url = format!("http://{addr}{path}"); + let response = reqwest::Client::new() + .request(method.clone(), url) + .send() + .await + .unwrap(); + + let expected = expected_response_code(&path); + assert_eq!( + expected, + response.status(), + "expected response code {expected} for {method} {path} when not logged in", + ); + } + } + } +} diff --git a/backend/tests/data_entries_integration_test.rs b/backend/tests/data_entries_integration_test.rs index 583b43c54..55399e88b 100644 --- a/backend/tests/data_entries_integration_test.rs +++ b/backend/tests/data_entries_integration_test.rs @@ -1,5 +1,6 @@ #![cfg(test)] +use axum::http::HeaderValue; use reqwest::{Response, StatusCode}; use serde_json::json; use sqlx::SqlitePool; @@ -11,26 +12,27 @@ use crate::{ utils::serve_api, }; use abacus::{ + ErrorResponse, data_entry::{ - status::DataEntryStatusName::*, ElectionStatusResponse, ElectionStatusResponseEntry, - GetDataEntryResponse, SaveDataEntryResponse, ValidationResultCode, + ElectionStatusResponse, ElectionStatusResponseEntry, GetDataEntryResponse, + SaveDataEntryResponse, ValidationResultCode, status::DataEntryStatusName::*, }, - ErrorResponse, }; pub mod shared; pub mod utils; -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_valid(pool: SqlitePool) { let addr = serve_api(pool.clone()).await; - create_and_finalise_data_entry(&addr, 1, 1).await; + let cookie = shared::typist_login(&addr).await; + create_and_finalise_data_entry(&addr, cookie, 1, 1).await; } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_validation(pool: SqlitePool) { let addr = serve_api(pool).await; - + let cookie = shared::typist_login(&addr).await; let request_body = json!({ "data": { "recounted": false, @@ -95,6 +97,7 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { let response = reqwest::Client::new() .post(&url) .json(&request_body) + .header("cookie", cookie) .send() .await .unwrap(); @@ -155,14 +158,15 @@ async fn test_polling_station_data_entry_validation(pool: SqlitePool) { ); } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_polling_station_data_entry_invalid(pool: SqlitePool) { let addr = serve_api(pool).await; - + let cookie = shared::typist_login(&addr).await; let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); let response = reqwest::Client::new() .post(&url) .header("content-type", "application/json") + .header("cookie", cookie) .body(r##"{"data":null}"##) .send() .await @@ -178,9 +182,10 @@ async fn test_polling_station_data_entry_invalid(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::typist_login(&addr).await; let request_body = shared::example_data_entry(None); let invalid_id = 123_456_789; @@ -189,6 +194,7 @@ async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) { let response = reqwest::Client::new() .post(&url) .json(&request_body) + .header("cookie", cookie.clone()) .send() .await .unwrap(); @@ -198,16 +204,22 @@ async fn test_polling_station_data_entry_only_for_existing(pool: SqlitePool) { // Check the same for finalising data entries let url = format!("http://{addr}/api/polling_stations/{invalid_id}/data_entries/1/finalise"); - let response = reqwest::Client::new().post(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .post(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::NOT_FOUND); } /// test that we can get a data entry after saving it -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_get(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::typist_login(&addr).await; let request_body = shared::example_data_entry(None); @@ -216,6 +228,7 @@ async fn test_polling_station_data_entry_get(pool: SqlitePool) { let response = reqwest::Client::new() .post(&url) .json(&request_body) + .header("cookie", cookie.clone()) .send() .await .unwrap(); @@ -223,7 +236,12 @@ async fn test_polling_station_data_entry_get(pool: SqlitePool) { let save_response: SaveDataEntryResponse = response.json().await.unwrap(); // get the data entry - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); // check that the data entry is the same @@ -239,21 +257,27 @@ async fn test_polling_station_data_entry_get(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_get_finalised(pool: SqlitePool) { let addr = serve_api(pool.clone()).await; - create_and_finalise_data_entry(&addr, 1, 1).await; + let cookie = shared::typist_login(&addr).await; + create_and_finalise_data_entry(&addr, cookie.clone(), 1, 1).await; // get the data entry and expect 404 Not Found let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_data_entry_deletion(pool: SqlitePool) { let addr = serve_api(pool).await; - + let cookie = shared::typist_login(&addr).await; let request_body = shared::example_data_entry(None); // create a data entry @@ -261,27 +285,41 @@ async fn test_polling_station_data_entry_deletion(pool: SqlitePool) { let response = reqwest::Client::new() .post(&url) .json(&request_body) + .header("cookie", cookie.clone()) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // delete the data entry - async fn delete_data_entry(addr: SocketAddr) -> Response { + async fn delete_data_entry(addr: SocketAddr, cookie: HeaderValue) -> Response { let url = format!("http://{addr}/api/polling_stations/1/data_entries/1"); - reqwest::Client::new().delete(&url).send().await.unwrap() + reqwest::Client::new() + .delete(&url) + .header("cookie", cookie) + .send() + .await + .unwrap() } - let response = delete_data_entry(addr).await; + let response = delete_data_entry(addr, cookie.clone()).await; assert_eq!(response.status(), StatusCode::NO_CONTENT); // we should not be allowed to delete the entry again - let response = delete_data_entry(addr).await; + let response = delete_data_entry(addr, cookie.clone()).await; assert_eq!(response.status(), StatusCode::CONFLICT); } -async fn get_statuses(addr: &SocketAddr) -> BTreeMap { +async fn get_statuses( + addr: &SocketAddr, + cookie: HeaderValue, +) -> BTreeMap { let url = format!("http://{addr}/api/elections/2/status"); - let response = reqwest::Client::new().get(url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body: ElectionStatusResponse = response.json().await.unwrap(); @@ -294,12 +332,14 @@ async fn get_statuses(addr: &SocketAddr) -> BTreeMap204")); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_election_zip_download(pool: SqlitePool) { let addr = serve_api(pool).await; - - create_result(&addr, 1, 2).await; - create_result(&addr, 2, 2).await; + let coordinator_cookie = shared::coordinator_login(&addr).await; + let typist_cookie = shared::typist_login(&addr).await; + create_result(&addr, typist_cookie.clone(), 1, 2).await; + create_result(&addr, typist_cookie, 2, 2).await; let url = format!("http://{addr}/api/elections/2/download_zip_results"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", coordinator_cookie) + .send() + .await + .unwrap(); let content_disposition = response.headers().get("Content-Disposition"); let content_type = response.headers().get("Content-Type"); diff --git a/backend/tests/polling_station_integration_test.rs b/backend/tests/polling_station_integration_test.rs index 557571a9c..9a54a047c 100644 --- a/backend/tests/polling_station_integration_test.rs +++ b/backend/tests/polling_station_integration_test.rs @@ -9,21 +9,26 @@ use crate::{ utils::serve_api, }; use abacus::{ + ErrorResponse, polling_station::{ PollingStation, PollingStationListResponse, PollingStationRequest, PollingStationType, }, - ErrorResponse, }; pub mod shared; pub mod utils; -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_listing(pool: SqlitePool) { let addr = serve_api(pool).await; - + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -32,15 +37,17 @@ async fn test_polling_station_listing(pool: SqlitePool) { ); let body: PollingStationListResponse = response.json().await.unwrap(); assert_eq!(body.polling_stations.len(), 2); - assert!(body - .polling_stations - .iter() - .any(|ps| ps.name == "Op Rolletjes")) + assert!( + body.polling_stations + .iter() + .any(|ps| ps.name == "Op Rolletjes") + ) } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_creation(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let election_id = 2; let url = format!("http://{addr}/api/elections/{election_id}/polling_stations"); @@ -55,6 +62,7 @@ async fn test_polling_station_creation(pool: SqlitePool) { postal_code: "1234 QY".to_string(), locality: "Heemdamseburg".to_string(), }) + .header("cookie", cookie) .send() .await .unwrap(); @@ -73,13 +81,19 @@ async fn test_polling_station_creation(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_get(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let election_id = 2; let url = format!("http://{addr}/api/elections/{election_id}/polling_stations/2"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -92,9 +106,10 @@ async fn test_polling_station_get(pool: SqlitePool) { assert_eq!(body.polling_station_type, Some(PollingStationType::Special)); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_update_ok(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations/2"); let response = reqwest::Client::new() @@ -108,6 +123,7 @@ async fn test_polling_station_update_ok(pool: SqlitePool) { postal_code: "1234 QY".to_string(), locality: "Testdorp".to_string(), }) + .header("cookie", cookie.clone()) .send() .await .unwrap(); @@ -118,7 +134,12 @@ async fn test_polling_station_update_ok(pool: SqlitePool) { "Unexpected response status" ); - let updated = reqwest::Client::new().get(&url).send().await.unwrap(); + let updated = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( updated.status(), @@ -131,9 +152,10 @@ async fn test_polling_station_update_ok(pool: SqlitePool) { assert_eq!(updated_body.address, "Teststraat 2a"); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_update_empty_type_ok(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations/2"); let response = reqwest::Client::new() @@ -147,6 +169,7 @@ async fn test_polling_station_update_empty_type_ok(pool: SqlitePool) { postal_code: "1234 QY".to_string(), locality: "Testdorp".to_string(), }) + .header("cookie", cookie.clone()) .send() .await .unwrap(); @@ -157,7 +180,12 @@ async fn test_polling_station_update_empty_type_ok(pool: SqlitePool) { "Unexpected response status" ); - let updated = reqwest::Client::new().get(&url).send().await.unwrap(); + let updated = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( updated.status(), @@ -170,9 +198,10 @@ async fn test_polling_station_update_empty_type_ok(pool: SqlitePool) { assert_eq!(updated_body.polling_station_type, None); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_update_not_found(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations/40404"); let response = reqwest::Client::new() @@ -186,6 +215,7 @@ async fn test_polling_station_update_not_found(pool: SqlitePool) { postal_code: "1234 QY".to_string(), locality: "Testdorp".to_string(), }) + .header("cookie", cookie) .send() .await .unwrap(); @@ -197,12 +227,18 @@ async fn test_polling_station_update_not_found(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_delete_ok(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations/2"); - let response = reqwest::Client::new().delete(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .delete(&url) + .header("cookie", cookie.clone()) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -210,7 +246,12 @@ async fn test_polling_station_delete_ok(pool: SqlitePool) { "Unexpected response status" ); - let gone = reqwest::Client::new().get(&url).send().await.unwrap(); + let gone = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( gone.status(), @@ -219,14 +260,20 @@ async fn test_polling_station_delete_ok(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_delete_with_data_entry_fails(pool: SqlitePool) { let addr = serve_api(pool).await; - - create_and_save_data_entry(&addr, 2, 1, None).await; + let typist_cookie = shared::typist_login(&addr).await; + let admin_cookie = shared::admin_login(&addr).await; + create_and_save_data_entry(&addr, typist_cookie, 2, 1, None).await; let url = format!("http://{addr}/api/elections/2/polling_stations/2"); - let response = reqwest::Client::new().delete(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .delete(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -237,14 +284,20 @@ async fn test_polling_station_delete_with_data_entry_fails(pool: SqlitePool) { assert_eq!(body.error, "Invalid data"); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_delete_with_results_fails(pool: SqlitePool) { let addr = serve_api(pool).await; - - create_result(&addr, 1, 2).await; + let typist_cookie = shared::typist_login(&addr).await; + let admin_cookie = shared::admin_login(&addr).await; + create_result(&addr, typist_cookie, 1, 2).await; let url = format!("http://{addr}/api/elections/2/polling_stations/1"); - let response = reqwest::Client::new().delete(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .delete(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -255,12 +308,18 @@ async fn test_polling_station_delete_with_results_fails(pool: SqlitePool) { assert_eq!(body.error, "Invalid data"); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_delete_not_found(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/2/polling_stations/40404"); - let response = reqwest::Client::new().delete(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .delete(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -269,9 +328,10 @@ async fn test_polling_station_delete_not_found(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_non_unique(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let election_id = 2; let url = format!("http://{addr}/api/elections/{election_id}/polling_stations"); @@ -286,6 +346,7 @@ async fn test_polling_station_non_unique(pool: SqlitePool) { postal_code: "1234 QY".to_string(), locality: "Heemdamseburg".to_string(), }) + .header("cookie", cookie) .send() .await .unwrap(); @@ -297,11 +358,17 @@ async fn test_polling_station_non_unique(pool: SqlitePool) { ); } -#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2"))))] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_2", "users"))))] async fn test_polling_station_list_invalid_election(pool: SqlitePool) { let addr = serve_api(pool).await; + let cookie = shared::coordinator_login(&addr).await; let url = format!("http://{addr}/api/elections/1234/polling_stations"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/backend/tests/shared/mod.rs b/backend/tests/shared/mod.rs index 8bb037876..682a2dbe6 100644 --- a/backend/tests/shared/mod.rs +++ b/backend/tests/shared/mod.rs @@ -7,9 +7,9 @@ use serde_json::json; use std::net::SocketAddr; use abacus::data_entry::{ - status::{ClientState, DataEntryStatusName}, CandidateVotes, DataEntry, DifferencesCounts, ElectionStatusResponse, PoliticalGroupVotes, PollingStationResults, SaveDataEntryResponse, VotersCounts, VotesCounts, + status::{ClientState, DataEntryStatusName}, }; // example data entry for an election with two parties with two candidates @@ -77,6 +77,7 @@ pub fn example_data_entry(client_state: Option<&str>) -> DataEntry { async fn post_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, entry_number: u32, data_entry: DataEntry, @@ -86,6 +87,7 @@ async fn post_data_entry( ); let response = Client::new() .post(&url) + .header("cookie", cookie) .json(&data_entry) .send() .await @@ -100,12 +102,14 @@ async fn post_data_entry( pub async fn create_and_save_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, entry_number: u32, client_state: Option<&str>, ) { post_data_entry( addr, + cookie, polling_station_id, entry_number, example_data_entry(client_state), @@ -115,17 +119,30 @@ pub async fn create_and_save_data_entry( pub async fn create_and_save_non_example_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, entry_number: u32, data_entry: DataEntry, ) { - post_data_entry(addr, polling_station_id, entry_number, data_entry).await; + post_data_entry(addr, cookie, polling_station_id, entry_number, data_entry).await; } -async fn finalise_data_entry(addr: &SocketAddr, polling_station_id: u32, entry_number: u32) { +async fn finalise_data_entry( + addr: &SocketAddr, + cookie: HeaderValue, + polling_station_id: u32, + entry_number: u32, +) { // Finalise the data entry - let url = format!("http://{addr}/api/polling_stations/{polling_station_id}/data_entries/{entry_number}/finalise"); - let response = Client::new().post(&url).send().await.unwrap(); + let url = format!( + "http://{addr}/api/polling_stations/{polling_station_id}/data_entries/{entry_number}/finalise" + ); + let response = Client::new() + .post(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); // Ensure the response is what we expect assert_eq!(response.status(), StatusCode::OK); @@ -133,22 +150,30 @@ async fn finalise_data_entry(addr: &SocketAddr, polling_station_id: u32, entry_n pub async fn create_and_finalise_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, entry_number: u32, ) { - create_and_save_data_entry(addr, polling_station_id, entry_number, None).await; - finalise_data_entry(addr, polling_station_id, entry_number).await; + create_and_save_data_entry(addr, cookie.clone(), polling_station_id, entry_number, None).await; + finalise_data_entry(addr, cookie, polling_station_id, entry_number).await; } pub async fn create_and_finalise_non_example_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, entry_number: u32, data_entry: DataEntry, ) { - create_and_save_non_example_data_entry(addr, polling_station_id, entry_number, data_entry) - .await; - finalise_data_entry(addr, polling_station_id, entry_number).await; + create_and_save_non_example_data_entry( + addr, + cookie.clone(), + polling_station_id, + entry_number, + data_entry, + ) + .await; + finalise_data_entry(addr, cookie, polling_station_id, entry_number).await; } async fn check_data_entry_status_is_definitive( @@ -158,7 +183,13 @@ async fn check_data_entry_status_is_definitive( ) { // check that data entry status for this polling station is now Definitive let url = format!("http://{addr}/api/elections/{election_id}/status"); - let response = Client::new().get(&url).send().await.unwrap(); + let cookie = typist_login(addr).await; + let response = Client::new() + .get(&url) + .header("cookie", cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body: ElectionStatusResponse = response.json().await.unwrap(); assert_eq!( @@ -171,27 +202,89 @@ async fn check_data_entry_status_is_definitive( ); } -pub async fn create_result(addr: &SocketAddr, polling_station_id: u32, election_id: u32) { - create_and_finalise_data_entry(addr, polling_station_id, 1).await; - create_and_finalise_data_entry(addr, polling_station_id, 2).await; +pub async fn create_result( + addr: &SocketAddr, + cookie: HeaderValue, + polling_station_id: u32, + election_id: u32, +) { + create_and_finalise_data_entry(addr, cookie.clone(), polling_station_id, 1).await; + create_and_finalise_data_entry(addr, cookie, polling_station_id, 2).await; check_data_entry_status_is_definitive(addr, polling_station_id, election_id).await; } pub async fn create_result_with_non_example_data_entry( addr: &SocketAddr, + cookie: HeaderValue, polling_station_id: u32, election_id: u32, data_entry: DataEntry, ) { - create_and_finalise_non_example_data_entry(addr, polling_station_id, 1, data_entry.clone()) - .await; - create_and_finalise_non_example_data_entry(addr, polling_station_id, 2, data_entry.clone()) - .await; + create_and_finalise_non_example_data_entry( + addr, + cookie.clone(), + polling_station_id, + 1, + data_entry.clone(), + ) + .await; + create_and_finalise_non_example_data_entry( + addr, + cookie, + polling_station_id, + 2, + data_entry.clone(), + ) + .await; check_data_entry_status_is_definitive(addr, polling_station_id, election_id).await; } -/// Calls the login endpoint and returns the session cookie -pub async fn login(addr: &SocketAddr) -> Option { +/// Calls the login endpoint for an Admin user and returns the session cookie +pub async fn admin_login(addr: &SocketAddr) -> HeaderValue { + let url = format!("http://{addr}/api/user/login"); + + let response = reqwest::Client::new() + .post(&url) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "username": "admin", + "password": "AdminPassword01", + }) + .to_string(), + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + response.headers().get("set-cookie").cloned().unwrap() +} + +/// Calls the login endpoint for a Coordinator user and returns the session cookie +pub async fn coordinator_login(addr: &SocketAddr) -> HeaderValue { + let url = format!("http://{addr}/api/user/login"); + + let response = reqwest::Client::new() + .post(&url) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "username": "coordinator", + "password": "CoordinatorPassword01", + }) + .to_string(), + )) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + response.headers().get("set-cookie").cloned().unwrap() +} + +/// Calls the login endpoint for a Typist user and returns the session cookie +pub async fn typist_login(addr: &SocketAddr) -> HeaderValue { let url = format!("http://{addr}/api/user/login"); let response = reqwest::Client::new() @@ -199,8 +292,8 @@ pub async fn login(addr: &SocketAddr) -> Option { .header(CONTENT_TYPE, "application/json") .body(Body::from( json!({ - "username": "user", - "password": "password", + "username": "typist", + "password": "TypistPassword01", }) .to_string(), )) @@ -209,5 +302,5 @@ pub async fn login(addr: &SocketAddr) -> Option { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - response.headers().get("set-cookie").cloned() + response.headers().get("set-cookie").cloned().unwrap() } diff --git a/backend/tests/user_integration_test.rs b/backend/tests/user_integration_test.rs index cc8e7fa7f..3be49de51 100644 --- a/backend/tests/user_integration_test.rs +++ b/backend/tests/user_integration_test.rs @@ -1,9 +1,8 @@ #![cfg(test)] use abacus::authentication::UserListResponse; -use hyper::{header::CONTENT_TYPE, StatusCode}; -use reqwest::Body; -use serde_json::{json, Value}; +use hyper::StatusCode; +use serde_json::{Value, json}; use sqlx::SqlitePool; use test_log::test; use utils::serve_api; @@ -14,38 +13,27 @@ pub mod utils; async fn test_user_last_activity_at_updating(pool: SqlitePool) { // Assert the user has no last activity timestamp yet let addr = serve_api(pool).await; + let admin_cookie = shared::admin_login(&addr).await; let url = format!("http://{addr}/api/user"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body: UserListResponse = response.json().await.unwrap(); - let user = body.users.first().unwrap(); - assert!(user.last_activity_at().is_none()); - - // Login, so we can call the whoami endpoint - let url = format!("http://{addr}/api/user/login"); let response = reqwest::Client::new() - .post(&url) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - json!({ - "username": "user", - "password": "password", - }) - .to_string(), - )) + .get(&url) + .header("cookie", admin_cookie.clone()) .send() .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let body: UserListResponse = response.json().await.unwrap(); + let typist_user = body.users.iter().find(|u| u.id() == 2).unwrap(); + assert!(typist_user.last_activity_at().is_none()); - let cookie = shared::login(&addr).await.unwrap(); + // Log in as the typist and call whoami to trigger an update + let typist_cookie = shared::typist_login(&addr).await; // Call an endpoint using the `FromRequestParts` for `User` let url = format!("http://{addr}/api/user/whoami"); let response = reqwest::Client::new() .get(&url) - .header("cookie", &cookie) + .header("cookie", &typist_cookie) .send() .await .unwrap(); @@ -53,7 +41,12 @@ async fn test_user_last_activity_at_updating(pool: SqlitePool) { // Test that a timestamp is present let url = format!("http://{addr}/api/user"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body: UserListResponse = response.json().await.unwrap(); let user = body.users.first().unwrap(); @@ -63,9 +56,15 @@ async fn test_user_last_activity_at_updating(pool: SqlitePool) { #[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_user_listing(pool: SqlitePool) { let addr = serve_api(pool).await; + let admin_cookie = shared::admin_login(&addr).await; let url = format!("http://{addr}/api/user"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); assert_eq!( response.status(), @@ -73,14 +72,19 @@ async fn test_user_listing(pool: SqlitePool) { "Unexpected response status" ); let body: UserListResponse = response.json().await.unwrap(); - assert_eq!(body.users.len(), 1); - assert!(body.users.iter().any(|ps| ps.username() == "user")) + assert_eq!(body.users.len(), 3); + assert!(body.users.iter().any(|ps| { + ["admin", "coordinator", "typist"] + .iter() + .any(|u| ps.username() == *u) + })) } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_user_creation(pool: SqlitePool) { let addr = serve_api(pool).await; let url = format!("http://{addr}/api/user"); + let admin_cookie = shared::admin_login(&addr).await; let response = reqwest::Client::new() .post(&url) @@ -90,6 +94,7 @@ async fn test_user_creation(pool: SqlitePool) { "fullname": "fullname", "temp_password": "MyLongPassword13" })) + .header("cookie", admin_cookie) .send() .await .unwrap(); @@ -101,7 +106,6 @@ async fn test_user_creation(pool: SqlitePool) { ); let body: Value = response.json().await.unwrap(); - dbg!(&body); assert_eq!(body["role"], "administrator"); assert_eq!(body["username"], "username"); @@ -109,10 +113,56 @@ async fn test_user_creation(pool: SqlitePool) { assert!(body.get("temp_password").is_none()); } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_user_creation_duplicate_username(pool: SqlitePool) { + let addr = serve_api(pool).await; + let url = format!("http://{addr}/api/user"); + let admin_cookie = shared::admin_login(&addr).await; + + let response = reqwest::Client::new() + .post(&url) + .json(&json!({ + "role": "administrator", + "username": "username", + "fullname": "fullname", + "temp_password": "MyLongPassword13" + })) + .header("cookie", admin_cookie.clone()) + .send() + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::CREATED, + "Unexpected response status" + ); + + let response = reqwest::Client::new() + .post(&url) + .json(&json!({ + "role": "administrator", + "username": "Username", + "fullname": "fullname", + "temp_password": "MyLongPassword13" + })) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "Unexpected response status" + ); +} + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_user_creation_anonymous(pool: SqlitePool) { let addr = serve_api(pool).await; let url = format!("http://{addr}/api/user"); + let admin_cookie = shared::admin_login(&addr).await; let response = reqwest::Client::new() .post(&url) @@ -121,6 +171,7 @@ async fn test_user_creation_anonymous(pool: SqlitePool) { "username": "username", "temp_password": "MyLongPassword13" })) + .header("cookie", admin_cookie) .send() .await .unwrap(); @@ -132,7 +183,6 @@ async fn test_user_creation_anonymous(pool: SqlitePool) { ); let body: Value = response.json().await.unwrap(); - dbg!(&body); assert_eq!(body["role"], "typist"); assert_eq!(body["username"], "username"); @@ -140,30 +190,129 @@ async fn test_user_creation_anonymous(pool: SqlitePool) { assert!(body.get("temp_password").is_none()); } +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_user_creation_invalid_password(pool: SqlitePool) { + let addr = serve_api(pool).await; + let admin_cookie = shared::admin_login(&addr).await; + let url = format!("http://{addr}/api/user"); + + let response = reqwest::Client::new() + .post(&url) + .json(&json!({ + "role": "typist", + "username": "username", + "temp_password": "too_short" + })) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_user_update_password_invalid(pool: SqlitePool) { + let addr = serve_api(pool).await; + let admin_cookie = shared::admin_login(&addr).await; + let url = format!("http://{addr}/api/user/2"); + + let response = reqwest::Client::new() + .put(&url) + .json(&json!({ + "temp_password": "too_short" + })) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_user_change_to_same_password_fails(pool: SqlitePool) { + let addr = serve_api(pool).await; + let typist_cookie = shared::typist_login(&addr).await; + let url = format!("http://{addr}/api/user/account"); + + let response = reqwest::Client::new() + .put(&url) + .json(&json!({ + "username": "typist", + "password": "TypistPassword01", + })) + .header("cookie", typist_cookie) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_user_get(pool: SqlitePool) { let addr = serve_api(pool).await; let url = format!("http://{addr}/api/user/1"); + let admin_cookie = shared::admin_login(&addr).await; - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new() + .get(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body: Value = response.json().await.unwrap(); - dbg!(&body); assert_eq!(body["id"], 1); assert_eq!(body["role"], "administrator"); - assert_eq!(body["username"], "user"); + assert_eq!(body["username"], "admin"); assert_eq!(body["fullname"], "Sanne Molenaar"); } -#[test(sqlx::test)] +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] async fn test_user_get_not_found(pool: SqlitePool) { let addr = serve_api(pool).await; let url = format!("http://{addr}/api/user/40404"); + let admin_cookie = shared::admin_login(&addr).await; + + let response = reqwest::Client::new() + .get(&url) + .header("cookie", admin_cookie) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_user_delete(pool: SqlitePool) { + let addr = serve_api(pool).await; + let url = format!("http://{addr}/api/user/1"); - let response = reqwest::Client::new().get(&url).send().await.unwrap(); + let response = reqwest::Client::new().delete(&url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = reqwest::Client::new().delete(&url).send().await.unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } + +#[test(sqlx::test(fixtures(path = "../fixtures", scripts("users"))))] +async fn test_can_delete_logged_in_user(pool: SqlitePool) { + let addr = serve_api(pool).await; + let url = format!("http://{addr}/api/user/2"); + shared::typist_login(&addr).await; + let admin_cookie = shared::admin_login(&addr).await; + + let response = reqwest::Client::new() + .delete(&url) + .header("cookie", &admin_cookie) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..8e0409bba --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +codecov: + notify: + after_n_builds: 2 +comment: + require_bundle_changes: True + bundle_change_threshold: "50Kb" +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/documentatie/functionaliteit/opties.md b/documentatie/functionaliteit/opties.md new file mode 100644 index 000000000..74fa81001 --- /dev/null +++ b/documentatie/functionaliteit/opties.md @@ -0,0 +1,16 @@ + +# Lijst met opties voor Abacus + +Deze lijst heeft tot doel het verzamelen van opties en mogelijkheden voor Abacus, bijvoorbeeld vanuit tests en gesprekken met gebruikers. + +## Gebruikersinterface + +### Statusoverzicht steminvoer + +- Sorteren op stembureaunummer +- Numerieke aantallen per stap anders sorteren: fouten en meldingen bovenaan, verder op volgorde van het werkproces +- Eenvoudig voortgangsscherm om de stand van zaken op een beamer of monitor te laten zien, zodat iedereen dat makkelijk kan volgen + +### Import/export + +- Import van CSV met bezwaren en andere gegevens diff --git a/documentatie/functionaliteit/planning-2025.md b/documentatie/functionaliteit/planning-2025.md index e712f1663..97e820780 100644 --- a/documentatie/functionaliteit/planning-2025.md +++ b/documentatie/functionaliteit/planning-2025.md @@ -2,68 +2,77 @@ ## oktober-december 2024 -- [M] De applicatie heeft onderdelen voor de rollen beheerder, coördinator en invoerder. -- [S] Bij fouten in de frontend verschijnt een nette foutpagina. -- [S] De frontend is bereikbaar vanuit de backend: de applicatie kan als één geheel gedraaid worden. -- [M] De applicatie wordt voorbereid op het vertalen van de interface (i18n). -- [L] De beheerder kan stembureaus aanmaken, bekijken, bewerken en verwijderen. -- [M] De coördinator heeft inzicht in de status en voortgang van het invoerproces, zonder live updates. -- [L] De tellingsresultaten kunnen als EML_NL-bestand (510b) worden geëxporteerd. -- [M] De invoer kan worden hervat na een onderbreking. -- [S] De invoerder kan de tweede invoer doen, en de tweede invoer wordt definitief gemaakt als deze volledig overeenkomt met de eerste invoer. +- [M] De applicatie heeft onderdelen voor de rollen beheerder, coördinator en invoerder. - [#397](https://github.com/kiesraad/abacus/issues/397) +- [S] Bij fouten in de frontend verschijnt een nette foutpagina. - [#398](https://github.com/kiesraad/abacus/issues/398) +- [S] De frontend is bereikbaar vanuit de backend: de applicatie kan als één geheel gedraaid worden. - [#63](https://github.com/kiesraad/abacus/issues/63) +- [M] De applicatie wordt voorbereid op het vertalen van de interface (i18n). - [#45](https://github.com/kiesraad/abacus/issues/45) +- [L] De beheerder kan stembureaus aanmaken, bekijken, bewerken en verwijderen. - [#396](https://github.com/kiesraad/abacus/issues/396) +- [M] De coördinator heeft inzicht in de status en voortgang van het invoerproces, zonder live updates. - [#399](https://github.com/kiesraad/abacus/issues/399) +- [L] De tellingsresultaten kunnen als EML_NL-bestand (510b) worden geëxporteerd. - [#546](https://github.com/kiesraad/abacus/issues/546) +- [M] De invoer kan worden hervat na een onderbreking. - [#137](https://github.com/kiesraad/abacus/issues/137) +- [S] De invoerder kan de tweede invoer doen, en de tweede invoer wordt definitief gemaakt als deze volledig overeenkomt met de eerste invoer. - [#129](https://github.com/kiesraad/abacus/issues/129) ## januari-februari 2025 -- _Frontend refactoring, nog uit te werken_ -- _Implementatie state machine in backend_ +- Frontend refactoring - [#838](https://github.com/kiesraad/abacus/issues/838) +- Implementatie state machine in backend - [#652](https://github.com/kiesraad/abacus/issues/652) -- Gebruikers kunnen inloggen met gebruikersnaam en wachtwoord (authenticatie/authn). -- Gebruikers hebben een van de drie rollen beheerder, coördinator of invoerder (autorisatie/authz). -- Gebruikers kunnen worden beheerd door de beheerder/coördinator. -- Er vindt logging van gebruikershandelingen plaats. +- Gebruikers kunnen inloggen met gebruikersnaam en wachtwoord (authenticatie/authn). - [#673](https://github.com/kiesraad/abacus/issues/673) +- Gebruikers hebben een van de drie rollen beheerder, coördinator of invoerder (autorisatie/authz). - [#676](https://github.com/kiesraad/abacus/issues/676) +- Gebruikers kunnen worden beheerd door de beheerder/coördinator. - [#792](https://github.com/kiesraad/abacus/issues/792) +- Er vindt logging van gebruikershandelingen plaats. - [#793](https://github.com/kiesraad/abacus/issues/793) -- De zetelverdeling voor de gemeenteraad wordt uitgerekend voor de meest voorkomende gevallen (meest voorkomende scenario, bijv. geen loting), en wordt weergegeven op een placeholder-pagina. -- De toewijzing van kandidaten aan de zetels kan worden bepaald (meest voorkomende scenario, bijv. nog geen registratie van overleden kandidaten). +- De zetelverdeling voor de gemeenteraad wordt uitgerekend voor de meest voorkomende gevallen (meest voorkomende scenario, bijv. geen loting) - [#545](https://github.com/kiesraad/abacus/issues/545) +- [L] De toewijzing van kandidaten aan de zetels kan worden bepaald (meest voorkomende scenario, bijv. nog geen registratie van overleden kandidaten). - [#787](https://github.com/kiesraad/abacus/issues/787) -- De tweede invoerder is verplicht een andere gebruiker dan de eerste invoerder. -- Er kan slechts één gebruiker tegelijkertijd hetzelfde stembureau invoeren. +## maart-april 2025 -- Het EML_NL-bestand met de verkiezingsdefinitie voor de gemeenteraadsverkiezing kan worden geïmporteerd. -- Het EML_NL-bestand met de kandidatenlijst voor de gemeenteraadsverkiezing kan worden geïmporteerd. +_Een deel moet nog worden doorgeschoven naar latere iteraties._ -- De coördinator kan de eerste zitting openen, schorsen en afronden. +- [S] Er kan slechts één gebruiker tegelijkertijd hetzelfde stembureau invoeren. - [#705](https://github.com/kiesraad/abacus/issues/705) +- [S] De tweede invoerder is verplicht een andere gebruiker dan de eerste invoerder. - [#698](https://github.com/kiesraad/abacus/issues/698) -## maart-april 2025 +- [M] Basis in de backend voor alle EML_NL formaten opzetten - [#1094](https://github.com/kiesraad/abacus/issues/1094) +- [M] Het EML_NL-bestand met de verkiezingsdefinitie voor de gemeenteraadsverkiezing kan worden geïmporteerd. - [#700](https://github.com/kiesraad/abacus/issues/700) +- [M] Het EML_NL-bestand met de kandidatenlijst voor de gemeenteraadsverkiezing kan worden geïmporteerd. - [#794](https://github.com/kiesraad/abacus/issues/794) -- _Nieuwe modellen implementeren?_ +- [?] De coördinator kan de eerste zitting openen, schorsen en afronden. - [#795](https://github.com/kiesraad/abacus/issues/795) + - _Eerst use cases uitwerken_ -- Invoer van lotingsresultaten tijdens de zetelverdeling. -- Het markeren van overleden kandidaten voor het starten van de zetelverdeling. -- De zetelverdeling kan als EML_NL-bestand (520) worden geëxporteerd. +- [XL] Nieuwe modellen implementeren - [#1063](https://github.com/kiesraad/abacus/issues/1063) + - _Nog opsplitsen_ -- Verschillen tussen de eerste en tweede invoer worden weergegeven en kunnen worden opgelost. -- Ondersteuning voor het invoeren in gemeenten met een decentrale stemopneming (DSO). +- [L] Invoer van lotingsresultaten tijdens de zetelverdeling. - [#788](https://github.com/kiesraad/abacus/issues/788) +- [L] Het markeren van overleden kandidaten voor de toewijzing van kandidaten. - [#797](https://github.com/kiesraad/abacus/issues/797) +- [M] De zetelverdeling kan als EML_NL-bestand (520) worden geëxporteerd. - [#802](https://github.com/kiesraad/abacus/issues/802) -- De uitslag kan in een tweede of volgende zitting worden gecorrigeerd met behulp van een corrigendum. - - _uitsplitsen in meerdere punten?_ +- [M] Verschillen tussen de eerste en tweede invoer worden weergegeven en kunnen worden opgelost door de coördinator. - [#130](https://github.com/kiesraad/abacus/issues/130) +- [XL] Ondersteuning voor het invoeren in gemeenten met een decentrale stemopneming (DSO). - [#798](https://github.com/kiesraad/abacus/issues/798) + - _Nog opsplitsen: invoer DSO PV, output GSB PV, ..._ -- Het EML_NL-bestand met de stembureaus van een gemeente kan worden geïmporteerd. -- Het EML_NL-bestand met de stembureaus van een gemeente kan worden geëxporteerd. +- [M] De tweede invoerder krijgt een waarschuwing te zien voor verschillen met de eerste invoer. - [#1095](https://github.com/kiesraad/abacus/issues/1095) -## mei-juni 2025 +- [XL] De uitslag kan in een tweede of volgende zitting worden gecorrigeerd met behulp van een corrigendum. - [#1109](https://github.com/kiesraad/abacus/issues/1109) + - _Nog opsplitsen: template stembureau PV, output corrigendum PV voor GSB_ -- De Abacus-server kan de afwezigheid van een internetverbinding detecteren (airgap-detectie). -- De Abacus-clients kunnen de afwezigheid van een internetverbinding detecteren (airgap-detectie). +- [M] Het EML_NL-bestand (110b) met de stembureaus van een gemeente kan worden geïmporteerd. - [#800](https://github.com/kiesraad/abacus/issues/800) +- [S] Het EML_NL-bestand (110b) met de stembureaus van een gemeente kan worden geëxporteerd. - [#801](https://github.com/kiesraad/abacus/issues/801) + +- [S] Code signing van Windows executables - [#1068](https://github.com/kiesraad/abacus/issues/1068) +- Gebruikersdocumentatie publiceren naar een meer gebruiksvriendelijke locatie - [#924](https://github.com/kiesraad/abacus/issues/924) +- Backend gebruikersstroom testen - [#796](https://github.com/kiesraad/abacus/issues/796) + +## mei-juni 2025 -- Installatieprogramma (installer) voor Windows. - - code signing? -- Packaging voor Linux-distributies (Debian/Ubuntu). +- De Abacus-server kan de afwezigheid van een internetverbinding detecteren (airgap-detectie). - [#1066](https://github.com/kiesraad/abacus/issues/1066) +- De Abacus-clients kunnen de afwezigheid van een internetverbinding detecteren (airgap-detectie). - [#1067](https://github.com/kiesraad/abacus/issues/1067) -- De processen-verbaal kunnen worden gegenereerd in het Nederlands en Fries. +- Installatieprogramma (installer) voor Windows. - [#1096](https://github.com/kiesraad/abacus/issues/1096) +- Packaging voor Linux-distributies (Debian/Ubuntu). - [#1069](https://github.com/kiesraad/abacus/issues/1069) ## juli-augustus 2025 -- _Verschillen kunnen worden opgelost door nieuwe invoer op lijstniveau. [?]_ +- Verschillen kunnen worden opgelost door nieuwe invoer op lijstniveau. - [#1098](https://github.com/kiesraad/abacus/issues/1098) -- Bezwaren en bijzonderheden kunnen worden ingevuld tijdens het invoeren van de GSB-zitting. -- Bezwaren en bijzonderheden kunnen per stembureau worden ingevoerd (bij CSO). +- Bezwaren en bijzonderheden kunnen worden ingevuld tijdens het invoeren van de GSB-zitting. - [#803](https://github.com/kiesraad/abacus/issues/803) +- Bezwaren en bijzonderheden kunnen per stembureau worden ingevoerd (bij CSO). - [#799](https://github.com/kiesraad/abacus/issues/799) diff --git a/documentatie/functionaliteit/versie-1.0-gr2026.md b/documentatie/functionaliteit/versie-1.0-gr2026.md index 003543427..9e54a907e 100644 --- a/documentatie/functionaliteit/versie-1.0-gr2026.md +++ b/documentatie/functionaliteit/versie-1.0-gr2026.md @@ -16,36 +16,37 @@ - Er is een installatieprogramma/-instructie voor de Abacus-server. - Het EML_NL-bestand met de verkiezingsdefinitie voor de betreffende verkiezing kan worden geïmporteerd. - Het EML_NL-bestand met de kandidatenlijst voor de betreffende verkiezing kan worden geïmporteerd. -- Het EML_NL-bestand met de stembureaus van een gemeente kan worden geïmporteerd. -- De beheerder kan stembureaus aanmaken. +- Het EML_NL-bestand met de stembureaus van een gemeente kan worden geïmporteerd en geëxporteerd. +- De beheerder kan stembureaus aanmaken, aanpassen, verwijderen. -### Invoeren van resultaten GSB +### Invoeren van uitslagen GSB - Invoerders kunnen uitslagen van stembureaus invoeren voor DSO en CSO. +- Invoerders kunnen het stembureaucorrigendum voor DSO invoeren. - Uitslagen worden ingevoerd volgens het vierogenprincipe. Dit betekent dat ze twee keer worden ingevoerd door twee verschillende invoerders. -- Verschillen tussen de twee invoeren worden opgelost door de coördinator. -- Validatie en consistentiechecks (controleprotocol) worden uitgevoerd op de ingevoerde tellingen van stembureaus. +- Verschillen tussen de twee invoeren oplossen: de coördinator kiest een van beide invoeren of laat dubbel herinvoeren. +- Validatie, consistentiechecks en controleprotocol opmerkelijke uitslagen worden uitgevoerd op de ingevoerde tellingen van stembureaus. ### Uitslagbepaling GSB - De uitslagen per stembureau kunnen worden opgeteld en dit leidt vervolgens tot de uitslag van het gemeentelijk stembureau (GSB). - De uitslagen kunnen worden geëxporteerd als XML-bestand volgens de EML_NL-standaard. - De uitslag van het GSB wordt gegenereerd als proces-verbaal in PDF-formaat. -- De processen-verbaal kunnen worden gegenereerd in het Nederlands en Fries. +- De processen-verbaal kunnen worden gegenereerd aan de hand van de vastgestelde modellen. - De uitslag kan na de eerste zitting worden gecorrigeerd met behulp van een corrigendum. -### Invoeren van resultaten CSB +### Invoeren van uitslagen CSB -- De uitslag van het GSB kan worden geïmporteerd als EML_NL-bestand. +- De uitslag van het GSB kan worden geïmporteerd als XML-bestand volgens de EML_NL-standaard. - Uitslagen worden ingevoerd volgens het vierogenprincipe. Dit betekent dat ze twee keer worden ingevoerd door twee verschillende invoerders. Een import van een EML_NL-bestand telt ook als invoer, waardoor nog maar één handmatige invoer nodig is. -- Verschillen tussen de twee invoeren worden opgelost door de coördinator. +- Verschillen tussen de twee invoeren oplossen: de coördinator kiest de handmatige invoer of laat dubbel herinvoeren. ### Uitslagbepaling CSB -- De zetelverdeling voor de gemeenteraad wordt uitgerekend. +- De zetelverdeling voor de gemeenteraad wordt uitgerekend en de zetels worden aan kandidaten toegewezen. - De uitslagen kunnen worden geëxporteerd als XML-bestand volgens de EML_NL-standaard. - De uitslag van het CSB wordt gegenereerd als proces-verbaal in PDF-formaat. -- De processen-verbaal kunnen worden gegenereerd in het Nederlands en Fries. +- De processen-verbaal kunnen worden gegenereerd aan de hand van de vastgestelde modellen. ### Ondersteunende functies @@ -59,10 +60,8 @@ - De Abacus-clients kunnen de afwezigheid van een internetverbinding detecteren (airgapdetectie). - Er vindt logging van gebruikershandelingen plaats. -- Het PDF-bestand van het proces-verbaal voldoen aan de WCAG-toegankelijkheidseisen. -- Verschillen kunnen worden opgelost door nieuwe invoer op lijstniveau. - Bijhouden van statistieken over gebruik. -- Aanmaken van geloofsbrieven. +- Aanmaken van benoemingsbrieven en kennisgevingen die strekken tot geloofsbrief - De coördinator GSB kan invoerders van het GSB beheren, en de coördinator CSB kan invoerders van het CSB beheren. @@ -71,11 +70,13 @@ *Deze eisen zullen alleen aan bod komen als er tijd genoeg is.* - De uitslag GSB wordt voorzien van een cryptografische handtekening. +- Het PDF-bestand van het proces-verbaal voldoet aan de WCAG-toegankelijkheidseisen. - Invoer van bezwaren en bijzonderheden per stembureau (afhankelijk van ontwikkeling modellen). +- Verschillen kunnen worden opgelost door nieuwe invoer op lijstniveau. - Beheer van werkplekken (aanvullen, design). - De software biedt ondersteuning voor meerdere verkiezingen tegelijkertijd. Dit is een vereiste die we sowieso zullen uitwerken na de GSB-fase, maar hier kan al in deze fase al een aanzet toe worden gedaan. -- De interface is beschikbaar in meerdere talen: Nederlands, Engels, Fries en Papiaments. -- Abacus is beschikbaar voor macOS. +- De interface is beschikbaar in meerdere talen: Nederlands, Engels, Fries en Papiaments, Nedersaksisch +- Abacus is officieel beschikbaar voor macOS. - Er is een installatieprogramma voor de Abacus-werkstations. ## Versie 1.0: niet binnen scope (won't have) diff --git a/documentatie/use-cases/autorisatiematrix.md b/documentatie/use-cases/autorisatiematrix.md index 84abd9023..423756b70 100644 --- a/documentatie/use-cases/autorisatiematrix.md +++ b/documentatie/use-cases/autorisatiematrix.md @@ -25,15 +25,15 @@ die de zetelverdeling vaststelt en daarvoor een proces-verbaal genereert. ## Rollen en rechten | Functionaliteit \ Rol | Beheerder | Coördinator GSB | Coördinator CSB | Invoerder GSB | Invoerder CSB | -|--------------------------------------------------|:---------:|:---------------:|:---------------:|:-------------:|:-------------:| +| ------------------------------------------------ | :-------: | :-------------: | :-------------: | :-----------: | :-----------: | | **Voorbereiding** | | | | | | | Applicatie installeren | X | | | | | | Verkiezing configureren | X | | | | | | Invoerstations beheren | X | | | | | -| Stembureaus beheren | X | X | | | | +| Stembureaus beheren [^1] | X | X | | | | | Gebruikers beheren: alle gebruikers | X | | | | | -| Gebruikers beheren: invoerders GSB [^1] | | X | | | | -| Gebruikers beheren: invoerders CSB [^1] | | | X | | | +| Gebruikers beheren: invoerders GSB [^2] | | X | | | | +| Gebruikers beheren: invoerders CSB [^2] | | | X | | | | **Tijdens de zitting GSB** | | | | | | | Een nieuwe zitting openen | | X | | | | | Invoer starten/schorsen/stoppen | | X | | | | @@ -53,4 +53,6 @@ die de zetelverdeling vaststelt en daarvoor een proces-verbaal genereert. | **Algemeen** | | | | | | | Logs raadplegen | X | X | X | | | -[^1]: Zeer gewenst (should have), initieel nog geen gebruikersbeheer voor coördinator. +[^1]: Stembureaus binnen de gemeente waar gestemd kan worden. Niet GSB(s) of CSB. + +[^2]: Zeer gewenst (should have), initieel nog geen gebruikersbeheer voor coördinator. diff --git a/documentatie/use-cases/beheerder.md b/documentatie/use-cases/beheerder.md index 6d235720d..af1570b20 100644 --- a/documentatie/use-cases/beheerder.md +++ b/documentatie/use-cases/beheerder.md @@ -7,13 +7,12 @@ _Niveau:_ hoog-over, wolk, ☁️ ### Hoofdscenario en uitbreidingen 1. [De beheerder installeert de applicatie.](#de-beheerder-installeert-de-applicatie-zee) -2. De beheerder leest de verkiezingsdefinitie in. +2. [De beheerder zet de verkiezingen in de applicatie.](#de-beheerder-zet-de-verkiezingen-in-de-applicatie-zee) 3. De beheerder leest de kandidatenlijst in. 4. [De beheerder zet de stembureaus in de applicatie.](#de-beheerder-zet-de-stembureaus-in-de-applicatie-zee) 5. De beheerder maakt de gebruikers aan. __Uitbreidingen:__ -2a. De applicatie geeft een foutmelding bij het inlezen van de verkiezingsdefinitie: 3a. De applicatie geeft een foutmelding bij het inlezen van de kandidatenlijst: @@ -55,6 +54,26 @@ __Uitbreidingen:__ - Downloaden van een sleutel o.i.d. voor afzenderverificatie ontbreekt nog, want nog geen beslissing over oplossing. +## De beheerder zet de verkiezingen in de applicatie (zee) + +__Niveau:__ gebruikersdoel, zee, 🌊 + +### Hoofdscenario en uitbreidingen + +__Hoofdscenario:__ +1. De beheerder leest de verkiezingsdefinitie in. +2. De beheerder stelt vast dat de hash van de verkiezingsdefinitie klopt. +3. De applicatie maakt op basis van de verkiezingsdefinitie de verkiezing GSB, de verkiezing CSB, en het GSB als stembureau voor het CSB aan. + +__Uitbreidingen:__ +1a. De applicatie geeft een foutmelding bij het inlezen van de verkiezingsdefinitie: + +2a. De hash van de verkiezingsdefinitie klopt niet. + +### Open punten +- Verder uitwerken hoe GSB en CSB apart aangemaakt worden. + + ## De beheerder zet de stembureaus in de applicatie (zee) __Niveau:__ gebruikersdoel, zee, 🌊 @@ -96,4 +115,20 @@ __Uitbreidingen:__ naar een andere verkiezing te kopiëren. Minder mooi alternatief is eerst exporteren en dan importeren. - Zodra invoer gestart is, mag het niet mogelijk zijn om stembureaus aan te passen of te verwijderen. Verwijderen wordt nu afgedwongen d.m.v. foreign keys in de database. Checks voor aanpassen en checks o.b.v. de fases van de verkiezing in de - applicatie moeten nog uitgewerkt worden. \ No newline at end of file + applicatie moeten nog uitgewerkt worden. + + +## De beheerder exporteert de stembureaus (zee) + +__Niveau:__ gebruikersdoel, zee, 🌊 + +### Hoofdscenario en uitbreidingen + +__Hoofdscenario__: + +1. De beheerder exporteert de stembureaus. +2. De beheerder slaat de geëxporteerde stembureaus op, zodat ze geïmporteerd kunnen worden bij een volgende verkiezing. + +### Open punten + +- Is dit eigenlijk de use case voor het opschonen van de gebruikte machines? diff --git a/documentatie/use-cases/csb-zitting.md b/documentatie/use-cases/csb-zitting.md index 54963019b..daed40e66 100644 --- a/documentatie/use-cases/csb-zitting.md +++ b/documentatie/use-cases/csb-zitting.md @@ -47,7 +47,6 @@ __Uitbreidingen:__ - Overzicht bijlages toevoegen? Komen niet uit de software. (P22-2) - ## Het CSB voert de tellingen van het GSB in (vlieger) __Niveau:__ hoog-over, vlieger, 🪁 @@ -92,8 +91,10 @@ __Uitbreidingen:__ 3a. Er zijn minder beschikbare zetels dan kandidaten met gelijke behaalde (voorkeur)stemmen:  3a1. De zetel wordt bij loting toegekend. -### Open punten +3b. Er zijn meer zetels aan een lijst toegekend dan dat er kandidaten op de lijst staan (lijstuitputting): +  3b1. De zetelverdeling wordt opnieuw berekend met inachtneming van de lijstuitputting. +  3b2. De kandidaten worden o.b.v. de nieuwe zetelverdeling aangewezen. -- De Kieswet heeft het pas over het buiten beschouwing laten van overleden kandidaten tijdens de toewijzing van de gekozen kandidaten. Wat als een lijst evenveel zetels krijgt als kandidaten, maar één van die kandidaten is overleden? -- Er is een voorgestelde wetswijziging dat lijsten de kiesdeler moeten halen om een restzetel te kunnen krijgen. De minister is voornemens de vragen in het verslag wetsvoorstel te beantwoorden na de gemeenteraadsverkiezingen van 2026. Deze wetswijziging gaat dus niet in vóór GR 2026. +### Buiten scope +- Er is een voorgestelde wetswijziging dat lijsten de kiesdeler moeten halen om een restzetel te kunnen krijgen. De minister is voornemens de vragen in het verslag wetsvoorstel te beantwoorden na de gemeenteraadsverkiezingen van 2026. Deze wetswijziging gaat dus niet in vóór GR 2026. diff --git a/documentatie/use-cases/gsb-eerste-zitting.md b/documentatie/use-cases/gsb-eerste-zitting.md index e61499465..9e8dcffb7 100644 --- a/documentatie/use-cases/gsb-eerste-zitting.md +++ b/documentatie/use-cases/gsb-eerste-zitting.md @@ -97,6 +97,36 @@ Nog op te stellen o.b.v. Uitbreidingen CSO. +## De coördinator GSB bewerkt de stembureaus tijdens de eerste of nieuwe zitting (zee) + +__Niveau:__ gebruikersdoel, zee, 🌊 + +### Hoofdscenario en uitbreidingen + +__Hoofdscenario 1:__ + +1. De coördinator GSB verneemt dat een stembureau niet open is gegaan. +2. De coördinator stelt vast dat het stembureau op de gepubliceerde lijst staat en in de applicatie staat. +3. De coördinator GSB verwijdert het stembureau. +4. De applicatie toont een waarschuwing dat elke aanpassing op een stembureau, waardoor die afwijkt van de gepubliceerde lijst, opgenomen moet worden in het PV. + +__Hoofdscenario 2:__ + +1. De coördinator GSB stelt vast dat de stembureaus in de applicatie niet kloppen met de gepubliceerde lijst. +2. De coördinator GSB corrigeert de stembureaus in de applicatie. +3. De applicatie toont een waarschuwing dat elke aanpassing op een stembureau, waardoor die afwijkt van de gepubliceerde lijst, opgenomen moet worden in het PV. + +### Niet in scope + +- Bij verwijderen stembureau kan de coördinator GSB de reden invoeren, die dan automatisch wordt opgenomen in het PV dat door de applicatie wordt gegenereerd. +- Het opnemen van andere bijzonderheden i.v.m. stembureaus in het PV, bijv. stembureau dat halverwege de dag werd gesloten. Dit is een andere use case en feature. + +### Open punten + +- Kan ook de beheerder tijdens een zitting de lijst met stembureaus corrigeren? + + + ## De coördinator voert bezwaren, bijzonderheden, etc. in. (zee) __Niveau:__ gebruikersdoel, zee, 🌊 @@ -116,11 +146,13 @@ __Hoofdscenario:__ __Uitbreidingen:__ -3a. De coördinator vult in: "zie bijlage". (ook voor 4) +3a. De coördinator vult in: "zie bijlage". + +4a. De coördinator vult in: "zie bijlage". ### Open punten -- Voert de coördinator de sectie "Nieuwe telling aantal toegelaten kiezers bij onverklaarde telverschillen" in? Of doet de coördinator dat? +- Voert de coördinator de sectie "Nieuwe telling aantal toegelaten kiezers bij onverklaarde telverschillen" in? Of doet de applicatie dat? - Nieuw model GSB PV heeft drie vinkjes: toegelaten kiezers opnieuw vastgesteld, onderzocht vanwege andere redenen, stembiljetten (deels) herteld. - De SB PVs verschillen hierin tussen DSO en CSO. - Als de applicatie dit moet doen, moeten de invoerders dit over kunnen nemen van het SB PV. diff --git a/documentatie/use-cases/gsb-invoer-eerste-zitting.md b/documentatie/use-cases/gsb-invoer-eerste-zitting.md index c3658509f..db20178b0 100644 --- a/documentatie/use-cases/gsb-invoer-eerste-zitting.md +++ b/documentatie/use-cases/gsb-invoer-eerste-zitting.md @@ -21,14 +21,22 @@ __Hoofdscenario:__ 5. De applicatie stelt vast dat er geen stembureaus met waarschuwingen zijn. __Uitbreidingen:__ + +2a. Tijdens invoer is er reden om de invoer (tijdelijk) te stoppen: +  2a1. De coördinator pauzeert de invoer. +  2a2. De applicatie blokkeert verdere invoer. + 4a. De applicatie stelt vast dat niet voor alle stembureaus resultaten zijn ingevoerd: 5a. De applicatie stelt vast dat er stembureaus met geaccepteerde waarschuwingen zijn: +### Niet in scope +- Verschillende fases in de applicatie, zoals inrichten, invoer, voorbereiden concept-PV. Reden hiervoor is dat we de coördinator niet willen beperken in wat deze wanneer kan doen. We zouden fases kunnen implementeren waartussen de coördinator vrij kan bewegen, maar dan is het gebruiksvriendelijker om bij bepaalde acties een waarschuwing te laten zien. De coördinator heeft wel de mogelijkheid om invoer open te zetten en te stoppen. Eventueel ook om invoer te pauzeren. + ### Open punten - Welke controles willen we nog nadat de invoer is afgesloten? Of zijn die controles onderdeel van het afsluiten? - +- Hoe ziet het stoppen/blokkeren van invoer er precies uit? ## De invoerders vullen de resultaten van de tellingen in (vlieger) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 444160687..c32030b0a 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -58,5 +58,12 @@ module.exports = { "@typescript-eslint/no-non-null-assertion": "off", }, }, + { + files: ["lib/ui/**/*.e2e.ts"], + rules: { + // Needed for Ladle, page.waitForSelector("[data-storyloaded]") + "playwright/no-wait-for-selector": "off", + }, + }, ], }; diff --git a/frontend/.gitignore b/frontend/.gitignore index 5baa3a0ad..a303fc3b8 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,3 +33,7 @@ playwright-report/ # Automatically created: https://github.com/mswjs/msw/discussions/1015#discussioncomment-1747585 mockServiceWorker.js + +# playwright state +e2e-tests/state/ +!e2e-tests/state/.gitkeep \ No newline at end of file diff --git a/frontend/app/component/apportionment/Apportionment.module.css b/frontend/app/component/apportionment/Apportionment.module.css index bc62897e3..44f825d0f 100644 --- a/frontend/app/component/apportionment/Apportionment.module.css +++ b/frontend/app/component/apportionment/Apportionment.module.css @@ -49,7 +49,7 @@ } } -.whole-seats-table { +.full-seats-table { tr { th { /* Signs (: and =) */ diff --git a/frontend/app/component/apportionment/ApportionmentTable.test.tsx b/frontend/app/component/apportionment/ApportionmentTable.test.tsx index d88e2f4e0..e5413d4ab 100644 --- a/frontend/app/component/apportionment/ApportionmentTable.test.tsx +++ b/frontend/app/component/apportionment/ApportionmentTable.test.tsx @@ -12,7 +12,7 @@ describe("ApportionmentTable", () => { , diff --git a/frontend/app/component/apportionment/ApportionmentTable.tsx b/frontend/app/component/apportionment/ApportionmentTable.tsx index 2ded593c9..0f82392a0 100644 --- a/frontend/app/component/apportionment/ApportionmentTable.tsx +++ b/frontend/app/component/apportionment/ApportionmentTable.tsx @@ -8,7 +8,7 @@ import cls from "./Apportionment.module.css"; interface ApportionmentTableProps { finalStanding: PoliticalGroupSeatAssignment[]; politicalGroups: PoliticalGroup[]; - wholeSeats: number; + fullSeats: number; residualSeats: number; seats: number; } @@ -23,7 +23,7 @@ function convert_zero_to_dash(number: number): string { export function ApportionmentTable({ finalStanding, politicalGroups, - wholeSeats, + fullSeats, residualSeats, seats, }: ApportionmentTableProps) { @@ -32,7 +32,7 @@ export function ApportionmentTable({ {t("list")} {t("list_name")} - {t("apportionment.whole_seat.plural")} + {t("apportionment.full_seat.plural")} {t("apportionment.residual_seat.plural")} {t("apportionment.total_seats")} @@ -46,7 +46,7 @@ export function ApportionmentTable({ {politicalGroups[standing.pg_number - 1]?.name || ""} - {convert_zero_to_dash(standing.whole_seats)} + {convert_zero_to_dash(standing.full_seats)} {convert_zero_to_dash(standing.residual_seats)} @@ -58,7 +58,7 @@ export function ApportionmentTable({ {t("apportionment.total")} - {wholeSeats} + {fullSeats} {residualSeats} {seats} diff --git a/frontend/app/component/apportionment/WholeSeatsTable.test.tsx b/frontend/app/component/apportionment/FullSeatsTable.test.tsx similarity index 83% rename from frontend/app/component/apportionment/WholeSeatsTable.test.tsx rename to frontend/app/component/apportionment/FullSeatsTable.test.tsx index b7a9ede02..41027dc8c 100644 --- a/frontend/app/component/apportionment/WholeSeatsTable.test.tsx +++ b/frontend/app/component/apportionment/FullSeatsTable.test.tsx @@ -2,13 +2,13 @@ import { describe, expect, test } from "vitest"; import { render, screen } from "@kiesraad/test"; +import { FullSeatsTable } from "./FullSeatsTable"; import { apportionment, election } from "./test-data/19-or-more-seats"; -import { WholeSeatsTable } from "./WholeSeatsTable"; -describe("WholeSeatsTable", () => { - test("renders a table with the whole seats assignment", async () => { +describe("FullSeatsTable", () => { + test("renders a table with the full seats assignment", async () => { render( - + {t("list")} {t("list_name")} @@ -23,7 +23,7 @@ export function WholeSeatsTable({ finalStanding, politicalGroups, quota }: Whole {t("apportionment.quota")} = - {t("apportionment.whole_seats_count")} + {t("apportionment.full_seats_count")} {finalStanding.map((standing: PoliticalGroupSeatAssignment) => { @@ -35,7 +35,7 @@ export function WholeSeatsTable({ finalStanding, politicalGroups, quota }: Whole : {quota} = - {standing.whole_seats} + {standing.full_seats} ); })} diff --git a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.test.tsx b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.test.tsx index 2052f0a25..fc8b37352 100644 --- a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.test.tsx +++ b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.test.tsx @@ -10,7 +10,7 @@ describe("LargestAveragesFor19OrMoreSeatsTable", () => { test("renders a table with the residual seat allocation with largest averages system for 19 or more seats", async () => { render( , diff --git a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx index 0fc3b9692..b4a2aa8e6 100644 --- a/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx +++ b/frontend/app/component/apportionment/LargestAveragesFor19OrMoreSeatsTable.tsx @@ -1,4 +1,9 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + LargestAverageAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; @@ -6,13 +11,13 @@ import { cn } from "@kiesraad/util"; import cls from "./Apportionment.module.css"; interface LargestAveragesFor19OrMoreSeatsTableProps { - highestAverageSteps: ApportionmentStep[]; + largestAverageSteps: ApportionmentStep[]; finalStanding: PoliticalGroupSeatAssignment[]; politicalGroups: PoliticalGroup[]; } export function LargestAveragesFor19OrMoreSeatsTable({ - highestAverageSteps, + largestAverageSteps, finalStanding, politicalGroups, }: LargestAveragesFor19OrMoreSeatsTableProps) { @@ -25,7 +30,7 @@ export function LargestAveragesFor19OrMoreSeatsTable({ {t("list")} {t("list_name")} - {highestAverageSteps.map((step: ApportionmentStep) => { + {largestAverageSteps.map((step: ApportionmentStep) => { return ( {t("apportionment.residual_seat.singular")} {step.residual_seat_number} @@ -46,14 +51,15 @@ export function LargestAveragesFor19OrMoreSeatsTable({ {politicalGroups[pg_seat_assignment.pg_number - 1]?.name || ""} - {highestAverageSteps.map((step: ApportionmentStep) => { + {largestAverageSteps.map((step: ApportionmentStep) => { + const change = step.change as LargestAverageAssignedSeat; const average = step.standing[pg_seat_assignment.pg_number - 1]?.next_votes_per_seat; if (average) { return ( {average} @@ -72,11 +78,14 @@ export function LargestAveragesFor19OrMoreSeatsTable({ {t("apportionment.residual_seat_assigned_to_list")} - {highestAverageSteps.map((step: ApportionmentStep) => ( - - {step.change.selected_pg_number} - - ))} + {largestAverageSteps.map((step: ApportionmentStep) => { + const change = step.change as LargestAverageAssignedSeat; + return ( + + {change.selected_pg_number} + + ); + })} diff --git a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.test.tsx b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.test.tsx index ac53e719c..75a5c8eb0 100644 --- a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.test.tsx +++ b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.test.tsx @@ -4,13 +4,13 @@ import { PoliticalGroup } from "@kiesraad/api"; import { render, screen } from "@kiesraad/test"; import { LargestAveragesForLessThan19SeatsTable } from "./LargestAveragesForLessThan19SeatsTable"; -import { apportionment, election, highest_average_steps } from "./test-data/less-than-19-seats"; +import { apportionment, election, largest_average_steps } from "./test-data/less-than-19-seats"; describe("LargestAveragesForLessThan19SeatsTable", () => { test("renders a table with the residual seat allocation with largest averages system for less than 19 seats", async () => { render( , diff --git a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx index 42702980c..05786ee31 100644 --- a/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx +++ b/frontend/app/component/apportionment/LargestAveragesForLessThan19SeatsTable.tsx @@ -1,4 +1,9 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + LargestAverageAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; @@ -6,13 +11,13 @@ import { cn } from "@kiesraad/util"; import cls from "./Apportionment.module.css"; interface LargestAveragesForLessThan19SeatsTableProps { - highestAverageSteps: ApportionmentStep[]; + largestAverageSteps: ApportionmentStep[]; finalStanding: PoliticalGroupSeatAssignment[]; politicalGroups: PoliticalGroup[]; } export function LargestAveragesForLessThan19SeatsTable({ - highestAverageSteps, + largestAverageSteps, finalStanding, politicalGroups, }: LargestAveragesForLessThan19SeatsTableProps) { @@ -20,8 +25,8 @@ export function LargestAveragesForLessThan19SeatsTable({
{t("list")} - {t("list_name")} - {t("apportionment.whole_seats_count")} + {t("list_name")} + {t("apportionment.full_seats_count")} {t("apportionment.average")} @@ -29,21 +34,20 @@ export function LargestAveragesForLessThan19SeatsTable({ {finalStanding.map((pg_seat_assignment) => { - const average = highestAverageSteps[0]?.standing[pg_seat_assignment.pg_number - 1]?.next_votes_per_seat; - const residual_seats = highestAverageSteps.filter( - (step) => step.change.selected_pg_number == pg_seat_assignment.pg_number, - ).length; + const average = largestAverageSteps[0]?.standing[pg_seat_assignment.pg_number - 1]?.next_votes_per_seat; + const residual_seats = largestAverageSteps.filter((step) => { + const change = step.change as LargestAverageAssignedSeat; + return change.selected_pg_number == pg_seat_assignment.pg_number; + }).length; return ( {pg_seat_assignment.pg_number} {politicalGroups[pg_seat_assignment.pg_number - 1]?.name || ""} - {pg_seat_assignment.whole_seats} + {pg_seat_assignment.full_seats} {average && ( - 0 ? "bg-yellow bold" : undefined}`} - > + 0 ? "bg-yellow bold" : undefined}> {average} )} diff --git a/frontend/app/component/apportionment/LargestSurplusesTable.test.tsx b/frontend/app/component/apportionment/LargestRemaindersTable.test.tsx similarity index 67% rename from frontend/app/component/apportionment/LargestSurplusesTable.test.tsx rename to frontend/app/component/apportionment/LargestRemaindersTable.test.tsx index 0ad694d90..bb4be6fd2 100644 --- a/frontend/app/component/apportionment/LargestSurplusesTable.test.tsx +++ b/frontend/app/component/apportionment/LargestRemaindersTable.test.tsx @@ -3,14 +3,14 @@ import { describe, expect, test } from "vitest"; import { PoliticalGroup } from "@kiesraad/api"; import { render, screen } from "@kiesraad/test"; -import { LargestSurplusesTable } from "./LargestSurplusesTable"; -import { apportionment, election, highest_surplus_steps } from "./test-data/less-than-19-seats"; +import { LargestRemaindersTable } from "./LargestRemaindersTable"; +import { apportionment, election, largest_remainder_steps } from "./test-data/less-than-19-seats"; -describe("LargestSurplusesTable", () => { - test("renders a table with the residual seat allocation with largest surpluses system", async () => { +describe("LargestRemaindersTable", () => { + test("renders a table with the residual seat allocation with largest remainders system", async () => { render( - , diff --git a/frontend/app/component/apportionment/LargestSurplusesTable.tsx b/frontend/app/component/apportionment/LargestRemaindersTable.tsx similarity index 58% rename from frontend/app/component/apportionment/LargestSurplusesTable.tsx rename to frontend/app/component/apportionment/LargestRemaindersTable.tsx index 4cd4c0425..95514854e 100644 --- a/frontend/app/component/apportionment/LargestSurplusesTable.tsx +++ b/frontend/app/component/apportionment/LargestRemaindersTable.tsx @@ -1,51 +1,56 @@ -import { ApportionmentStep, PoliticalGroup, PoliticalGroupSeatAssignment } from "@kiesraad/api"; +import { + ApportionmentStep, + LargestRemainderAssignedSeat, + PoliticalGroup, + PoliticalGroupSeatAssignment, +} from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Table } from "@kiesraad/ui"; import { cn } from "@kiesraad/util"; import cls from "./Apportionment.module.css"; -interface LargestSurplusesTableProps { - highestSurplusSteps: ApportionmentStep[]; +interface LargestRemaindersTableProps { + largestRemainderSteps: ApportionmentStep[]; finalStanding: PoliticalGroupSeatAssignment[]; politicalGroups: PoliticalGroup[]; } -export function LargestSurplusesTable({ - highestSurplusSteps, +export function LargestRemaindersTable({ + largestRemainderSteps, finalStanding, politicalGroups, -}: LargestSurplusesTableProps) { +}: LargestRemaindersTableProps) { const finalStandingPgsMeetingThreshold = finalStanding.filter( - (pg_seat_assignment) => pg_seat_assignment.meets_surplus_threshold, + (pg_seat_assignment) => pg_seat_assignment.meets_remainder_threshold, ); return ( -
+
{t("list")} - {t("list_name")} - {t("apportionment.whole_seats_count")} + {t("list_name")} + {t("apportionment.full_seats_count")} - {t("apportionment.surplus")} + {t("apportionment.remainder")} {t("apportionment.residual_seats_count")} {finalStandingPgsMeetingThreshold.map((pg_seat_assignment) => { const residual_seats = - highestSurplusSteps.filter((step) => step.change.selected_pg_number == pg_seat_assignment.pg_number) - .length || 0; + largestRemainderSteps.filter((step) => { + const change = step.change as LargestRemainderAssignedSeat; + return change.selected_pg_number == pg_seat_assignment.pg_number; + }).length || 0; return ( {pg_seat_assignment.pg_number} {politicalGroups[pg_seat_assignment.pg_number - 1]?.name || ""} - {pg_seat_assignment.whole_seats} - 0 ? "bg-yellow bold" : undefined}`} - > - {pg_seat_assignment.surplus_votes} + {pg_seat_assignment.full_seats} + 0 ? "bg-yellow bold" : undefined}> + {pg_seat_assignment.remainder_votes} {residual_seats} diff --git a/frontend/app/component/apportionment/ResidualSeatsCalculationTable.test.tsx b/frontend/app/component/apportionment/ResidualSeatsCalculationTable.test.tsx index a80710f94..551c5dad3 100644 --- a/frontend/app/component/apportionment/ResidualSeatsCalculationTable.test.tsx +++ b/frontend/app/component/apportionment/ResidualSeatsCalculationTable.test.tsx @@ -10,7 +10,7 @@ describe("ResidualSeatsCalculationTable", () => { render( , ); diff --git a/frontend/app/component/apportionment/ResidualSeatsCalculationTable.tsx b/frontend/app/component/apportionment/ResidualSeatsCalculationTable.tsx index b0fe2e2f7..5d2b596fc 100644 --- a/frontend/app/component/apportionment/ResidualSeatsCalculationTable.tsx +++ b/frontend/app/component/apportionment/ResidualSeatsCalculationTable.tsx @@ -6,15 +6,11 @@ import cls from "./Apportionment.module.css"; interface ResidualSeatsCalculationTableProps { seats: number; - wholeSeats: number; + fullSeats: number; residualSeats: number; } -export function ResidualSeatsCalculationTable({ - seats, - wholeSeats, - residualSeats, -}: ResidualSeatsCalculationTableProps) { +export function ResidualSeatsCalculationTable({ seats, fullSeats, residualSeats }: ResidualSeatsCalculationTableProps) { return (
@@ -27,9 +23,9 @@ export function ResidualSeatsCalculationTable({ - {t("apportionment.total_number_assigned_whole_seats")} + {t("apportionment.total_number_assigned_full_seats")} - {wholeSeats} + {fullSeats} — ({t("apportionment.minus")}) diff --git a/frontend/app/component/apportionment/index.ts b/frontend/app/component/apportionment/index.ts index 555371c99..d46b5137f 100644 --- a/frontend/app/component/apportionment/index.ts +++ b/frontend/app/component/apportionment/index.ts @@ -1,7 +1,7 @@ export * from "./ApportionmentTable"; export * from "./ElectionSummaryTable"; +export * from "./FullSeatsTable"; export * from "./LargestAveragesFor19OrMoreSeatsTable"; export * from "./LargestAveragesForLessThan19SeatsTable"; -export * from "./LargestSurplusesTable"; +export * from "./LargestRemaindersTable"; export * from "./ResidualSeatsCalculationTable"; -export * from "./WholeSeatsTable"; diff --git a/frontend/app/component/apportionment/test-data/19-or-more-seats.ts b/frontend/app/component/apportionment/test-data/19-or-more-seats.ts index 5c5fbf8d7..fd1ce4a34 100644 --- a/frontend/app/component/apportionment/test-data/19-or-more-seats.ts +++ b/frontend/app/component/apportionment/test-data/19-or-more-seats.ts @@ -2,7 +2,7 @@ import { ApportionmentResult, Election, ElectionSummary } from "@kiesraad/api"; export const apportionment: ApportionmentResult = { seats: 23, - whole_seats: 19, + full_seats: 19, residual_seats: 4, quota: { integer: 52, @@ -13,9 +13,10 @@ export const apportionment: ApportionmentResult = { { residual_seat_number: 1, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 5, pg_options: [5], + pg_assigned: [5], votes_per_seat: { integer: 50, numerator: 1, @@ -26,86 +27,86 @@ export const apportionment: ApportionmentResult = { { pg_number: 1, votes_cast: 600, - surplus_votes: { + remainder_votes: { integer: 26, numerator: 2, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 0, denominator: 12, }, - whole_seats: 11, + full_seats: 11, residual_seats: 0, }, { pg_number: 2, votes_cast: 302, - surplus_votes: { + remainder_votes: { integer: 41, numerator: 3, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 2, denominator: 6, }, - whole_seats: 5, + full_seats: 5, residual_seats: 0, }, { pg_number: 3, votes_cast: 98, - surplus_votes: { + remainder_votes: { integer: 45, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 0, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 4, votes_cast: 99, - surplus_votes: { + remainder_votes: { integer: 46, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 1, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 5, votes_cast: 101, - surplus_votes: { + remainder_votes: { integer: 48, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 1, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, ], @@ -113,9 +114,10 @@ export const apportionment: ApportionmentResult = { { residual_seat_number: 2, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 2, pg_options: [2], + pg_assigned: [2], votes_per_seat: { integer: 50, numerator: 2, @@ -126,86 +128,86 @@ export const apportionment: ApportionmentResult = { { pg_number: 1, votes_cast: 600, - surplus_votes: { + remainder_votes: { integer: 26, numerator: 2, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 0, denominator: 12, }, - whole_seats: 11, + full_seats: 11, residual_seats: 0, }, { pg_number: 2, votes_cast: 302, - surplus_votes: { + remainder_votes: { integer: 41, numerator: 3, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 2, denominator: 6, }, - whole_seats: 5, + full_seats: 5, residual_seats: 0, }, { pg_number: 3, votes_cast: 98, - surplus_votes: { + remainder_votes: { integer: 45, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 0, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 4, votes_cast: 99, - surplus_votes: { + remainder_votes: { integer: 46, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 1, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 5, votes_cast: 101, - surplus_votes: { + remainder_votes: { integer: 48, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 33, numerator: 2, denominator: 3, }, - whole_seats: 1, + full_seats: 1, residual_seats: 1, }, ], @@ -213,9 +215,10 @@ export const apportionment: ApportionmentResult = { { residual_seat_number: 3, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 1, pg_options: [1], + pg_assigned: [1], votes_per_seat: { integer: 50, numerator: 0, @@ -226,86 +229,86 @@ export const apportionment: ApportionmentResult = { { pg_number: 1, votes_cast: 600, - surplus_votes: { + remainder_votes: { integer: 26, numerator: 2, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 50, numerator: 0, denominator: 12, }, - whole_seats: 11, + full_seats: 11, residual_seats: 0, }, { pg_number: 2, votes_cast: 302, - surplus_votes: { + remainder_votes: { integer: 41, numerator: 3, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 43, numerator: 1, denominator: 7, }, - whole_seats: 5, + full_seats: 5, residual_seats: 1, }, { pg_number: 3, votes_cast: 98, - surplus_votes: { + remainder_votes: { integer: 45, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 0, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 4, votes_cast: 99, - surplus_votes: { + remainder_votes: { integer: 46, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 1, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 5, votes_cast: 101, - surplus_votes: { + remainder_votes: { integer: 48, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 33, numerator: 2, denominator: 3, }, - whole_seats: 1, + full_seats: 1, residual_seats: 1, }, ], @@ -313,9 +316,10 @@ export const apportionment: ApportionmentResult = { { residual_seat_number: 4, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 4, pg_options: [4], + pg_assigned: [4], votes_per_seat: { integer: 49, numerator: 1, @@ -326,86 +330,86 @@ export const apportionment: ApportionmentResult = { { pg_number: 1, votes_cast: 600, - surplus_votes: { + remainder_votes: { integer: 26, numerator: 2, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 46, numerator: 2, denominator: 13, }, - whole_seats: 11, + full_seats: 11, residual_seats: 1, }, { pg_number: 2, votes_cast: 302, - surplus_votes: { + remainder_votes: { integer: 41, numerator: 3, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 43, numerator: 1, denominator: 7, }, - whole_seats: 5, + full_seats: 5, residual_seats: 1, }, { pg_number: 3, votes_cast: 98, - surplus_votes: { + remainder_votes: { integer: 45, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 0, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 4, votes_cast: 99, - surplus_votes: { + remainder_votes: { integer: 46, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 49, numerator: 1, denominator: 2, }, - whole_seats: 1, + full_seats: 1, residual_seats: 0, }, { pg_number: 5, votes_cast: 101, - surplus_votes: { + remainder_votes: { integer: 48, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 33, numerator: 2, denominator: 3, }, - whole_seats: 1, + full_seats: 1, residual_seats: 1, }, ], @@ -415,65 +419,65 @@ export const apportionment: ApportionmentResult = { { pg_number: 1, votes_cast: 600, - surplus_votes: { + remainder_votes: { integer: 26, numerator: 2, denominator: 23, }, - meets_surplus_threshold: true, - whole_seats: 11, + meets_remainder_threshold: true, + full_seats: 11, residual_seats: 1, total_seats: 12, }, { pg_number: 2, votes_cast: 302, - surplus_votes: { + remainder_votes: { integer: 41, numerator: 3, denominator: 23, }, - meets_surplus_threshold: true, - whole_seats: 5, + meets_remainder_threshold: true, + full_seats: 5, residual_seats: 1, total_seats: 6, }, { pg_number: 3, votes_cast: 98, - surplus_votes: { + remainder_votes: { integer: 45, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, - whole_seats: 1, + meets_remainder_threshold: true, + full_seats: 1, residual_seats: 0, total_seats: 1, }, { pg_number: 4, votes_cast: 99, - surplus_votes: { + remainder_votes: { integer: 46, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, - whole_seats: 1, + meets_remainder_threshold: true, + full_seats: 1, residual_seats: 1, total_seats: 2, }, { pg_number: 5, votes_cast: 101, - surplus_votes: { + remainder_votes: { integer: 48, numerator: 19, denominator: 23, }, - meets_surplus_threshold: true, - whole_seats: 1, + meets_remainder_threshold: true, + full_seats: 1, residual_seats: 1, total_seats: 2, }, diff --git a/frontend/app/component/apportionment/test-data/absolute-majority-change.ts b/frontend/app/component/apportionment/test-data/absolute-majority-change.ts new file mode 100644 index 000000000..4edf2097b --- /dev/null +++ b/frontend/app/component/apportionment/test-data/absolute-majority-change.ts @@ -0,0 +1,716 @@ +import { ApportionmentResult, Election, ElectionSummary } from "@kiesraad/api"; + +export const apportionment: ApportionmentResult = { + seats: 15, + full_seats: 12, + residual_seats: 3, + quota: { + integer: 340, + numerator: 4, + denominator: 15, + }, + steps: [ + { + residual_seat_number: 1, + change: { + assigned_by: "LargestRemainder", + selected_pg_number: 2, + pg_options: [2], + pg_assigned: [2], + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + remainder_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + full_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 325, + numerator: 2, + denominator: 3, + }, + full_seats: 2, + residual_seats: 0, + }, + { + pg_number: 3, + votes_cast: 567, + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 283, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 4, + votes_cast: 536, + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + remainder_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 2, + change: { + assigned_by: "LargestRemainder", + selected_pg_number: 3, + pg_options: [3], + pg_assigned: [3], + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + remainder_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + full_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + full_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 283, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 4, + votes_cast: 536, + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + remainder_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 3, + change: { + assigned_by: "LargestRemainder", + selected_pg_number: 4, + pg_options: [4], + pg_assigned: [4], + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + remainder_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + full_seats: 7, + residual_seats: 0, + }, + { + pg_number: 2, + votes_cast: 977, + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + full_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 189, + numerator: 0, + denominator: 3, + }, + full_seats: 1, + residual_seats: 1, + }, + { + pg_number: 4, + votes_cast: 536, + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 268, + numerator: 0, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + remainder_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + ], + }, + { + residual_seat_number: 3, + change: { + assigned_by: "AbsoluteMajorityChange", + pg_retracted_seat: 4, + pg_assigned_seat: 1, + }, + standing: [ + { + pg_number: 1, + votes_cast: 2571, + remainder_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 321, + numerator: 3, + denominator: 8, + }, + full_seats: 7, + residual_seats: 1, + }, + { + pg_number: 2, + votes_cast: 977, + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 244, + numerator: 1, + denominator: 4, + }, + full_seats: 2, + residual_seats: 1, + }, + { + pg_number: 3, + votes_cast: 567, + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 189, + numerator: 0, + denominator: 3, + }, + full_seats: 1, + residual_seats: 1, + }, + { + pg_number: 4, + votes_cast: 536, + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 178, + numerator: 2, + denominator: 3, + }, + full_seats: 1, + residual_seats: 0, + }, + { + pg_number: 5, + votes_cast: 453, + remainder_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + next_votes_per_seat: { + integer: 226, + numerator: 1, + denominator: 2, + }, + full_seats: 1, + residual_seats: 0, + }, + ], + }, + ], + final_standing: [ + { + pg_number: 1, + votes_cast: 2571, + remainder_votes: { + integer: 189, + numerator: 2, + denominator: 15, + }, + meets_remainder_threshold: true, + full_seats: 7, + residual_seats: 1, + total_seats: 8, + }, + { + pg_number: 2, + votes_cast: 977, + remainder_votes: { + integer: 296, + numerator: 7, + denominator: 15, + }, + meets_remainder_threshold: true, + full_seats: 2, + residual_seats: 1, + total_seats: 3, + }, + { + pg_number: 3, + votes_cast: 567, + remainder_votes: { + integer: 226, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + full_seats: 1, + residual_seats: 1, + total_seats: 2, + }, + { + pg_number: 4, + votes_cast: 536, + remainder_votes: { + integer: 195, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + full_seats: 1, + residual_seats: 0, + total_seats: 1, + }, + { + pg_number: 5, + votes_cast: 453, + remainder_votes: { + integer: 112, + numerator: 11, + denominator: 15, + }, + meets_remainder_threshold: true, + full_seats: 1, + residual_seats: 0, + total_seats: 1, + }, + ], +}; + +export const election_summary: ElectionSummary = { + voters_counts: { + poll_card_count: 5104, + proxy_certificate_count: 0, + voter_card_count: 0, + total_admitted_voters_count: 5104, + }, + votes_counts: { + votes_candidates_count: 5104, + blank_votes_count: 0, + invalid_votes_count: 0, + total_votes_cast_count: 5104, + }, + differences_counts: { + more_ballots_count: { + count: 0, + polling_stations: [], + }, + fewer_ballots_count: { + count: 0, + polling_stations: [], + }, + unreturned_ballots_count: { + count: 0, + polling_stations: [], + }, + too_few_ballots_handed_out_count: { + count: 0, + polling_stations: [], + }, + too_many_ballots_handed_out_count: { + count: 0, + polling_stations: [], + }, + other_explanation_count: { + count: 0, + polling_stations: [], + }, + no_explanation_count: { + count: 0, + polling_stations: [], + }, + }, + recounted_polling_stations: [], + political_group_votes: [ + { + number: 1, + total: 2571, + candidate_votes: [ + { + number: 1, + votes: 1571, + }, + { + number: 2, + votes: 1000, + }, + ], + }, + { + number: 2, + total: 977, + candidate_votes: [ + { + number: 1, + votes: 577, + }, + { + number: 2, + votes: 400, + }, + ], + }, + { + number: 3, + total: 567, + candidate_votes: [ + { + number: 1, + votes: 367, + }, + { + number: 2, + votes: 200, + }, + ], + }, + { + number: 4, + total: 536, + candidate_votes: [ + { + number: 1, + votes: 336, + }, + { + number: 2, + votes: 200, + }, + ], + }, + { + number: 5, + total: 453, + candidate_votes: [ + { + number: 1, + votes: 253, + }, + { + number: 2, + votes: 200, + }, + ], + }, + ], +}; + +export const election: Election = { + id: 3, + name: "Test Election Absolute Majority Change", + location: "Test Location", + number_of_voters: 6000, + category: "Municipal", + number_of_seats: 15, + election_date: "2026-01-01", + nomination_date: "2026-01-01", + status: "DataEntryInProgress", + political_groups: [ + { + number: 1, + name: "Political Group A", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 2, + name: "Political Group B", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 3, + name: "Political Group C", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 4, + name: "Political Group D", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + { + number: 5, + name: "Political Group E", + candidates: [ + { + number: 1, + initials: "A.", + first_name: "Alice", + last_name: "Foo", + locality: "Amsterdam", + gender: "Female", + }, + { + number: 2, + initials: "C.", + first_name: "Charlie", + last_name: "Doe", + locality: "Rotterdam", + }, + ], + }, + ], +}; diff --git a/frontend/app/component/apportionment/test-data/less-than-19-seats.ts b/frontend/app/component/apportionment/test-data/less-than-19-seats.ts index ea0d03bd1..773868f0e 100644 --- a/frontend/app/component/apportionment/test-data/less-than-19-seats.ts +++ b/frontend/app/component/apportionment/test-data/less-than-19-seats.ts @@ -1,13 +1,14 @@ import { ApportionmentResult, ApportionmentStep, Election, ElectionSummary } from "@kiesraad/api"; -export const highest_surplus_steps: ApportionmentStep[] = [ +export const largest_remainder_steps: ApportionmentStep[] = [ { residual_seat_number: 1, change: { - assigned_by: "HighestSurplus", + assigned_by: "LargestRemainder", selected_pg_number: 2, pg_options: [2], - surplus_votes: { + pg_assigned: [2], + remainder_votes: { integer: 60, numerator: 0, denominator: 15, @@ -17,137 +18,137 @@ export const highest_surplus_steps: ApportionmentStep[] = [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 73, numerator: 5, denominator: 11, }, - whole_seats: 10, + full_seats: 10, residual_seats: 0, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 60, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 58, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 57, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 56, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 55, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 54, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 52, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, ], @@ -155,10 +156,11 @@ export const highest_surplus_steps: ApportionmentStep[] = [ { residual_seat_number: 2, change: { - assigned_by: "HighestSurplus", + assigned_by: "LargestRemainder", selected_pg_number: 1, pg_options: [1], - surplus_votes: { + pg_assigned: [1], + remainder_votes: { integer: 8, numerator: 0, denominator: 15, @@ -168,150 +170,151 @@ export const highest_surplus_steps: ApportionmentStep[] = [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 73, numerator: 5, denominator: 11, }, - whole_seats: 10, + full_seats: 10, residual_seats: 0, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 30, numerator: 0, denominator: 2, }, - whole_seats: 0, + full_seats: 0, residual_seats: 1, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 58, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 57, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 56, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 55, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 54, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 52, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, ], }, ]; -export const highest_average_steps: ApportionmentStep[] = [ +export const largest_average_steps: ApportionmentStep[] = [ { residual_seat_number: 3, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 1, pg_options: [1], + pg_assigned: [1], votes_per_seat: { integer: 67, numerator: 4, @@ -322,137 +325,137 @@ export const highest_average_steps: ApportionmentStep[] = [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 67, numerator: 4, denominator: 12, }, - whole_seats: 10, + full_seats: 10, residual_seats: 1, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 30, numerator: 0, denominator: 2, }, - whole_seats: 0, + full_seats: 0, residual_seats: 1, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 58, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 57, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 56, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 55, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 54, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 52, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, ], @@ -460,9 +463,10 @@ export const highest_average_steps: ApportionmentStep[] = [ { residual_seat_number: 4, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 3, pg_options: [3], + pg_assigned: [3], votes_per_seat: { integer: 58, numerator: 0, @@ -473,137 +477,137 @@ export const highest_average_steps: ApportionmentStep[] = [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 62, numerator: 2, denominator: 13, }, - whole_seats: 10, + full_seats: 10, residual_seats: 2, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 30, numerator: 0, denominator: 2, }, - whole_seats: 0, + full_seats: 0, residual_seats: 1, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 58, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 57, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 56, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 55, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 54, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 52, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, ], @@ -611,9 +615,10 @@ export const highest_average_steps: ApportionmentStep[] = [ { residual_seat_number: 5, change: { - assigned_by: "HighestAverage", + assigned_by: "LargestAverage", selected_pg_number: 4, pg_options: [4], + pg_assigned: [4], votes_per_seat: { integer: 57, numerator: 0, @@ -624,137 +629,137 @@ export const highest_average_steps: ApportionmentStep[] = [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 62, numerator: 2, denominator: 13, }, - whole_seats: 10, + full_seats: 10, residual_seats: 2, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, + meets_remainder_threshold: true, next_votes_per_seat: { integer: 30, numerator: 0, denominator: 2, }, - whole_seats: 0, + full_seats: 0, residual_seats: 1, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 29, numerator: 0, denominator: 2, }, - whole_seats: 0, + full_seats: 0, residual_seats: 1, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 57, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 56, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 55, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 54, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, + meets_remainder_threshold: false, next_votes_per_seat: { integer: 52, numerator: 0, denominator: 1, }, - whole_seats: 0, + full_seats: 0, residual_seats: 0, }, ], @@ -763,116 +768,116 @@ export const highest_average_steps: ApportionmentStep[] = [ export const apportionment: ApportionmentResult = { seats: 15, - whole_seats: 10, + full_seats: 10, residual_seats: 5, quota: { integer: 80, numerator: 0, denominator: 15, }, - steps: highest_surplus_steps.concat(highest_average_steps), + steps: largest_remainder_steps.concat(largest_average_steps), final_standing: [ { pg_number: 1, votes_cast: 808, - surplus_votes: { + remainder_votes: { integer: 8, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, - whole_seats: 10, + meets_remainder_threshold: true, + full_seats: 10, residual_seats: 2, total_seats: 12, }, { pg_number: 2, votes_cast: 60, - surplus_votes: { + remainder_votes: { integer: 60, numerator: 0, denominator: 15, }, - meets_surplus_threshold: true, - whole_seats: 0, + meets_remainder_threshold: true, + full_seats: 0, residual_seats: 1, total_seats: 1, }, { pg_number: 3, votes_cast: 58, - surplus_votes: { + remainder_votes: { integer: 58, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 1, total_seats: 1, }, { pg_number: 4, votes_cast: 57, - surplus_votes: { + remainder_votes: { integer: 57, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 1, total_seats: 1, }, { pg_number: 5, votes_cast: 56, - surplus_votes: { + remainder_votes: { integer: 56, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 0, total_seats: 0, }, { pg_number: 6, votes_cast: 55, - surplus_votes: { + remainder_votes: { integer: 55, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 0, total_seats: 0, }, { pg_number: 7, votes_cast: 54, - surplus_votes: { + remainder_votes: { integer: 54, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 0, total_seats: 0, }, { pg_number: 8, votes_cast: 52, - surplus_votes: { + remainder_votes: { integer: 52, numerator: 0, denominator: 15, }, - meets_surplus_threshold: false, - whole_seats: 0, + meets_remainder_threshold: false, + full_seats: 0, residual_seats: 0, total_seats: 0, }, diff --git a/frontend/app/component/error/ErrorBoundary.tsx b/frontend/app/component/error/ErrorBoundary.tsx index 7437cb4f0..63d90f701 100644 --- a/frontend/app/component/error/ErrorBoundary.tsx +++ b/frontend/app/component/error/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { useRouteError } from "react-router"; +import { Navigate, useRouteError } from "react-router"; import { FatalErrorPage } from "app/module/FatalErrorPage"; import { NotFoundPage } from "app/module/NotFoundPage"; @@ -8,6 +8,12 @@ import { ApiError, FatalApiError, NetworkError, NotFoundError } from "@kiesraad/ export function ErrorBoundary() { const error = useRouteError() as Error; + // redirect to login page if the user is not authenticated + if (error instanceof ApiError && error.code === 401) { + // redirect to login page + return ; + } + // debug print the error to the console console.error(error); diff --git a/frontend/app/component/form/user/account_setup/AccountSetupForm.tsx b/frontend/app/component/form/user/account_setup/AccountSetupForm.tsx index 4d2a25cab..873449f59 100644 --- a/frontend/app/component/form/user/account_setup/AccountSetupForm.tsx +++ b/frontend/app/component/form/user/account_setup/AccountSetupForm.tsx @@ -23,22 +23,27 @@ export function AccountSetupForm() { return (
-

{t("user.personalize_account")}

+

{t("account.personalize_account")}

- + - +
diff --git a/frontend/app/component/form/user/change_password/ChangePassword.test.tsx b/frontend/app/component/form/user/change_password/ChangePassword.test.tsx deleted file mode 100644 index e4c51adb8..000000000 --- a/frontend/app/component/form/user/change_password/ChangePassword.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, test } from "vitest"; - -import { ApiProvider } from "@kiesraad/api"; -import { WhoAmIRequestHandler } from "@kiesraad/api-mocks"; -import { overrideOnce, screen, server, waitFor } from "@kiesraad/test"; - -import { ChangePasswordForm } from "./ChangePasswordForm"; - -describe("ChangePasswordForm", () => { - beforeEach(() => { - server.use(WhoAmIRequestHandler); - }); - - test("Successful change-password", async () => { - render( - - - , - ); - - let requestBody: object | null = null; - - overrideOnce( - "post", - "/api/user/change-password", - 200, - { - user_id: 1, - username: "user", - }, - undefined, - async (request) => { - requestBody = (await request.json()) as object; - }, - ); - - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getByText("Wachtwoord")).toBeVisible(); - }); - - const password = screen.getByLabelText("Wachtwoord"); - await user.type(password, "password"); - const newPassword = screen.getByLabelText("Kies nieuw wachtwoord"); - await user.type(newPassword, "password_new"); - const repeatPassword = screen.getByLabelText("Herhaal het wachtwoord dat je net hebt ingevuld"); - await user.type(repeatPassword, "password_new"); - - const submitButton = screen.getByRole("button", { name: "Opslaan" }); - await user.click(submitButton); - - await waitFor(() => { - expect(requestBody).toStrictEqual({ username: "user", password: "password", new_password: "password_new" }); - }); - }); - - test("Incorrect password repeat in change-password form", async () => { - render( - - - , - ); - - overrideOnce("post", "/api/user/change-password", 401, { - error: "Invalid username and/or password", - fatal: false, - reference: "InvalidPassword", - }); - - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getByText("Wachtwoord")).toBeVisible(); - }); - - const password = screen.getByLabelText("Wachtwoord"); - await user.type(password, "password"); - const newPassword = screen.getByLabelText("Kies nieuw wachtwoord"); - await user.type(newPassword, "password_new"); - const repeatPassword = screen.getByLabelText("Herhaal het wachtwoord dat je net hebt ingevuld"); - await user.type(repeatPassword, "password_new_wrong"); - - const submitButton = screen.getByRole("button", { name: "Opslaan" }); - await user.click(submitButton); - - await waitFor(async () => { - expect(await screen.findByText("De wachtwoorden komen niet overeen")).toBeVisible(); - }); - }); - - test("Unsuccessful change-password", async () => { - render( - - - , - ); - - overrideOnce("post", "/api/user/change-password", 401, { - error: "Invalid username and/or password", - fatal: false, - reference: "InvalidPassword", - }); - - const user = userEvent.setup(); - - await waitFor(() => { - expect(screen.getByText("Wachtwoord")).toBeVisible(); - }); - - const password = screen.getByLabelText("Wachtwoord"); - await user.type(password, "password-wrong"); - const newPassword = screen.getByLabelText("Kies nieuw wachtwoord"); - await user.type(newPassword, "password_new"); - const repeatPassword = screen.getByLabelText("Herhaal het wachtwoord dat je net hebt ingevuld"); - await user.type(repeatPassword, "password_new"); - - const submitButton = screen.getByRole("button", { name: "Opslaan" }); - await user.click(submitButton); - - await waitFor(async () => { - expect(await screen.findByText("Het opgegeven wachtwoord is onjuist")).toBeVisible(); - }); - }); -}); diff --git a/frontend/app/component/form/user/change_password/ChangePasswordForm.tsx b/frontend/app/component/form/user/change_password/ChangePasswordForm.tsx deleted file mode 100644 index d0f53af9d..000000000 --- a/frontend/app/component/form/user/change_password/ChangePasswordForm.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { FormEvent, useState } from "react"; - -import { - AnyApiError, - ApiError, - CHANGE_PASSWORD_REQUEST_BODY, - CHANGE_PASSWORD_REQUEST_PATH, - FatalApiError, - isError, - LoginResponse, - useApiState, -} from "@kiesraad/api"; -import { t, TranslationPath } from "@kiesraad/i18n"; -import { Alert, BottomBar, Button, FormLayout, InputField, Loader } from "@kiesraad/ui"; - -const INITIAL_FORM_STATE = { - password: "", - newPassword: "", - newPasswordRepeat: "", -}; - -function errorMessage(error: AnyApiError | TranslationPath) { - if (typeof error === "string") { - return t(error); - } - - if (error instanceof ApiError || error instanceof FatalApiError) { - return t(`error.api_error.${error.reference}`); - } - - throw error; -} - -export function ChangePasswordForm() { - const { user, client } = useApiState(); - const [formState, setFormState] = useState(INITIAL_FORM_STATE); - const [loading, setLoading] = useState(false); - const [success, setSuccess] = useState(false); - const [error, setError] = useState(null); - - // Handle form submission - async function handleSubmit(event: FormEvent) { - event.preventDefault(); - - // Validate the form - if (formState.newPassword !== formState.newPasswordRepeat) { - setError("user.password_mismatch"); - return; - } - - // Submit the credentials to the API - setLoading(true); - const requestPath: CHANGE_PASSWORD_REQUEST_PATH = "/api/user/change-password"; - const requestBody: CHANGE_PASSWORD_REQUEST_BODY = { - username: user?.username as string, - password: formState.password, - new_password: formState.newPassword, - }; - const result = await client.postRequest(requestPath, requestBody); - - // Handle the result - setLoading(false); - if (isError(result)) { - setError(result); - setSuccess(false); - } else { - setFormState(INITIAL_FORM_STATE); - setError(null); - setSuccess(true); - } - } - - if (!user) { - return ; - } - - return ( - { - void handleSubmit(e); - }} - > - - {error && ( - - { - setError(null); - }} - margin="mb-lg" - > -

{errorMessage(error)}

-
-
- )} - {success && ( - - { - setError(null); - }} - margin="mb-lg" - > -

{t("user.password_changed")}

-
-
- )} -

- {t("user.username")}: {user.username} -

- { - setFormState({ ...formState, password: e.target.value }); - }} - /> - { - setFormState({ ...formState, newPassword: e.target.value }); - }} - /> - { - setFormState({ ...formState, newPasswordRepeat: e.target.value }); - }} - /> -
- - - - - - - ); -} diff --git a/frontend/app/component/form/user/login/LoginForm.test.tsx b/frontend/app/component/form/user/login/LoginForm.test.tsx index 08d0b67bf..ab38b613a 100644 --- a/frontend/app/component/form/user/login/LoginForm.test.tsx +++ b/frontend/app/component/form/user/login/LoginForm.test.tsx @@ -16,7 +16,7 @@ describe("LoginForm", () => { 200, { user_id: 1, - username: "user", + username: "admin", }, undefined, async (request) => { diff --git a/frontend/app/component/form/user/login/LoginForm.tsx b/frontend/app/component/form/user/login/LoginForm.tsx index ad3e84e3e..4354fb501 100644 --- a/frontend/app/component/form/user/login/LoginForm.tsx +++ b/frontend/app/component/form/user/login/LoginForm.tsx @@ -1,44 +1,33 @@ import { FormEvent, useState } from "react"; import { useNavigate } from "react-router"; -import { - AnyApiError, - FatalError, - isError, - LOGIN_REQUEST_BODY, - LOGIN_REQUEST_PATH, - LoginResponse, - useApiState, -} from "@kiesraad/api"; +import { AnyApiError, FatalError, isError, useApiState } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Alert, BottomBar, Button, FormLayout, InputField } from "@kiesraad/ui"; export function LoginForm() { const navigate = useNavigate(); - const { client, setUser } = useApiState(); + const { login } = useApiState(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Handle form submission async function handleSubmit(event: FormEvent) { event.preventDefault(); - setLoading(true); - // Submit the credentials to the API - const formData = new FormData(event.currentTarget); - const requestPath: LOGIN_REQUEST_PATH = "/api/user/login"; - const requestBody: LOGIN_REQUEST_BODY = { - username: formData.get("username") as string, - password: formData.get("password") as string, - }; - const result = await client.postRequest(requestPath, requestBody); + setLoading(true); + const result = await login(username, password); // Handle the result setLoading(false); if (isError(result)) { setError(result); } else { - setUser(result.data); + setError(null); + setUsername(""); + setPassword(""); // TODO: Handle successful login navigation here void navigate("../setup"); } @@ -66,24 +55,32 @@ export function LoginForm() { )} { + setUsername(e.target.value); + }} /> { + setPassword(e.target.value); + }} /> diff --git a/frontend/app/component/navbar/NavBar.module.css b/frontend/app/component/navbar/NavBar.module.css index 31d9aed06..102502adf 100644 --- a/frontend/app/component/navbar/NavBar.module.css +++ b/frontend/app/component/navbar/NavBar.module.css @@ -49,7 +49,21 @@ .user-info { display: flex; align-items: center; - gap: 0.25rem; + gap: 0.5rem; + + span { + text-transform: lowercase; + } + + a { + text-decoration: underline; + color: inherit; + + &:hover, + &:active { + color: inherit !important; + } + } } svg { diff --git a/frontend/app/component/navbar/NavBar.stories.tsx b/frontend/app/component/navbar/NavBar.stories.tsx index 95cca0052..c7d241b28 100644 --- a/frontend/app/component/navbar/NavBar.stories.tsx +++ b/frontend/app/component/navbar/NavBar.stories.tsx @@ -4,7 +4,7 @@ import { Story } from "@ladle/react"; import { electionDetailsMockResponse } from "lib/api-mocks/ElectionMockData"; import { ElectionProviderContext } from "lib/api/election/ElectionProviderContext"; -import { Election } from "@kiesraad/api"; +import { Election, Role, TestUserProvider } from "@kiesraad/api"; import { NavBar } from "./NavBar"; import styles from "./NavBar.module.css"; @@ -14,31 +14,31 @@ export default { title: "App / Navigation bar", }; -const locations = [ - { pathname: "/account/login", hash: "" }, - { pathname: "/account/setup", hash: "" }, - { pathname: "/elections", hash: "" }, - { pathname: "/elections/1", hash: "" }, - { pathname: "/elections/1/data-entry", hash: "" }, - { pathname: "/elections/1/data-entry/1/1", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/recounted", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/voters-and-votes", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/list/1", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/save", hash: "" }, - { pathname: "/dev", hash: "" }, - { pathname: "/elections", hash: "#administratorcoordinator" }, - { pathname: "/users", hash: "#administratorcoordinator" }, - { pathname: "/workstations", hash: "#administrator" }, - { pathname: "/logs", hash: "#administratorcoordinator" }, - { pathname: "/elections/1", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/report", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/status", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations/create", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations/1/update", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment/details-whole-seats", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment/details-residual-seats", hash: "#administratorcoordinator" }, +const locations: { pathname: string; userRole: Role }[] = [ + { pathname: "/account/login", userRole: "typist" }, + { pathname: "/account/setup", userRole: "typist" }, + { pathname: "/elections", userRole: "typist" }, + { pathname: "/elections/1", userRole: "typist" }, + { pathname: "/elections/1/data-entry", userRole: "typist" }, + { pathname: "/elections/1/data-entry/1/1", userRole: "typist" }, + { pathname: "/elections/1/data-entry/1/1/recounted", userRole: "typist" }, + { pathname: "/elections/1/data-entry/1/1/voters-and-votes", userRole: "typist" }, + { pathname: "/elections/1/data-entry/1/1/list/1", userRole: "typist" }, + { pathname: "/elections/1/data-entry/1/1/save", userRole: "typist" }, + { pathname: "/dev", userRole: "typist" }, + { pathname: "/elections", userRole: "coordinator" }, + { pathname: "/users", userRole: "coordinator" }, + { pathname: "/workstations", userRole: "coordinator" }, + { pathname: "/logs", userRole: "coordinator" }, + { pathname: "/elections/1", userRole: "coordinator" }, + { pathname: "/elections/1/report", userRole: "coordinator" }, + { pathname: "/elections/1/status", userRole: "coordinator" }, + { pathname: "/elections/1/polling-stations", userRole: "coordinator" }, + { pathname: "/elections/1/polling-stations/create", userRole: "coordinator" }, + { pathname: "/elections/1/polling-stations/1/update", userRole: "coordinator" }, + { pathname: "/elections/1/apportionment", userRole: "coordinator" }, + { pathname: "/elections/1/apportionment/details-full-seats", userRole: "coordinator" }, + { pathname: "/elections/1/apportionment/details-residual-seats", userRole: "coordinator" }, ]; export const AllRoutes: Story = () => ( @@ -49,13 +49,15 @@ export const AllRoutes: Story = () => ( }} > {locations.map((location) => ( - - - {location.pathname} - {location.hash} - - -
+ + + + {location.pathname} + {location.userRole} + + +
+
))} diff --git a/frontend/app/component/navbar/NavBar.test.tsx b/frontend/app/component/navbar/NavBar.test.tsx index 0fc01f1f1..7df4d9f47 100644 --- a/frontend/app/component/navbar/NavBar.test.tsx +++ b/frontend/app/component/navbar/NavBar.test.tsx @@ -1,42 +1,53 @@ import { userEvent } from "@testing-library/user-event"; import { beforeEach, describe, expect, test } from "vitest"; -import { ElectionProvider } from "@kiesraad/api"; +import { ElectionProvider, Role, TestUserProvider } from "@kiesraad/api"; import { ElectionRequestHandler } from "@kiesraad/api-mocks"; import { render, screen, server } from "@kiesraad/test"; import { NavBar } from "./NavBar"; +import { NavBarLinks } from "./NavBarLinks"; -async function renderNavBar(location: { pathname: string; hash: string }) { +async function renderNavBar(location: { pathname: string }, userRole: Role) { render( - - - , + + + + + , ); // wait for the NavBar to be rendered expect(await screen.findByLabelText("primary-navigation")).toBeInTheDocument(); } +function renderNavBarLinks(location: { pathname: string }) { + render( + + + , + ); +} + describe("NavBar", () => { beforeEach(() => { server.use(ElectionRequestHandler); }); test.each([ - { pathname: "/account/login", hash: "" }, - { pathname: "/account/setup", hash: "" }, - { pathname: "/elections", hash: "" }, - { pathname: "/elections/1", hash: "" }, - { pathname: "/invalid-notfound", hash: "" }, - ])("no links for $pathname", async (location) => { - await renderNavBar(location); + { pathname: "/account/login" }, + { pathname: "/account/setup" }, + { pathname: "/elections" }, + { pathname: "/elections/1" }, + { pathname: "/invalid-notfound" }, + ])("no links for $pathname", (location) => { + renderNavBarLinks(location); expect(screen.queryByRole("link")).not.toBeInTheDocument(); }); test("elections link and current election name for '/elections/1/data-entry'", async () => { - await renderNavBar({ pathname: "/elections/1/data-entry", hash: "" }); + await renderNavBar({ pathname: "/elections/1/data-entry" }, "typist"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).toBeVisible(); expect( @@ -48,28 +59,29 @@ describe("NavBar", () => { }); test.each([ - { pathname: "/elections/1/data-entry/1/1", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/recounted", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/voters-and-votes", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/list/1", hash: "" }, - { pathname: "/elections/1/data-entry/1/1/save", hash: "" }, + { pathname: "/elections/1/data-entry/1/1" }, + { pathname: "/elections/1/data-entry/1/1/recounted" }, + { pathname: "/elections/1/data-entry/1/1/voters-and-votes" }, + { pathname: "/elections/1/data-entry/1/1/list/1" }, + { pathname: "/elections/1/data-entry/1/1/save" }, ])("elections link and current election link for $pathname", async (location) => { - await renderNavBar(location); + await renderNavBar(location, "typist"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).toBeVisible(); expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); }); test.each([ - { pathname: "/elections", hash: "#administratorcoordinator" }, - { pathname: "/users", hash: "#administratorcoordinator" }, - { pathname: "/users/create", hash: "#administratorcoordinator" }, - { pathname: "/users/create/details", hash: "#administratorcoordinator" }, - { pathname: "/workstations", hash: "#administratorcoordinator" }, - { pathname: "/logs", hash: "#administratorcoordinator" }, - { pathname: "/elections/1", hash: "#administratorcoordinator" }, + { pathname: "/elections" }, + { pathname: "/users" }, + { pathname: "/users" }, + { pathname: "/users/create" }, + { pathname: "/users/create/details" }, + { pathname: "/workstations" }, + { pathname: "/logs" }, + { pathname: "/elections/1" }, ])("top level management links for $pathname", async (location) => { - await renderNavBar(location); + await renderNavBar(location, "administrator"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).toBeVisible(); expect(screen.queryByRole("link", { name: "Gebruikers" })).toBeVisible(); @@ -78,22 +90,22 @@ describe("NavBar", () => { }); test.each([ - { pathname: "/elections/1/report", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/status", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/report" }, + { pathname: "/elections/1/status" }, + { pathname: "/elections/1/polling-stations" }, + { pathname: "/elections/1/apportionment" }, ])("election management links for $pathname", async (location) => { - await renderNavBar(location); + await renderNavBar(location, "coordinator"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); }); test.each([ - { pathname: "/elections/1/polling-stations/create", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations/1/update", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/polling-stations/create" }, + { pathname: "/elections/1/polling-stations/1/update" }, ])("polling station management links for $pathname", async (location) => { - await renderNavBar(location); + await renderNavBar(location, "coordinator"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); @@ -101,10 +113,10 @@ describe("NavBar", () => { }); test.each([ - { pathname: "/elections/1/apportionment/details-whole-seats", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment/details-residual-seats", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/apportionment/details-full-seats" }, + { pathname: "/elections/1/apportionment/details-residual-seats" }, ])("polling station management links for $pathname", async (location) => { - await renderNavBar(location); + await renderNavBar(location, "coordinator"); expect(screen.queryByRole("link", { name: "Verkiezingen" })).not.toBeInTheDocument(); expect(screen.queryByRole("link", { name: "Heemdamseburg — Gemeenteraadsverkiezingen 2026" })).toBeVisible(); @@ -112,16 +124,16 @@ describe("NavBar", () => { }); test.each([ - { pathname: "/elections/1/report", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/status", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations/create", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/polling-stations/1/update", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment/details-whole-seats", hash: "#administratorcoordinator" }, - { pathname: "/elections/1/apportionment/details-residual-seats", hash: "#administratorcoordinator" }, + { pathname: "/elections/1/report" }, + { pathname: "/elections/1/status" }, + { pathname: "/elections/1/polling-stations" }, + { pathname: "/elections/1/polling-stations/create" }, + { pathname: "/elections/1/polling-stations/1/update" }, + { pathname: "/elections/1/apportionment/details-full-seats" }, + { pathname: "/elections/1/apportionment/details-residual-seats" }, ])("menu works for $pathname", async (location) => { const user = userEvent.setup(); - await renderNavBar(location); + await renderNavBar(location, "administrator"); const menuButton = screen.getByRole("button", { name: "Menu" }); expect(menuButton).toBeVisible(); diff --git a/frontend/app/component/navbar/NavBar.tsx b/frontend/app/component/navbar/NavBar.tsx index 96e85c9ae..546e9797e 100644 --- a/frontend/app/component/navbar/NavBar.tsx +++ b/frontend/app/component/navbar/NavBar.tsx @@ -1,26 +1,15 @@ +import { Link } from "react-router"; + +import { useApiState } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; -import { IconUser } from "@kiesraad/icon"; import styles from "./NavBar.module.css"; import { NavBarLinks } from "./NavBarLinks"; -type NavBarProps = { location: { pathname: string; hash: string } }; +type NavBarProps = { location: { pathname: string } }; export function NavBar({ location }: NavBarProps) { - const isAdministrator = location.hash.includes("administrator"); - const isCoordinator = location.hash.includes("coordinator"); - - const role = []; - if (isAdministrator || isCoordinator) { - if (isAdministrator) { - role.push(t("administrator")); - } - if (isCoordinator) { - role.push(t("coordinator")); - } - } else { - role.push(t("typist")); - } + const { user } = useApiState(); return ( ); diff --git a/frontend/app/component/navbar/NavBarLinks.tsx b/frontend/app/component/navbar/NavBarLinks.tsx index 06062ae45..25aa50650 100644 --- a/frontend/app/component/navbar/NavBarLinks.tsx +++ b/frontend/app/component/navbar/NavBarLinks.tsx @@ -1,12 +1,12 @@ import { Link, NavLink } from "react-router"; -import { Election, useElection } from "@kiesraad/api"; +import { Election, useElection, useUserRole } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { IconChevronRight } from "@kiesraad/icon"; import { NavBarMenuButton } from "./NavBarMenu"; -type NavBarLinksProps = { location: { pathname: string; hash: string } }; +type NavBarLinksProps = { location: { pathname: string } }; function ElectionBreadcrumb({ election }: { election: Election }) { return ( @@ -45,44 +45,47 @@ function ElectionManagementLinks({ location }: NavBarLinksProps) { if (location.pathname.match(/^\/elections\/\d+\/?$/)) { return <>; - } else { - return ( - <> - - - - - {location.pathname.match(/^\/elections\/\d+\/polling-stations\/(create|\d+\/update)$/) && ( - <> - - {t("polling_stations")} - - )} - {location.pathname.match(/^\/elections\/\d+\/apportionment\/(details-whole-seats|details-residual-seats)$/) && ( - <> - - {t("apportionment.title")} - - )} - - ); } + + return ( + <> + + + + + {location.pathname.match(/^\/elections\/\d+\/polling-stations\/(create|\d+\/update)$/) && ( + <> + + {t("polling_stations")} + + )} + {location.pathname.match(/^\/elections\/\d+\/apportionment\/(details-full-seats|details-residual-seats)$/) && ( + <> + + {t("apportionment.title")} + + )} + + ); } -function TopLevelManagementLinks() { +function TopLevelManagementLinks({ isAdministrator }: { isAdministrator: boolean }) { return ( <> - {t("election.title.plural")} - {t("users.users")} - {t("workstations.workstations")} - {t("logs")} + {t("election.title.plural")} + {isAdministrator && ( + <> + {t("users.users")} + {t("workstations.workstations")} + + )} + {t("logs")} ); } export function NavBarLinks({ location }: NavBarLinksProps) { - const isAdministrator = location.hash.includes("administrator"); - const isCoordinator = location.hash.includes("coordinator"); + const { isAdministrator, isCoordinator } = useUserRole(); if ( (location.pathname.match(/^\/elections(\/\d+)?$/) && (isAdministrator || isCoordinator)) || @@ -90,12 +93,16 @@ export function NavBarLinks({ location }: NavBarLinksProps) { location.pathname === "/workstations" || location.pathname === "/logs" ) { - return ; - } else if (location.pathname.match(/^\/elections\/\d+\/data-entry/)) { + return ; + } + + if (location.pathname.match(/^\/elections\/\d+\/data-entry/)) { return ; - } else if (location.pathname.match(/^\/elections\/\d+/)) { + } + + if (location.pathname.match(/^\/elections\/\d+/)) { return ; - } else { - return <>; } + + return <>; } diff --git a/frontend/app/component/navbar/NavBarMenu.tsx b/frontend/app/component/navbar/NavBarMenu.tsx index 1b6daad03..a8dc9d5bf 100644 --- a/frontend/app/component/navbar/NavBarMenu.tsx +++ b/frontend/app/component/navbar/NavBarMenu.tsx @@ -9,19 +9,19 @@ import styles from "./NavBar.module.css"; export function NavBarMenu() { return (
- + {t("election.title.plural")} - + {t("users.users")} - + {t("workstations.workstations")} - + {t("logs")} diff --git a/frontend/app/module/DevHomePage.test.tsx b/frontend/app/module/DevHomePage.test.tsx index 6a4bcdae7..079b6a38e 100644 --- a/frontend/app/module/DevHomePage.test.tsx +++ b/frontend/app/module/DevHomePage.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, test } from "vitest"; +import { TestUserProvider } from "@kiesraad/api"; import { ElectionListRequestHandler } from "@kiesraad/api-mocks"; import { render, screen, server } from "@kiesraad/test"; @@ -11,7 +12,11 @@ describe("DevHomePage", () => { }); test("renders DevHomePage with election links", async () => { - render(); + render( + + + , + ); expect(await screen.findByRole("heading", { level: 1, name: "Abacus 🧮" })).toBeVisible(); expect(screen.getAllByRole("link", { name: "Gemeenteraadsverkiezingen 2026" })[0]).toBeVisible(); diff --git a/frontend/app/module/DevHomePage.tsx b/frontend/app/module/DevHomePage.tsx index f72d5907c..427ec3370 100644 --- a/frontend/app/module/DevHomePage.tsx +++ b/frontend/app/module/DevHomePage.tsx @@ -2,33 +2,15 @@ import { Link } from "react-router"; import { MockTest } from "app/component/MockTest"; -import { ElectionListProvider, useElectionList } from "@kiesraad/api"; +import { ElectionListProvider, useApiState, useElectionList, useUserRole } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { AppLayout, PageTitle } from "@kiesraad/ui"; -function DevLinks() { +function TypistLinks() { const { electionList } = useElectionList(); return ( <> -

Dit is een ontwikkelversie van Abacus. Kies hieronder welk deel van de applicatie je wilt gebruiken.

- {t("general")} -
    -
  • - {t("user.account")} -
      -
    • - {t("user.login")} -
    • -
    • - {t("user.account_setup")} -
    • -
    • - {t("user.change_password")} -
    • -
    -
  • -
{t("typist")}
  • @@ -42,43 +24,130 @@ function DevLinks() { ))}
+ + ); +} + +function AdministratorCoordinatorLinks() { + const { electionList } = useElectionList(); + + return ( + <> {t("administrator")} / {t("coordinator")}
  • - {t("election.manage")} + {t("election.manage")}
    • {electionList.map((election) => (
    • - {election.name} + {election.name}
      • - {t("apportionment.title")} + {t("election_status.main_title")}
      • - {t("election_status.main_title")} + {t("apportionment.title")}
      • - - {t("polling_station.title.plural")} - + {t("polling_station.title.plural")}
    • ))}
  • - {t("users.management")} + {t("users.management")}
  • - {t("workstations.manage")} + {t("workstations.manage")}
  • - {t("activity_log")} + {t("activity_log")} +
  • +
+ + ); +} + +function DevLinks() { + const { user, login, logout } = useApiState(); + const { isTypist, isAdministrator, isCoordinator } = useUserRole(); + + return ( + <> +

Dit is een ontwikkelversie van Abacus. Kies hieronder welk deel van de applicatie je wilt gebruiken.

+ Inloggen als +
    +
  • + { + void login("admin", "AdminPassword01"); + }} + > + {t("administrator")} + +
  • +
  • + { + void login("typist", "TypistPassword01"); + }} + > + {t("typist")} + +
  • +
  • + { + void login("coordinator", "CoordinatorPassword01"); + }} + > + {t("coordinator")} + +
  • + {user && ( +
  • + { + void logout(); + }} + > + {t("account.logout")}: {user.fullname || user.username} ({user.role}) + +
  • + )} +
+ {t("general")} +
    +
  • + {t("account.account")} +
      +
    • + {t("account.login")} +
    • +
    • + {t("account.account_setup")} +
    • +
+ {isTypist && ( + + + + )} + {(isAdministrator || isCoordinator) && ( + + + + )} ); } @@ -94,9 +163,7 @@ export function DevHomePage() {
- - - + {__API_MSW__ && }
diff --git a/frontend/app/module/FatalErrorPage.tsx b/frontend/app/module/FatalErrorPage.tsx index 7868baa15..cdc042f84 100644 --- a/frontend/app/module/FatalErrorPage.tsx +++ b/frontend/app/module/FatalErrorPage.tsx @@ -18,7 +18,7 @@ export function FatalErrorPage({ message, code, reference, error }: FatalErrorPa return ( {/* Show NavBar for / to avoid call to useElection outside ElectionProvider */} - + {(code || reference) && (

diff --git a/frontend/app/module/NotFoundPage.tsx b/frontend/app/module/NotFoundPage.tsx index 3299e7111..5fb820608 100644 --- a/frontend/app/module/NotFoundPage.tsx +++ b/frontend/app/module/NotFoundPage.tsx @@ -14,7 +14,7 @@ export function NotFoundPage({ message, path }: NotFoundPageProps) { return ( {/* Show NavBar for / to avoid call to useElection outside ElectionProvider */} - + {path &&

{tx("error.page_not_found", undefined, { path })}

}

{t("error.not_found_feedback")}

diff --git a/frontend/app/module/account/Logout.tsx b/frontend/app/module/account/Logout.tsx new file mode 100644 index 000000000..315efe238 --- /dev/null +++ b/frontend/app/module/account/Logout.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; +import { Navigate } from "react-router"; + +import { useApiState } from "@kiesraad/api"; + +export function Logout() { + const { logout } = useApiState(); + + // logout the user when the component is mounted + useEffect(() => { + void logout(); + }, [logout]); + + return ; +} diff --git a/frontend/app/module/account/page/AccountSetupPage.tsx b/frontend/app/module/account/page/AccountSetupPage.tsx index e5d001ad7..35b3a8812 100644 --- a/frontend/app/module/account/page/AccountSetupPage.tsx +++ b/frontend/app/module/account/page/AccountSetupPage.tsx @@ -1,12 +1,19 @@ import { useState } from "react"; +import { Navigate } from "react-router"; import { AccountSetupForm } from "app/component/form/user/account_setup/AccountSetupForm"; +import { useUser } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Alert, PageTitle } from "@kiesraad/ui"; export function AccountSetupPage() { const [showAlert, setShowAlert] = useState(true); + const user = useUser(); + + if (!user) { + return ; + } function hideAlert() { setShowAlert(!showAlert); @@ -14,16 +21,16 @@ export function AccountSetupPage() { return ( <> - +
-

{t("user.account_setup")}

+

{t("account.account_setup")}

{showAlert && ( -

{t("user.login_success")}

-

{t("user.phrases.setting_up_account")}

+

{t("account.login_success")}

+

{t("account.setting_up_account")}

)}
diff --git a/frontend/app/module/account/page/ChangePasswordPage.test.tsx b/frontend/app/module/account/page/ChangePasswordPage.test.tsx deleted file mode 100644 index a7bbba6b3..000000000 --- a/frontend/app/module/account/page/ChangePasswordPage.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render } from "@testing-library/react"; -import { beforeEach, describe, expect, test } from "vitest"; - -import { WhoAmIRequestHandler } from "@kiesraad/api-mocks"; -import { Providers, screen, server, waitFor } from "@kiesraad/test"; - -import { ChangePasswordPage } from "./ChangePasswordPage"; - -describe("ChangePasswordPage", () => { - beforeEach(() => { - server.use(WhoAmIRequestHandler); - }); - - test("The change-password page should state the currently logged-in user", async () => { - render( - - - , - ); - - await waitFor(() => { - expect(screen.getByText("Gebruikersnaam: user")).toBeVisible(); - }); - }); -}); diff --git a/frontend/app/module/account/page/ChangePasswordPage.tsx b/frontend/app/module/account/page/ChangePasswordPage.tsx deleted file mode 100644 index a509c33a9..000000000 --- a/frontend/app/module/account/page/ChangePasswordPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ChangePasswordForm } from "app/component/form/user/change_password/ChangePasswordForm"; - -import { t } from "@kiesraad/i18n"; -import { PageTitle } from "@kiesraad/ui"; - -export function ChangePasswordPage() { - return ( - <> - -
-
-

{t("user.change_password")}

-
-
-
-
- -
-
- - ); -} diff --git a/frontend/app/module/account/page/LoginPage.tsx b/frontend/app/module/account/page/LoginPage.tsx index b22c96b47..cda8308eb 100644 --- a/frontend/app/module/account/page/LoginPage.tsx +++ b/frontend/app/module/account/page/LoginPage.tsx @@ -6,10 +6,10 @@ import { PageTitle } from "@kiesraad/ui"; export function LoginPage() { return ( <> - +
-

{t("user.login")}

+

{t("account.login")}

diff --git a/frontend/app/module/account/page/UserHomePage.tsx b/frontend/app/module/account/page/UserHomePage.tsx index 947c76d7a..4bfa40d58 100644 --- a/frontend/app/module/account/page/UserHomePage.tsx +++ b/frontend/app/module/account/page/UserHomePage.tsx @@ -1,22 +1,29 @@ -import { Link } from "react-router"; +import { Link, Navigate } from "react-router"; +import { useUser } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { PageTitle } from "@kiesraad/ui"; export function UserHomePage() { + const user = useUser(); + + if (!user) { + return ; + } + return ( <> - +
-

{t("user.account")}

+

{t("account.account")}

  • - {t("user.login")} + {t("account.login")}
diff --git a/frontend/app/module/apportionment/page/Apportionment.module.css b/frontend/app/module/apportionment/page/Apportionment.module.css index 5a168aa0e..8965fa7ba 100644 --- a/frontend/app/module/apportionment/page/Apportionment.module.css +++ b/frontend/app/module/apportionment/page/Apportionment.module.css @@ -14,3 +14,7 @@ width: 39rem; margin-bottom: 2rem; } + +.absolute-majority-change-information { + width: 53rem; +} diff --git a/frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.test.tsx b/frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.test.tsx similarity index 86% rename from frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.test.tsx rename to frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.test.tsx index 3ff508e27..39fe8b5a6 100644 --- a/frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.test.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.test.tsx @@ -8,33 +8,33 @@ import { ApportionmentProvider, ElectionApportionmentResponse, ElectionProvider, import { getElectionMockData } from "@kiesraad/api-mocks"; import { expectErrorPage, overrideOnce, Providers, render, screen, setupTestRouter } from "@kiesraad/test"; -import { ApportionmentWholeSeatsPage } from "./ApportionmentWholeSeatsPage"; +import { ApportionmentFullSeatsPage } from "./ApportionmentFullSeatsPage"; -const renderApportionmentWholeSeatsPage = () => +const renderApportionmentFullSeatsPage = () => render( - + , ); -describe("ApportionmentWholeSeatsPage", () => { - test("Whole seats allocation and residual seats calculation tables visible", async () => { +describe("ApportionmentFullSeatsPage", () => { + test("Full seats allocation and residual seats calculation tables visible", async () => { overrideOnce("get", "/api/elections/1", 200, getElectionMockData(election)); overrideOnce("post", "/api/elections/1/apportionment", 200, { apportionment: apportionment, election_summary: election_summary, } satisfies ElectionApportionmentResponse); - renderApportionmentWholeSeatsPage(); + renderApportionmentFullSeatsPage(); expect(await screen.findByRole("heading", { level: 1, name: "Verdeling van de volle zetels" })); expect(await screen.findByRole("heading", { level: 2, name: "Hoe vaak haalde elke partij de kiesdeler?" })); - const whole_seats_table = await screen.findByTestId("whole_seats_table"); - expect(whole_seats_table).toBeVisible(); - expect(whole_seats_table).toHaveTableContent([ + const full_seats_table = await screen.findByTestId("full_seats_table"); + expect(full_seats_table).toBeVisible(); + expect(full_seats_table).toHaveTableContent([ ["Lijst", "Lijstnaam", "Aantal stemmen", ":", "Kiesdeler", "=", "Aantal volle zetels"], ["1", "Political Group A", "808", ":", "80", "", "=", "10"], ["2", "Political Group B", "60", ":", "80", "", "=", "0"], @@ -65,7 +65,7 @@ describe("ApportionmentWholeSeatsPage", () => { reference: "ApportionmentNotAvailableUntilDataEntryFinalised", } satisfies ErrorResponse); - renderApportionmentWholeSeatsPage(); + renderApportionmentFullSeatsPage(); // Wait for the page to be loaded expect(await screen.findByRole("heading", { level: 1, name: "Verdeling van de volle zetels" })); @@ -75,7 +75,7 @@ describe("ApportionmentWholeSeatsPage", () => { await screen.findByText("De zetelverdeling kan pas gemaakt worden als alle stembureaus zijn ingevoerd"), ).toBeVisible(); - expect(screen.queryByTestId("whole_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("full_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("residual_seats_calculation_table")).not.toBeInTheDocument(); }); @@ -87,7 +87,7 @@ describe("ApportionmentWholeSeatsPage", () => { reference: "DrawingOfLotsRequired", } satisfies ErrorResponse); - renderApportionmentWholeSeatsPage(); + renderApportionmentFullSeatsPage(); // Wait for the page to be loaded expect(await screen.findByRole("heading", { level: 1, name: "Verdeling van de volle zetels" })); @@ -97,7 +97,7 @@ describe("ApportionmentWholeSeatsPage", () => { await screen.findByText("Loting is noodzakelijk, maar nog niet beschikbaar in deze versie van Abacus"), ).toBeVisible(); - expect(screen.queryByTestId("whole_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("full_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("residual_seats_calculation_table")).not.toBeInTheDocument(); }); @@ -115,7 +115,7 @@ describe("ApportionmentWholeSeatsPage", () => { reference: "InternalServerError", }); - await router.navigate("/elections/1/apportionment/details-whole-seats"); + await router.navigate("/elections/1/apportionment/details-full-seats"); rtlRender(); diff --git a/frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.tsx b/frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.tsx similarity index 85% rename from frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.tsx rename to frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.tsx index cd54ca466..011567d86 100644 --- a/frontend/app/module/apportionment/page/ApportionmentWholeSeatsPage.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentFullSeatsPage.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; -import { ResidualSeatsCalculationTable, WholeSeatsTable } from "app/component/apportionment"; +import { FullSeatsTable, ResidualSeatsCalculationTable } from "app/component/apportionment"; import { useApportionmentContext, useElection } from "@kiesraad/api"; import { t, tx } from "@kiesraad/i18n"; @@ -8,7 +8,7 @@ import { Alert, FormLayout, PageTitle } from "@kiesraad/ui"; import cls from "./Apportionment.module.css"; -export function ApportionmentWholeSeatsPage() { +export function ApportionmentFullSeatsPage() { const { election } = useElection(); const { apportionment, error } = useApportionmentContext(); @@ -17,7 +17,7 @@ export function ApportionmentWholeSeatsPage() {
-

{t("apportionment.details_whole_seats")}

+

{t("apportionment.details_full_seats")}

@@ -34,8 +34,8 @@ export function ApportionmentWholeSeatsPage() { <>

{t("apportionment.how_often_is_quota_met")}

- {t("apportionment.whole_seats_information")} - {t("apportionment.full_seats_information")} + = 19 ? "averages" : "surpluses"}`, + `apportionment.residual_seats_information_largest_${apportionment.seats >= 19 ? "averages" : "remainders"}`, )}
diff --git a/frontend/app/module/apportionment/page/ApportionmentPage.test.tsx b/frontend/app/module/apportionment/page/ApportionmentPage.test.tsx index 1e419a2ee..cde93db72 100644 --- a/frontend/app/module/apportionment/page/ApportionmentPage.test.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentPage.test.tsx @@ -65,7 +65,7 @@ describe("ApportionmentPage", () => { expect(links[0]).toHaveTextContent("10 zetels werden als volle zetel toegewezen"); expect(within(links[0] as HTMLElement).getByRole("link", { name: "bekijk details" })).toHaveAttribute( "href", - "/details-whole-seats", + "/details-full-seats", ); expect(links[1]).toHaveTextContent("5 zetels werden als restzetel toegewezen"); expect(within(links[1] as HTMLElement).getByRole("link", { name: "bekijk details" })).toHaveAttribute( diff --git a/frontend/app/module/apportionment/page/ApportionmentPage.tsx b/frontend/app/module/apportionment/page/ApportionmentPage.tsx index aed863c1c..918b5f42c 100644 --- a/frontend/app/module/apportionment/page/ApportionmentPage.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentPage.tsx @@ -8,7 +8,7 @@ import { Alert, FormLayout, PageTitle } from "@kiesraad/ui"; import cls from "./Apportionment.module.css"; -function get_number_of_seats_assigned_sentence(seats: number, type: "residual_seat" | "whole_seat"): string { +function get_number_of_seats_assigned_sentence(seats: number, type: "residual_seat" | "full_seat"): string { return t(`apportionment.seats_assigned.${seats > 1 ? "plural" : "singular"}`, { num_seat: seats, type_seat: t(`apportionment.${type}.singular`).toLowerCase(), @@ -54,14 +54,14 @@ export function ApportionmentPage() {
  • - {get_number_of_seats_assigned_sentence(apportionment.whole_seats, "whole_seat")} ( - {t("apportionment.view_details")}) + {get_number_of_seats_assigned_sentence(apportionment.full_seats, "full_seat")} ( + {t("apportionment.view_details")})
  • {get_number_of_seats_assigned_sentence(apportionment.residual_seats, "residual_seat")} ( diff --git a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx index 868c2b98b..4f7845ca5 100644 --- a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.test.tsx @@ -6,11 +6,16 @@ import { election as election_19_or_more_seats, election_summary as election_summary_19_or_more_seats, } from "app/component/apportionment/test-data/19-or-more-seats"; +import { + apportionment as apportionment_absolute_majority_change, + election as election_absolute_majority_change, + election_summary as election_summary_absolute_majority_change, +} from "app/component/apportionment/test-data/absolute-majority-change"; import { apportionment as apportionment_less_than_19_seats, election as election_less_than_19_seats, election_summary as election_summary_less_than_19_seats, - highest_surplus_steps, + largest_remainder_steps, } from "app/component/apportionment/test-data/less-than-19-seats"; import { routes } from "app/routes"; @@ -61,8 +66,9 @@ describe("ApportionmentResidualSeatsPage", () => { ["", "Restzetel toegekend aan lijst", "5", "2", "1", "4", ""], ]); - expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("largest_remainders_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Residual seats allocation tables for less than 19 seats with both systems visible", async () => { @@ -82,9 +88,9 @@ describe("ApportionmentResidualSeatsPage", () => { name: "De restzetels gaan naar de partijen met de grootste overschotten", }), ); - const largest_surpluses_table = await screen.findByTestId("largest_surpluses_table"); - expect(largest_surpluses_table).toBeVisible(); - expect(largest_surpluses_table).toHaveTableContent([ + const largest_remainders_table = await screen.findByTestId("largest_remainders_table"); + expect(largest_remainders_table).toBeVisible(); + expect(largest_remainders_table).toHaveTableContent([ ["Lijst", "Lijstnaam", "Aantal volle zetels", "Overschot", "Aantal restzetels"], ["1", "Political Group A", "10", "8", "", "1"], ["2", "Political Group B", "0", "60", "", "1"], @@ -108,14 +114,15 @@ describe("ApportionmentResidualSeatsPage", () => { ]); expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); - test("Residual seats allocation tables for less than 19 seats with only surplus system visible", async () => { + test("Residual seats allocation tables for less than 19 seats with only remainder system visible", async () => { overrideOnce("get", "/api/elections/1", 200, getElectionMockData(election_less_than_19_seats)); overrideOnce("post", "/api/elections/1/apportionment", 200, { apportionment: { ...apportionment_less_than_19_seats, - steps: highest_surplus_steps, + steps: largest_remainder_steps, }, election_summary: election_summary_less_than_19_seats, } satisfies ElectionApportionmentResponse); @@ -130,14 +137,51 @@ describe("ApportionmentResidualSeatsPage", () => { name: "De restzetels gaan naar de partijen met de grootste overschotten", }), ); - const largest_surpluses_table = await screen.findByTestId("largest_surpluses_table"); - expect(largest_surpluses_table).toBeVisible(); - expect(largest_surpluses_table).toHaveTableContent([ + const largest_remainders_table = await screen.findByTestId("largest_remainders_table"); + expect(largest_remainders_table).toBeVisible(); + expect(largest_remainders_table).toHaveTableContent([ ["Lijst", "Lijstnaam", "Aantal volle zetels", "Overschot", "Aantal restzetels"], ["1", "Political Group A", "10", "8", "", "1"], ["2", "Political Group B", "0", "60", "", "1"], ]); + expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); + }); + + test("Residual seats allocation table for less than 19 seats and absolute majority change information visible", async () => { + overrideOnce("get", "/api/elections/1", 200, getElectionMockData(election_absolute_majority_change)); + overrideOnce("post", "/api/elections/1/apportionment", 200, { + apportionment: apportionment_absolute_majority_change, + election_summary: election_summary_absolute_majority_change, + } satisfies ElectionApportionmentResponse); + + renderApportionmentResidualSeatsPage(); + + expect(await screen.findByRole("heading", { level: 1, name: "Verdeling van de restzetels" })); + + expect( + await screen.findByRole("heading", { + level: 2, + name: "De restzetels gaan naar de partijen met de grootste overschotten", + }), + ); + const largest_remainders_table = await screen.findByTestId("largest_remainders_table"); + expect(largest_remainders_table).toBeVisible(); + expect(largest_remainders_table).toHaveTableContent([ + ["Lijst", "Lijstnaam", "Aantal volle zetels", "Overschot", "Aantal restzetels"], + ["1", "Political Group A", "7", "189", "2/15", "0"], + ["2", "Political Group B", "2", "296", "7/15", "1"], + ["3", "Political Group C", "1", "226", "11/15", "1"], + ["4", "Political Group D", "1", "195", "11/15", "1"], + ["5", "Political Group E", "1", "112", "11/15", "0"], + ]); + + expect(await screen.findByTestId("absolute_majority_change_information")).toHaveTextContent( + "Overeenkomstig artikel P 9 van de Kieswet (volstrekte meerderheid) wordt aan lijst 1 alsnog één zetel toegewezen en vervalt daartegenover één zetel, die eerder was toegewezen aan lijst 4.", + ); + expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); }); @@ -162,8 +206,9 @@ describe("ApportionmentResidualSeatsPage", () => { ).toBeVisible(); expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); - expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("largest_remainders_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Not available because drawing of lots is not implemented yet", async () => { @@ -185,8 +230,9 @@ describe("ApportionmentResidualSeatsPage", () => { ).toBeVisible(); expect(screen.queryByTestId("largest_averages_for_19_or_more_seats_table")).not.toBeInTheDocument(); - expect(screen.queryByTestId("largest_surpluses_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("largest_remainders_table")).not.toBeInTheDocument(); expect(screen.queryByTestId("largest_averages_for_less_than_19_seats_table")).not.toBeInTheDocument(); + expect(screen.queryByTestId("absolute_majority_change_information")).not.toBeInTheDocument(); }); test("Internal Server Error renders error page", async () => { diff --git a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx index 9f1631d46..311467d78 100644 --- a/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx +++ b/frontend/app/module/apportionment/page/ApportionmentResidualSeatsPage.tsx @@ -3,10 +3,10 @@ import { Link } from "react-router"; import { LargestAveragesFor19OrMoreSeatsTable, LargestAveragesForLessThan19SeatsTable, - LargestSurplusesTable, + LargestRemaindersTable, } from "app/component/apportionment"; -import { useApportionmentContext, useElection } from "@kiesraad/api"; +import { AbsoluteMajorityChange, useApportionmentContext, useElection } from "@kiesraad/api"; import { t, tx } from "@kiesraad/i18n"; import { Alert, FormLayout, PageTitle } from "@kiesraad/ui"; @@ -25,19 +25,19 @@ function render_title_and_header() { ); } -function render_information(seats: number, residual_seats: number) { +function render_information(seats: number, residualSeats: number) { return ( {tx( - `apportionment.whole_seats_information_link.${residual_seats > 1 ? "plural" : "singular"}`, + `apportionment.full_seats_information_link.${residualSeats > 1 ? "plural" : "singular"}`, { - link: (title) => {title}, + link: (title) => {title}, }, - { num_residual_seats: residual_seats }, + { num_residual_seats: residualSeats }, )}

    - {tx(`apportionment.information_largest_${seats >= 19 ? "averages" : "surpluses"}`)} + {tx(`apportionment.information_largest_${seats >= 19 ? "averages" : "remainders"}`)}
    ); } @@ -64,59 +64,72 @@ export function ApportionmentResidualSeatsPage() { ); } if (apportionment) { - const highestSurplusSteps = apportionment.steps.filter((step) => step.change.assigned_by === "HighestSurplus"); - const highestAverageSteps = apportionment.steps.filter((step) => step.change.assigned_by === "HighestAverage"); + const largestRemainderSteps = apportionment.steps.filter((step) => step.change.assigned_by === "LargestRemainder"); + const largestAverageSteps = apportionment.steps.filter((step) => step.change.assigned_by === "LargestAverage"); + const absoluteMajorityChange = apportionment.steps + .map((step) => step.change) + .find((change) => change.assigned_by === "AbsoluteMajorityChange") as AbsoluteMajorityChange | undefined; return ( <> {render_title_and_header()}
    {apportionment.residual_seats > 0 ? ( - apportionment.seats >= 19 ? ( -
    -

    {t("apportionment.residual_seats_largest_averages")}

    - {render_information(apportionment.seats, apportionment.residual_seats)} - {highestAverageSteps.length > 0 && ( - - )} -
    - ) : ( - <> + <> + {apportionment.seats >= 19 ? (
    -

    {t("apportionment.residual_seats_largest_surpluses")}

    +

    {t("apportionment.residual_seats_largest_averages")}

    {render_information(apportionment.seats, apportionment.residual_seats)} - {highestSurplusSteps.length > 0 && ( - 0 && ( + )}
    - {highestAverageSteps.length > 0 && ( + ) : ( + <>
    -

    {t("apportionment.leftover_residual_seats_assignment")}

    - - {t( - `apportionment.leftover_residual_seats_amount_and_information.${highestAverageSteps.length > 1 ? "plural" : "singular"}`, - { num_seats: highestAverageSteps.length }, - )} - - { - {t("apportionment.residual_seats_largest_remainders")} + {render_information(apportionment.seats, apportionment.residual_seats)} + {largestRemainderSteps.length > 0 && ( + - } + )}
    - )} - - ) + {largestAverageSteps.length > 0 && ( +
    +

    {t("apportionment.remaining_residual_seats_assignment")}

    + + {t( + `apportionment.remaining_residual_seats_amount_and_information.${largestAverageSteps.length > 1 ? "plural" : "singular"}`, + { num_seats: largestAverageSteps.length }, + )} + + { + + } +
    + )} + + )} + {absoluteMajorityChange && ( + + {t("apportionment.absolute_majority_change", { + pg_assigned_seat: absoluteMajorityChange.pg_assigned_seat, + pg_retracted_seat: absoluteMajorityChange.pg_retracted_seat, + })} + + )} + ) : ( {t("apportionment.no_residual_seats_to_assign")} )} diff --git a/frontend/app/module/apportionment/page/index.ts b/frontend/app/module/apportionment/page/index.ts index 8270c5714..0a8a7cbad 100644 --- a/frontend/app/module/apportionment/page/index.ts +++ b/frontend/app/module/apportionment/page/index.ts @@ -1,3 +1,3 @@ +export * from "./ApportionmentFullSeatsPage"; export * from "./ApportionmentPage"; export * from "./ApportionmentResidualSeatsPage"; -export * from "./ApportionmentWholeSeatsPage"; diff --git a/frontend/app/module/data_entry/page/DataEntryHomePage.test.tsx b/frontend/app/module/data_entry/page/DataEntryHomePage.test.tsx index d3a0b46cd..dbb9cacb1 100644 --- a/frontend/app/module/data_entry/page/DataEntryHomePage.test.tsx +++ b/frontend/app/module/data_entry/page/DataEntryHomePage.test.tsx @@ -5,14 +5,14 @@ import { beforeEach, describe, expect, test } from "vitest"; import { routes } from "app/routes"; -import { ElectionProvider, ElectionStatusProvider, ElectionStatusResponse } from "@kiesraad/api"; +import { ElectionProvider, ElectionStatusProvider } from "@kiesraad/api"; import { electionDetailsMockResponse, ElectionListRequestHandler, ElectionRequestHandler, ElectionStatusRequestHandler, } from "@kiesraad/api-mocks"; -import { overrideOnce, Providers, render, screen, server, setupTestRouter, waitFor, within } from "@kiesraad/test"; +import { overrideOnce, Providers, render, screen, server, setupTestRouter, within } from "@kiesraad/test"; import { DataEntryHomePage } from "./DataEntryHomePage"; @@ -97,52 +97,6 @@ describe("DataEntryHomePage", () => { expect(screen.queryByRole("alert")).toBeNull(); }); - test("Rerender re-fetches election status", async () => { - overrideOnce("get", "/api/elections/1/status", 200, { - statuses: [ - { polling_station_id: 1, status: "first_entry_not_started" }, - { polling_station_id: 2, status: "first_entry_not_started" }, - ], - } satisfies ElectionStatusResponse); - - // render and expect the initial status to be fetched - const { rerender } = renderDataEntryHomePage(); - await waitFor(() => { - expect(screen.queryByText("Welk stembureau ga je invoeren?")).not.toBeInTheDocument(); - }); - expect(screen.queryByText("Alle stembureaus zijn ingevoerd")).not.toBeInTheDocument(); - - // unmount DataEntryHomePage, but keep the providers as-is - rerender( - - - <> - - , - ); - await waitFor(() => { - expect(screen.queryByText("Welk stembureau ga je invoeren?")).not.toBeInTheDocument(); - }); - - // new status is that all polling stations are definitive, so the alert should be visible - overrideOnce("get", "/api/elections/1/status", 200, { - statuses: [ - { polling_station_id: 1, status: "definitive" }, - { polling_station_id: 2, status: "definitive" }, - ], - } satisfies ElectionStatusResponse); - - // rerender DataEntryHomePage and expect the new status to be fetched - rerender( - - - - - , - ); - expect(screen.queryByText("Alle stembureaus zijn ingevoerd")).not.toBeInTheDocument(); - }); - test("Data entry saved alert works", async () => { const user = userEvent.setup(); diff --git a/frontend/app/module/election/page/ElectionHomePage.test.tsx b/frontend/app/module/election/page/ElectionHomePage.test.tsx index 527868f88..88776091c 100644 --- a/frontend/app/module/election/page/ElectionHomePage.test.tsx +++ b/frontend/app/module/election/page/ElectionHomePage.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from "vitest"; import { ElectionHomePage } from "app/module/election"; -import { ElectionProvider, ElectionStatusProvider } from "@kiesraad/api"; +import { ElectionProvider, ElectionStatusProvider, TestUserProvider } from "@kiesraad/api"; import { ElectionRequestHandler } from "@kiesraad/api-mocks"; import { overrideOnce, render, screen, server } from "@kiesraad/test"; @@ -17,11 +17,13 @@ describe("ElectionHomePage", () => { }); render( - - - - - , + + + + + + + , ); // Wait for the page to be loaded @@ -34,8 +36,5 @@ describe("ElectionHomePage", () => { expect(list.children[0]?.children[0]?.children[0]).toHaveTextContent("Stembureaus"); expect(list.children[0]?.children[0]?.children[1]).toHaveTextContent("Statusoverzicht"); expect(list.children[0]?.children[0]?.children[2]).toHaveTextContent("Zetelverdeling"); - expect(list.children[1]).toHaveTextContent("Invoerder:"); - expect(list.children[1]?.children[0]?.childElementCount).toBe(1); - expect(list.children[1]?.children[0]?.children[0]).toHaveTextContent("Invoeren"); }); }); diff --git a/frontend/app/module/election/page/ElectionHomePage.tsx b/frontend/app/module/election/page/ElectionHomePage.tsx index 76cefceaa..7eafaeb92 100644 --- a/frontend/app/module/election/page/ElectionHomePage.tsx +++ b/frontend/app/module/election/page/ElectionHomePage.tsx @@ -1,14 +1,19 @@ -import { Link } from "react-router"; +import { Link, Navigate } from "react-router"; import { Footer } from "app/component/footer/Footer"; -import { useElection } from "@kiesraad/api"; +import { useElection, useUserRole } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { PageTitle } from "@kiesraad/ui"; export function ElectionHomePage() { + const { isTypist } = useUserRole(); const { election } = useElection(); + if (isTypist) { + return ; + } + return ( <> @@ -24,23 +29,18 @@ export function ElectionHomePage() { {t("coordinator")}:
    • - {t("polling_station.title.plural")} + {t("polling_station.title.plural")}
    • - {t("election_status.main_title")} + {t("election_status.main_title")}
    • - {t("apportionment.title")} + {t("apportionment.title")}
  • - {t("typist")}: -
      -
    • - {t("data_entry.title")} -
    • -
    + {t("polling_station.title.plural")}
diff --git a/frontend/app/module/election/page/ElectionStatusPage.tsx b/frontend/app/module/election/page/ElectionStatusPage.tsx index 36daf1a19..8a05a4521 100644 --- a/frontend/app/module/election/page/ElectionStatusPage.tsx +++ b/frontend/app/module/election/page/ElectionStatusPage.tsx @@ -14,7 +14,7 @@ export function ElectionStatusPage() { const { statuses } = useElectionStatus(); function finishInput() { - void navigate("../report#coordinator"); + void navigate("../report"); } return ( diff --git a/frontend/app/module/election/page/OverviewPage.tsx b/frontend/app/module/election/page/OverviewPage.tsx index 87a54f691..ec9b68e0b 100644 --- a/frontend/app/module/election/page/OverviewPage.tsx +++ b/frontend/app/module/election/page/OverviewPage.tsx @@ -4,7 +4,7 @@ import { ElectionStatusWithIcon } from "app/component/election/ElectionStatusWit import { Footer } from "app/component/footer/Footer"; import { NavBar } from "app/component/navbar/NavBar"; -import { Election, useElectionList } from "@kiesraad/api"; +import { Election, useElectionList, useUserRole } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Alert, PageTitle, Table } from "@kiesraad/ui"; @@ -12,13 +12,14 @@ export function OverviewPage() { const navigate = useNavigate(); const location = useLocation(); const { electionList } = useElectionList(); + const { isAdministrator, isCoordinator } = useUserRole(); const isNewAccount = location.hash === "#new-account"; - const isAdminOrCoordinator = location.hash.includes("administrator") || location.hash.includes("coordinator"); + const isAdminOrCoordinator = isAdministrator || isCoordinator; function electionLink(election: Election): To { if (isAdminOrCoordinator) { - return `/elections/${election.id}#administratorcoordinator`; + return `/elections/${election.id}`; } else { return `/elections/${election.id}/data-entry`; } diff --git a/frontend/app/module/users/UserListPage.tsx b/frontend/app/module/users/UserListPage.tsx index 0faf759f4..8de5570cf 100644 --- a/frontend/app/module/users/UserListPage.tsx +++ b/frontend/app/module/users/UserListPage.tsx @@ -9,6 +9,8 @@ import { formatDateTime, useQueryParam } from "@kiesraad/util"; export function UserListPage() { const { requestState } = useUserListRequest(); const [createdMessage, clearCreatedMessage] = useQueryParam("created"); + const [updatedMessage, clearUpdatedMessage] = useQueryParam("updated"); + const [deletedMessage, clearDeletedMessage] = useQueryParam("deleted"); if (requestState.status === "loading") { return ; @@ -45,6 +47,20 @@ export function UserListPage() { )} + {updatedMessage && ( + +

{t("users.user_updated")}

+

{updatedMessage}

+
+ )} + + {deletedMessage && ( + +

{t("users.user_deleted")}

+

{deletedMessage}

+
+ )} +
@@ -65,7 +81,7 @@ export function UserListPage() { {user.username} {t(user.role)} - {user.fullname ?? {t("users.not_used")}} + {user.fullname || {t("users.not_used")}} {user.last_activity_at ? formatDateTime(new Date(user.last_activity_at)) : "–"} diff --git a/frontend/app/module/users/create/UserCreateDetailsPage.tsx b/frontend/app/module/users/create/UserCreateDetailsPage.tsx index 60932ebc8..3d49f573d 100644 --- a/frontend/app/module/users/create/UserCreateDetailsPage.tsx +++ b/frontend/app/module/users/create/UserCreateDetailsPage.tsx @@ -5,12 +5,11 @@ import { CreateUserRequest, isSuccess, Role } from "@kiesraad/api"; import { t } from "@kiesraad/i18n"; import { Alert, Button, Form, FormLayout, InputField, PageTitle } from "@kiesraad/ui"; +import { MIN_PASSWORD_LENGTH, validatePassword } from "../validatePassword"; import { useUserCreateContext } from "./useUserCreateContext"; type ValidationErrors = Partial; -const MIN_PASSWORD_LENGTH = 12; - export function UserCreateDetailsPage() { const navigate = useNavigate(); const { role, type, username, createUser, apiError, saving } = useUserCreateContext(); @@ -59,10 +58,9 @@ export function UserCreateDetailsPage() { errors.fullname = required; } - if (user.temp_password.length === 0) { - errors.temp_password = required; - } else if (user.temp_password.length < MIN_PASSWORD_LENGTH) { - errors.temp_password = t("users.temporary_password_error_min_length", { min_length: MIN_PASSWORD_LENGTH }); + const passwordError = validatePassword(user.temp_password); + if (passwordError) { + errors.temp_password = passwordError; } const isValid = Object.keys(errors).length === 0; diff --git a/frontend/app/module/users/index.ts b/frontend/app/module/users/index.ts index 704f52d00..87efaf620 100644 --- a/frontend/app/module/users/index.ts +++ b/frontend/app/module/users/index.ts @@ -1,2 +1,3 @@ export * from "./UserListPage"; export * from "./create"; +export * from "./update"; diff --git a/frontend/app/module/users/update/UserDelete.test.tsx b/frontend/app/module/users/update/UserDelete.test.tsx new file mode 100644 index 000000000..9429bc0e3 --- /dev/null +++ b/frontend/app/module/users/update/UserDelete.test.tsx @@ -0,0 +1,40 @@ +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { render } from "@kiesraad/test"; + +import { UserDelete } from "./UserDelete"; + +function renderComponent(saving = false) { + const onDelete = vi.fn(); + render(); + return { onDelete }; +} + +describe("UserDelete", () => { + test("delete after confirm", async () => { + const { onDelete } = renderComponent(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Gebruiker verwijderen" })); + + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Verwijderen" })); + expect(onDelete).toHaveBeenCalledOnce(); + }); + + test("cancel delete", async () => { + const { onDelete } = renderComponent(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Gebruiker verwijderen" })); + + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + + await user.click(screen.getAllByRole("button", { name: "Annuleren" })[0]!); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(onDelete).not.toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/app/module/users/update/UserDelete.tsx b/frontend/app/module/users/update/UserDelete.tsx new file mode 100644 index 000000000..bdd98ca36 --- /dev/null +++ b/frontend/app/module/users/update/UserDelete.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; + +import { t } from "@kiesraad/i18n"; +import { IconTrash } from "@kiesraad/icon"; +import { Button, Modal } from "@kiesraad/ui"; + +interface UserDeleteProps { + onDelete: () => void; + saving: boolean; +} + +export function UserDelete({ onDelete, saving }: UserDeleteProps) { + const [showModal, setShowModal] = useState(false); + + function toggleModal() { + setShowModal(!showModal); + } + + return ( + <> + + + {showModal && ( + +

{t("users.delete_are_you_sure")}

+ +
+ )} + + ); +} diff --git a/frontend/app/module/users/update/UserUpdateForm.test.tsx b/frontend/app/module/users/update/UserUpdateForm.test.tsx new file mode 100644 index 000000000..634fbc2cd --- /dev/null +++ b/frontend/app/module/users/update/UserUpdateForm.test.tsx @@ -0,0 +1,107 @@ +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; + +import { User } from "@kiesraad/api"; +import { render } from "@kiesraad/test"; + +import { UserUpdateForm } from "./UserUpdateForm"; + +async function renderForm(user: Partial = {}, saving = false) { + const onSave = vi.fn(); + const onAbort = vi.fn(); + + render( + , + ); + + expect(await screen.findByRole("heading", { name: "Details van het account" })).toBeInTheDocument(); + return { onSave, onAbort }; +} + +describe("UserUpdateForm", () => { + test("renders username and role", async () => { + await renderForm(); + expect(await screen.findByText("Gebruiker01")).toBeInTheDocument(); + expect(await screen.findByText("Invoerder")).toBeInTheDocument(); + }); + + test("fullname field", async () => { + const { onSave } = await renderForm({ fullname: "Voor en Achternaam" }); + const fullnameInput = screen.getByLabelText("Volledige naam"); + expect(fullnameInput).toBeInTheDocument(); + expect(fullnameInput).toHaveValue("Voor en Achternaam"); + + const user = userEvent.setup(); + await user.clear(fullnameInput); + await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" })); + + expect(onSave).not.toHaveBeenCalled(); + expect(fullnameInput).toBeInvalid(); + expect(fullnameInput).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn"); + + await user.type(fullnameInput, "Nieuwe Naam"); + await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" })); + + expect(onSave).toHaveBeenCalledExactlyOnceWith({ + fullname: "Nieuwe Naam", + }); + }); + + test("without fullname", async () => { + const { onSave } = await renderForm({ fullname: undefined }); + expect(screen.queryByLabelText("Volledige naam")).not.toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" })); + + expect(onSave).toHaveBeenCalledExactlyOnceWith({}); + }); + + test("password field", async () => { + const { onSave } = await renderForm(); + + const user = userEvent.setup(); + expect(screen.queryByLabelText("Nieuw wachtwoord")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Wijzig wachtwoord" })); + + const passwordInput = await screen.findByLabelText("Nieuw wachtwoord"); + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput).toHaveValue(""); + + expect(screen.queryByRole("button", { name: "Wijzig wachtwoord" })).not.toBeInTheDocument(); + + const save = screen.getByRole("button", { name: "Wijzigingen opslaan" }); + await user.click(save); + + expect(passwordInput).toBeInvalid(); + expect(passwordInput).toHaveAccessibleErrorMessage("Dit veld mag niet leeg zijn"); + + await user.type(passwordInput, "Vol"); + await user.click(save); + expect(passwordInput).toHaveAccessibleErrorMessage( + "Dit wachtwoord is niet lang genoeg. Gebruik minimaal 12 karakters", + ); + + await user.type(passwordInput, "doendeKarakters01"); + await user.click(save); + + expect(passwordInput).not.toBeInvalid(); + expect(onSave).toHaveBeenCalledExactlyOnceWith({ + temp_password: "VoldoendeKarakters01", + }); + }); + + test("abort update", async () => { + const { onAbort, onSave } = await renderForm(); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Annuleren" })); + expect(onAbort).toHaveBeenCalledOnce(); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/app/module/users/update/UserUpdateForm.tsx b/frontend/app/module/users/update/UserUpdateForm.tsx new file mode 100644 index 000000000..57bc0f1e6 --- /dev/null +++ b/frontend/app/module/users/update/UserUpdateForm.tsx @@ -0,0 +1,117 @@ +import { FormEvent, useState } from "react"; + +import { UpdateUserRequest, User } from "@kiesraad/api"; +import { t } from "@kiesraad/i18n"; +import { IconPencil } from "@kiesraad/icon"; +import { Button, Form, FormLayout, InputField } from "@kiesraad/ui"; + +import { MIN_PASSWORD_LENGTH, validatePassword } from "../validatePassword"; + +export interface UserUpdateFormProps { + user: User; + onSave: (userUpdate: UpdateUserRequest) => void; + onAbort: () => void; + saving: boolean; +} + +type ValidationErrors = Partial; + +export function UserUpdateForm({ user, onSave, onAbort, saving }: UserUpdateFormProps) { + const [editPassword, setEditPassword] = useState(false); + const [validationErrors, setValidationErrors] = useState(); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + const userUpdate: UpdateUserRequest = {}; + const errors: ValidationErrors = {}; + + if (user.fullname) { + userUpdate.fullname = (formData.get("fullname") as string).trim(); + if (userUpdate.fullname.length === 0) { + errors.fullname = t("form_errors.FORM_VALIDATION_RESULT_REQUIRED"); + } + } + + if (editPassword) { + userUpdate.temp_password = formData.get("temp_password") as string; + const passwordError = validatePassword(userUpdate.temp_password); + if (passwordError) { + errors.temp_password = passwordError; + } + } + + const isValid = Object.keys(errors).length === 0; + setValidationErrors(isValid ? undefined : errors); + + if (isValid) { + onSave(userUpdate); + } + } + + return ( + <> +
+ + + + + {user.fullname && ( + + )} + + {editPassword ? ( + + ) : ( + + {t("users.change_password_hint")} + + + + )} + + {t(user.role)} + + + + + + + + + + ); +} diff --git a/frontend/app/module/users/update/UserUpdatePage.test.tsx b/frontend/app/module/users/update/UserUpdatePage.test.tsx new file mode 100644 index 000000000..d20fdb6f9 --- /dev/null +++ b/frontend/app/module/users/update/UserUpdatePage.test.tsx @@ -0,0 +1,51 @@ +import { screen } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { UserDeleteRequestHandler, UserGetRequestHandler, UserUpdateRequestHandler } from "@kiesraad/api-mocks"; +import { render, server } from "@kiesraad/test"; + +import { UserUpdatePage } from "./UserUpdatePage"; + +const navigate = vi.fn(); + +vi.mock(import("@kiesraad/util"), async (importOriginal) => ({ + ...(await importOriginal()), + useNumericParam: () => 1, +})); + +vi.mock(import("react-router"), async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); + +describe("UserUpdatePage", () => { + beforeEach(() => { + server.use(UserGetRequestHandler, UserUpdateRequestHandler, UserDeleteRequestHandler); + }); + + test("update user", async () => { + render(); + expect(await screen.findByRole("heading", { name: "Details van het account" })).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Wijzigingen opslaan" })); + + const expectedMessage = "De wijzigingen in het account van Sanne Molenaar zijn opgeslagen"; + expect(navigate).toHaveBeenCalledExactlyOnceWith(`/users?updated=${encodeURIComponent(expectedMessage)}`); + }); + + test("delete user", async () => { + render(); + expect(await screen.findByRole("heading", { name: "Details van het account" })).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Gebruiker verwijderen" })); + expect(await screen.findByRole("dialog")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Verwijderen" })); + + const expectedMessage = "Het account van Sanne Molenaar is verwijderd"; + expect(navigate).toHaveBeenCalledExactlyOnceWith(`/users?deleted=${encodeURIComponent(expectedMessage)}`); + }); +}); diff --git a/frontend/app/module/users/update/UserUpdatePage.tsx b/frontend/app/module/users/update/UserUpdatePage.tsx new file mode 100644 index 000000000..ce98f5ee5 --- /dev/null +++ b/frontend/app/module/users/update/UserUpdatePage.tsx @@ -0,0 +1,69 @@ +import { useNavigate } from "react-router"; + +import { UpdateUserRequest, useApiRequest, User, USER_GET_REQUEST_PATH } from "@kiesraad/api"; +import { t } from "@kiesraad/i18n"; +import { Alert, FormLayout, Loader, PageTitle } from "@kiesraad/ui"; +import { useNumericParam } from "@kiesraad/util"; + +import { UserDelete } from "./UserDelete"; +import { UserUpdateForm } from "./UserUpdateForm"; +import { useUserUpdate } from "./useUserUpdate"; + +export function UserUpdatePage() { + const navigate = useNavigate(); + const userId = useNumericParam("userId"); + const { requestState: getUser } = useApiRequest(`/api/user/${userId}` satisfies USER_GET_REQUEST_PATH); + const { error, update, remove, saving } = useUserUpdate(userId); + + if (getUser.status === "api-error") { + throw getUser.error; + } + + if (getUser.status === "loading") { + return ; + } + + const user = getUser.data; + + function handleSave(userUpdate: UpdateUserRequest) { + void update(userUpdate).then(({ fullname, username }) => { + const updatedMessage = t("users.user_updated_details", { fullname: fullname || username }); + void navigate(`/users?updated=${encodeURIComponent(updatedMessage)}`); + }); + } + + function handleDelete() { + void remove().then(() => { + const deletedMessage = t("users.user_deleted_details", { fullname: user.fullname || user.username }); + void navigate(`/users?deleted=${encodeURIComponent(deletedMessage)}`); + }); + } + + function handleAbort() { + void navigate("/users"); + } + + return ( + <> + +
+
+

{user.fullname || user.username}

+
+
+ +
+
+ {error && ( + + {error.message} + + )} + + + +
+
+ + ); +} diff --git a/frontend/app/module/users/update/index.ts b/frontend/app/module/users/update/index.ts new file mode 100644 index 000000000..a25dd5f78 --- /dev/null +++ b/frontend/app/module/users/update/index.ts @@ -0,0 +1 @@ +export * from "./UserUpdatePage"; diff --git a/frontend/app/module/users/update/useUserUpdate.ts b/frontend/app/module/users/update/useUserUpdate.ts new file mode 100644 index 000000000..be0be1a35 --- /dev/null +++ b/frontend/app/module/users/update/useUserUpdate.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +import { AnyApiError, isSuccess, UpdateUserRequest, useCrud, User, USER_UPDATE_REQUEST_PATH } from "@kiesraad/api"; + +export function useUserUpdate(userId: number) { + const [error, setError] = useState(); + const api = useCrud(`/api/user/${userId}` satisfies USER_UPDATE_REQUEST_PATH); + + const saving = api.requestState.status === "loading"; + + function update(userUpdate: UpdateUserRequest): Promise { + return api.update(userUpdate).then((result) => { + if (isSuccess(result)) { + return result.data; + } else { + setError(result); + window.scrollTo(0, 0); + + // Do not resolve Promise, error has been handled + throw result; + } + }); + } + + function remove(): Promise { + return api.remove().then((result) => { + if (!isSuccess(result)) { + setError(result); + window.scrollTo(0, 0); + + // Do not resolve Promise, error has been handled + throw result; + } + }); + } + + return { error, update, remove, saving }; +} diff --git a/frontend/app/module/users/validatePassword.ts b/frontend/app/module/users/validatePassword.ts new file mode 100644 index 000000000..2662fd1cc --- /dev/null +++ b/frontend/app/module/users/validatePassword.ts @@ -0,0 +1,13 @@ +import { t } from "@kiesraad/i18n"; + +export const MIN_PASSWORD_LENGTH = 12; + +export function validatePassword(password: string): string | undefined { + if (password.length === 0) { + return t("form_errors.FORM_VALIDATION_RESULT_REQUIRED"); + } else if (password.length < MIN_PASSWORD_LENGTH) { + return t("users.temporary_password_error_min_length", { min_length: MIN_PASSWORD_LENGTH }); + } + + return undefined; +} diff --git a/frontend/app/routes.tsx b/frontend/app/routes.tsx index 0d2fa6fe7..c4a7c2976 100644 --- a/frontend/app/routes.tsx +++ b/frontend/app/routes.tsx @@ -18,6 +18,7 @@ import { UserCreateRolePage, UserCreateTypePage, UserListPage, + UserUpdatePage, } from "app/module/users"; import { WorkstationsHomePage } from "app/module/workstations"; @@ -26,12 +27,12 @@ import { t } from "@kiesraad/i18n"; import { ErrorBoundary } from "./component/error/ErrorBoundary"; import { CheckAndSaveForm } from "./component/form/data_entry/check_and_save/CheckAndSaveForm"; import { AccountSetupPage, LoginLayout, LoginPage, UserHomePage } from "./module/account"; -import { ChangePasswordPage } from "./module/account/page/ChangePasswordPage"; +import { Logout } from "./module/account/Logout"; import { + ApportionmentFullSeatsPage, ApportionmentLayout, ApportionmentPage, ApportionmentResidualSeatsPage, - ApportionmentWholeSeatsPage, } from "./module/apportionment"; import { CandidatesVotesPage, @@ -54,8 +55,8 @@ export const routes = createRoutesFromElements( }> } /> } /> + } /> } /> - } /> }> } /> @@ -64,7 +65,7 @@ export const routes = createRoutesFromElements( }> } /> } /> - } /> + } /> } /> } /> + } /> } /> diff --git a/frontend/e2e-tests/authentication.e2e.ts b/frontend/e2e-tests/authentication.e2e.ts index 6dfc10f12..b2dad9955 100644 --- a/frontend/e2e-tests/authentication.e2e.ts +++ b/frontend/e2e-tests/authentication.e2e.ts @@ -1,32 +1,11 @@ import { expect, test } from "@playwright/test"; -import { createRandomUsername } from "./helpers-utils/e2e-test-utils"; - test.describe("authentication", () => { - // These tests only run in development mode (non-release backend builds) - // TODO: create user with normal production flow when available - // eslint-disable-next-line playwright/no-skipped-test - test.skip(process.env.BACKEND_BUILD === "release"); - - test("login happy path", async ({ page, request }) => { - const username = createRandomUsername(); - const password = "password_test"; - // create a test user - const createUserResponse = await request.post("/api/user/development/create", { - headers: { - "Content-Type": "application/json", - }, - data: { - username, - password, - }, - }); - expect(createUserResponse.status()).toBe(201); - + test("login happy path", async ({ page }) => { await page.goto("/account/login"); - await page.getByLabel("Gebruikersnaam").fill(username); - await page.getByLabel("Wachtwoord").fill(password); + await page.getByLabel("Gebruikersnaam").fill("admin"); + await page.getByLabel("Wachtwoord").fill("AdminPassword01"); await page.getByRole("button", { name: "Inloggen" }).click(); await page.waitForURL("/account/setup"); @@ -38,7 +17,7 @@ test.describe("authentication", () => { test("login unhappy path", async ({ page }) => { await page.goto("/account/login"); - await page.getByLabel("Gebruikersnaam").fill("user"); + await page.getByLabel("Gebruikersnaam").fill("admin"); await page.getByLabel("Wachtwoord").fill("wrong-password"); await page.getByRole("button", { name: "Inloggen" }).click(); diff --git a/frontend/e2e-tests/data-entry.e2e.ts b/frontend/e2e-tests/data-entry.e2e.ts index dd7fc45fd..90bc165fb 100644 --- a/frontend/e2e-tests/data-entry.e2e.ts +++ b/frontend/e2e-tests/data-entry.e2e.ts @@ -21,6 +21,10 @@ import { noRecountNoDifferencesRequest, } from "./test-data/request-response-templates"; +test.use({ + storageState: "e2e-tests/state/typist.json", +}); + test.describe("full data entry flow", () => { test("no recount, no differences", async ({ page, pollingStation }) => { await page.goto(`/elections/${pollingStation.election_id}/data-entry`); diff --git a/frontend/e2e-tests/data-entry.model.e2e.ts b/frontend/e2e-tests/data-entry.model.e2e.ts index 85eb4905e..bac92c25b 100644 --- a/frontend/e2e-tests/data-entry.model.e2e.ts +++ b/frontend/e2e-tests/data-entry.model.e2e.ts @@ -173,6 +173,10 @@ const votesEmpty: VotesCounts = { total_votes_cast_count: 0, }; +test.use({ + storageState: "e2e-tests/state/typist.json", +}); + test.describe("Data entry model test", () => { createTestModel(machine) .getSimplePaths() diff --git a/frontend/e2e-tests/fixtures.ts b/frontend/e2e-tests/fixtures.ts index 61c5beee1..dd59bcd7d 100644 --- a/frontend/e2e-tests/fixtures.ts +++ b/frontend/e2e-tests/fixtures.ts @@ -11,8 +11,13 @@ import { POLLING_STATION_DATA_ENTRY_SAVE_REQUEST_PATH, POLLING_STATION_GET_REQUEST_PATH, PollingStation, + User, + USER_CREATE_REQUEST_BODY, + USER_CREATE_REQUEST_PATH, } from "@kiesraad/api"; +import { createRandomUsername } from "./helpers-utils/e2e-test-utils"; +import { loginAs } from "./setup"; import { electionRequest, noRecountNoDifferencesRequest, @@ -31,10 +36,14 @@ type Fixtures = { pollingStationFirstEntryDone: PollingStation; // Election with polling stations and two completed data entries for each completedElection: Election; + // Newly created User + user: User; }; export const test = base.extend({ emptyElection: async ({ request }, use) => { + await loginAs(request, "admin"); + // overide the current storage state // create an election with no polling stations const url: ELECTION_CREATE_REQUEST_PATH = `/api/elections`; const electionResponse = await request.post(url, { data: electionRequest }); @@ -44,6 +53,7 @@ export const test = base.extend({ await use(election); }, election: async ({ request, emptyElection }, use) => { + await loginAs(request, "admin"); // create polling stations in the existing emptyElection const url: POLLING_STATION_CREATE_REQUEST_PATH = `/api/elections/${emptyElection.id}/polling_stations`; for (const pollingStationRequest of pollingStationRequests) { @@ -60,6 +70,7 @@ export const test = base.extend({ await use(election); }, pollingStation: async ({ request, election }, use) => { + await loginAs(request, "admin"); // get the first polling station of the existing election const url: POLLING_STATION_GET_REQUEST_PATH = `/api/elections/${election.election.id}/polling_stations/${election.polling_stations[0]?.id ?? 0}`; const response = await request.get(url); @@ -69,6 +80,7 @@ export const test = base.extend({ await use(pollingStation); }, pollingStationFirstEntryDone: async ({ request, pollingStation }, use) => { + await loginAs(request, "typist"); // first data entry of the existing polling station const saveResponse = await request.post(`/api/polling_stations/${pollingStation.id}/data_entries/1`, { data: noRecountNoDifferencesRequest, @@ -80,6 +92,7 @@ export const test = base.extend({ await use(pollingStation); }, completedElection: async ({ request, election }, use) => { + await loginAs(request, "typist"); // finalise both data entries for all polling stations for (const pollingStationId of election.polling_stations.map((ps) => ps.id)) { for (const entryNumber of [1, 2]) { @@ -96,4 +109,19 @@ export const test = base.extend({ await use(election.election); }, + user: async ({ request }, use) => { + await loginAs(request, "admin"); + // create a new user + const url: USER_CREATE_REQUEST_PATH = "/api/user"; + const data: USER_CREATE_REQUEST_BODY = { + role: "typist", + username: createRandomUsername(), + fullname: "Gebruiker met Achternaam", + temp_password: "temp_password_9876", + }; + const userResponse = await request.post(url, { data }); + expect(userResponse.ok()).toBeTruthy(); + + await use((await userResponse.json()) as User); + }, }); diff --git a/frontend/e2e-tests/page-objects/users/UserDeleteModalPgObj.ts b/frontend/e2e-tests/page-objects/users/UserDeleteModalPgObj.ts new file mode 100644 index 000000000..89875a4fa --- /dev/null +++ b/frontend/e2e-tests/page-objects/users/UserDeleteModalPgObj.ts @@ -0,0 +1,11 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class UserDeleteModal { + readonly modal: Locator; + readonly delete: Locator; + + constructor(protected readonly page: Page) { + this.modal = page.getByRole("dialog"); + this.delete = this.modal.getByRole("button", { name: "Verwijderen" }); + } +} diff --git a/frontend/e2e-tests/page-objects/users/UserListPgObj.ts b/frontend/e2e-tests/page-objects/users/UserListPgObj.ts index aec361b15..503058f1b 100644 --- a/frontend/e2e-tests/page-objects/users/UserListPgObj.ts +++ b/frontend/e2e-tests/page-objects/users/UserListPgObj.ts @@ -10,4 +10,8 @@ export class UserListPgObj { this.create = page.getByRole("link", { name: "Gebruiker toevoegen" }); this.table = page.getByRole("table"); } + + row(text: string): Locator { + return this.table.locator(`tr:has-text("${text}")`); + } } diff --git a/frontend/e2e-tests/page-objects/users/UserUpdatePgObj.ts b/frontend/e2e-tests/page-objects/users/UserUpdatePgObj.ts new file mode 100644 index 000000000..f594d75c0 --- /dev/null +++ b/frontend/e2e-tests/page-objects/users/UserUpdatePgObj.ts @@ -0,0 +1,13 @@ +import { type Locator, type Page } from "@playwright/test"; + +export class UserUpdatePgObj { + readonly fullname: Locator; + readonly save: Locator; + readonly delete: Locator; + + constructor(protected readonly page: Page) { + this.fullname = page.getByLabel("Volledige naam"); + this.save = page.getByRole("button", { name: "Opslaan" }); + this.delete = page.getByRole("button", { name: "Gebruiker verwijderen" }); + } +} diff --git a/frontend/e2e-tests/pdf-rendering.e2e.ts b/frontend/e2e-tests/pdf-rendering.e2e.ts index 440c10fa1..799096558 100644 --- a/frontend/e2e-tests/pdf-rendering.e2e.ts +++ b/frontend/e2e-tests/pdf-rendering.e2e.ts @@ -5,9 +5,13 @@ import { stat } from "node:fs/promises"; import { test } from "./fixtures"; +test.use({ + storageState: "e2e-tests/state/coordinator.json", +}); + test.describe("pdf rendering", () => { test("it downloads a pdf", async ({ page, completedElection }) => { - await page.goto(`/elections/${completedElection.id}/status#coordinator`); + await page.goto(`/elections/${completedElection.id}/status`); const electionStatusPage = new ElectionStatus(page); await electionStatusPage.finish.click(); diff --git a/frontend/e2e-tests/polling-station-crud.e2e.ts b/frontend/e2e-tests/polling-station-crud.e2e.ts index 3ec1f75e4..85441e2e1 100644 --- a/frontend/e2e-tests/polling-station-crud.e2e.ts +++ b/frontend/e2e-tests/polling-station-crud.e2e.ts @@ -5,12 +5,16 @@ import { test } from "./fixtures"; import { PollingStationListEmptyPgObj } from "./page-objects/polling_station/PollingStationListEmptyPgObj"; import { PollingStationListPgObj } from "./page-objects/polling_station/PollingStationListPgObj"; +test.use({ + storageState: "e2e-tests/state/admin.json", +}); + test.describe("Polling station CRUD", () => { test("it redirects correctly after successful create of first polling station of an election", async ({ page, emptyElection, }) => { - await page.goto(`/elections/${emptyElection.id}/polling-stations#coordinator`); + await page.goto(`/elections/${emptyElection.id}/polling-stations`); const pollingStationListEmptyPage = new PollingStationListEmptyPgObj(page); await pollingStationListEmptyPage.createPollingStation.click(); @@ -29,7 +33,7 @@ test.describe("Polling station CRUD", () => { }); test("it redirects correctly after successful create of another polling station", async ({ page, election }) => { - await page.goto(`/elections/${election.election.id}/polling-stations#coordinator`); + await page.goto(`/elections/${election.election.id}/polling-stations`); const pollingStationListPage = new PollingStationListPgObj(page); await pollingStationListPage.createPollingStation.click(); diff --git a/frontend/e2e-tests/resume-data-entry.e2e.ts b/frontend/e2e-tests/resume-data-entry.e2e.ts index 1b263edee..44b891e6e 100644 --- a/frontend/e2e-tests/resume-data-entry.e2e.ts +++ b/frontend/e2e-tests/resume-data-entry.e2e.ts @@ -11,8 +11,13 @@ import { import { PollingStation, VotersCounts, VotesCounts } from "@kiesraad/api"; import { test } from "./fixtures"; +import { loginAs } from "./setup"; import { emptyDataEntryResponse } from "./test-data/request-response-templates"; +test.use({ + storageState: "e2e-tests/state/typist.json", +}); + test.describe("resume data entry flow", () => { const fillFirstTwoPagesAndAbort = async (page: Page, pollingStation: PollingStation) => { await page.goto(`/elections/${pollingStation.election_id}/data-entry/${pollingStation.id}/1/recounted`); @@ -129,6 +134,7 @@ test.describe("resume data entry flow", () => { await expect(pollingStationChoicePage.fieldset).toBeVisible(); await expect(pollingStationChoicePage.resumeDataEntry).toBeVisible(); + await loginAs(request, "typist"); const dataEntryResponse = await request.get(`/api/polling_stations/${pollingStation.id}/data_entries/1`); expect(dataEntryResponse.status()).toBe(200); expect(await dataEntryResponse.json()).toMatchObject({ @@ -202,6 +208,7 @@ test.describe("resume data entry flow", () => { const pollingStationChoicePage = new PollingStationChoicePage(page); await expect(pollingStationChoicePage.fieldset).toBeVisible(); + await loginAs(request, "typist"); const dataEntryResponse = await request.get(`/api/polling_stations/${pollingStation.id}/data_entries/1`); expect(dataEntryResponse.status()).toBe(200); expect(await dataEntryResponse.json()).toMatchObject({ @@ -354,6 +361,7 @@ test.describe("resume data entry flow", () => { const pollingStationChoicePage = new PollingStationChoicePage(page); await expect(pollingStationChoicePage.fieldset).toBeVisible(); + await loginAs(request, "typist"); const dataEntryResponse = await request.get(`/api/polling_stations/${pollingStation.id}/data_entries/1`); expect(dataEntryResponse.status()).toBe(404); }); @@ -394,6 +402,7 @@ test.describe("resume data entry flow", () => { const pollingStationChoicePage = new PollingStationChoicePage(page); await expect(pollingStationChoicePage.fieldset).toBeVisible(); + await loginAs(request, "typist"); const dataEntryResponse = await request.get(`/api/polling_stations/${pollingStation.id}/data_entries/1`); expect(dataEntryResponse.status()).toBe(404); }); diff --git a/frontend/e2e-tests/setup.ts b/frontend/e2e-tests/setup.ts new file mode 100644 index 000000000..99f8c6213 --- /dev/null +++ b/frontend/e2e-tests/setup.ts @@ -0,0 +1,28 @@ +import { APIRequestContext, type FullConfig, request } from "@playwright/test"; + +export async function loginAs(request: APIRequestContext, username: string) { + const capitalizedUsername = username.charAt(0).toUpperCase() + username.slice(1); + const password = capitalizedUsername + "Password01"; + await request.post("/api/user/login", { + data: { + username, + password, + }, + }); +} + +async function globalSetup(config: FullConfig) { + const baseUrl = config.projects[0]?.use.baseURL; + const session = await request.newContext({ baseURL: baseUrl }); + + await loginAs(session, "admin"); + await session.storageState({ path: "e2e-tests/state/admin.json" }); + + await loginAs(session, "coordinator"); + await session.storageState({ path: "e2e-tests/state/coordinator.json" }); + + await loginAs(session, "typist"); + await session.storageState({ path: "e2e-tests/state/typist.json" }); +} + +export default globalSetup; diff --git a/frontend/e2e-tests/users.e2e.ts b/frontend/e2e-tests/users.e2e.ts index e99e66e3f..ff84dac43 100644 --- a/frontend/e2e-tests/users.e2e.ts +++ b/frontend/e2e-tests/users.e2e.ts @@ -5,7 +5,13 @@ import { createRandomUsername } from "./helpers-utils/e2e-test-utils"; import { UserCreateDetailsPgObj } from "./page-objects/users/UserCreateDetailsPgObj"; import { UserCreateRolePgObj } from "./page-objects/users/UserCreateRolePgObj"; import { UserCreateTypePgObj } from "./page-objects/users/UserCreateTypePgObj"; +import { UserDeleteModal } from "./page-objects/users/UserDeleteModalPgObj"; import { UserListPgObj } from "./page-objects/users/UserListPgObj"; +import { UserUpdatePgObj } from "./page-objects/users/UserUpdatePgObj"; + +test.use({ + storageState: "e2e-tests/state/admin.json", +}); test.describe("Users", () => { test("create a user with role administrator", async ({ page }) => { @@ -63,4 +69,42 @@ test.describe("Users", () => { await expect(userListPgObj.alert).toContainText(`${username} is toegevoegd met de rol Invoerder`); await expect(userListPgObj.table).toContainText(username); }); + + test("update user", async ({ user, page }) => { + await page.goto(`/users`); + + const userListPgObj = new UserListPgObj(page); + + await userListPgObj.row(user.username).click(); + + const userUpdatePgObj = new UserUpdatePgObj(page); + await expect(userUpdatePgObj.fullname).toHaveValue("Gebruiker met Achternaam"); + await userUpdatePgObj.fullname.fill("Gebruiker met Wijzigingen"); + await userUpdatePgObj.save.click(); + + await expect(userListPgObj.alert).toContainText("Wijzigingen opgeslagen"); + await expect(userListPgObj.alert).toContainText( + "De wijzigingen in het account van Gebruiker met Wijzigingen zijn opgeslagen", + ); + await expect(userListPgObj.row(user.username)).toContainText("Gebruiker met Wijzigingen"); + }); + + test("delete user", async ({ user, page }) => { + await page.goto(`/users`); + + const userListPgObj = new UserListPgObj(page); + + await userListPgObj.row(user.username).click(); + + const userUpdatePgObj = new UserUpdatePgObj(page); + await expect(userUpdatePgObj.fullname).toHaveValue("Gebruiker met Achternaam"); + await userUpdatePgObj.delete.click(); + + const modal = new UserDeleteModal(page); + await modal.delete.click(); + + await expect(userListPgObj.alert).toContainText("Gebruiker verwijderd"); + await expect(userListPgObj.alert).toContainText("Het account van Gebruiker met Achternaam is verwijderd"); + await expect(userListPgObj.row(user.username)).toHaveCount(0); + }); }); diff --git a/frontend/e2e-tests/zip-download.e2e.ts b/frontend/e2e-tests/zip-download.e2e.ts index 69edba5eb..131d1b4f4 100644 --- a/frontend/e2e-tests/zip-download.e2e.ts +++ b/frontend/e2e-tests/zip-download.e2e.ts @@ -5,9 +5,13 @@ import { stat } from "node:fs/promises"; import { test } from "./fixtures"; +test.use({ + storageState: "e2e-tests/state/coordinator.json", +}); + test.describe("election results zip", () => { test("it downloads a zip", async ({ page, completedElection }) => { - await page.goto(`/elections/${completedElection.id}/status#coordinator`); + await page.goto(`/elections/${completedElection.id}/status`); const electionStatusPage = new ElectionStatus(page); await electionStatusPage.finish.click(); diff --git a/frontend/lib/api-mocks/RequestHandlers.ts b/frontend/lib/api-mocks/RequestHandlers.ts index 2c3d5ae14..b42ab4cf5 100644 --- a/frontend/lib/api-mocks/RequestHandlers.ts +++ b/frontend/lib/api-mocks/RequestHandlers.ts @@ -16,8 +16,15 @@ import { USER_CREATE_REQUEST_BODY, USER_CREATE_REQUEST_PARAMS, USER_CREATE_REQUEST_PATH, + USER_DELETE_REQUEST_PARAMS, + USER_DELETE_REQUEST_PATH, + USER_GET_REQUEST_PARAMS, + USER_GET_REQUEST_PATH, USER_LIST_REQUEST_PARAMS, USER_LIST_REQUEST_PATH, + USER_UPDATE_REQUEST_BODY, + USER_UPDATE_REQUEST_PARAMS, + USER_UPDATE_REQUEST_PATH, UserListResponse, } from "@kiesraad/api"; @@ -49,9 +56,16 @@ export const pingHandler = http.post - HttpResponse.json({ user_id: 1, username: "user" } satisfies LoginResponse, { status: 200 }), -); +export const WhoAmIRequestHandler = http.get("/api/user/whoami", () => { + const loginResponse: LoginResponse = { + user_id: 1, + fullname: "Example Name", + username: "admin", + role: "administrator", + needs_password_change: false, + }; + return HttpResponse.json(loginResponse, { status: 200 }); +}); // get election list handler export const ElectionListRequestHandler = http.get("/api/elections", () => @@ -132,6 +146,13 @@ export const UserCreateRequestHandler = http.post< USER_CREATE_REQUEST_PATH >("/api/user", () => HttpResponse.json(userMockData[userMockData.length - 1], { status: 200 })); +export const UserGetRequestHandler = http.get< + ParamsToString, + null, + User, + USER_GET_REQUEST_PATH +>("/api/user/1", () => HttpResponse.json(userMockData[0], { status: 200 })); + export const UserListRequestHandler = http.get< USER_LIST_REQUEST_PARAMS, null, @@ -139,6 +160,20 @@ export const UserListRequestHandler = http.get< USER_LIST_REQUEST_PATH >("/api/user", () => HttpResponse.json({ users: userMockData }, { status: 200 })); +export const UserUpdateRequestHandler = http.put< + ParamsToString, + USER_UPDATE_REQUEST_BODY, + User, + USER_UPDATE_REQUEST_PATH +>("/api/user/1", () => HttpResponse.json(userMockData[0], { status: 200 })); + +export const UserDeleteRequestHandler = http.delete< + ParamsToString, + null, + undefined, + USER_DELETE_REQUEST_PATH +>("/api/user/1", () => new HttpResponse(null, { status: 200 })); + export const handlers: HttpHandler[] = [ pingHandler, WhoAmIRequestHandler, @@ -154,5 +189,8 @@ export const handlers: HttpHandler[] = [ PollingStationGetHandler, PollingStationUpdateHandler, UserCreateRequestHandler, + UserGetRequestHandler, UserListRequestHandler, + UserUpdateRequestHandler, + UserDeleteRequestHandler, ]; diff --git a/frontend/lib/api/ApiProvider.tsx b/frontend/lib/api/ApiProvider.tsx index 487f1f3b6..b0e541476 100644 --- a/frontend/lib/api/ApiProvider.tsx +++ b/frontend/lib/api/ApiProvider.tsx @@ -14,7 +14,7 @@ export interface ApiProviderProps { const client = new ApiClient(); export function ApiProvider({ children, fetchInitialUser = true }: ApiProviderProps) { - const { user, setUser } = useSessionState(fetchInitialUser); + const { user, setUser, login, logout } = useSessionState(fetchInitialUser); // Unset the current user when the API returns an invalid session error // indicating that the sessions has expired or the user is not authenticated anymore @@ -32,6 +32,8 @@ export function ApiProvider({ children, fetchInitialUser = true }: ApiProviderPr client, user, setUser, + logout, + login, }; return {children}; diff --git a/frontend/lib/api/TestUserProvider.tsx b/frontend/lib/api/TestUserProvider.tsx new file mode 100644 index 000000000..fa0b1a4bb --- /dev/null +++ b/frontend/lib/api/TestUserProvider.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; + +import { ApiState } from "./api.types"; +import { ApiClient } from "./ApiClient"; +import { ApiProviderContext } from "./ApiProviderContext"; +import { Role } from "./gen/openapi"; + +interface TestUserProviderProps { + userRole: Role; + children: ReactNode; +} + +export function TestUserProvider({ userRole, children }: TestUserProviderProps) { + const apiState = { + client: new ApiClient(), + user: { + user_id: 1, + role: userRole, + fullname: "Test User", + username: "test", + }, + }; + + return {children}; +} diff --git a/frontend/lib/api/api.types.ts b/frontend/lib/api/api.types.ts index c5b8f9172..bd8b71f8f 100644 --- a/frontend/lib/api/api.types.ts +++ b/frontend/lib/api/api.types.ts @@ -42,4 +42,6 @@ export interface ApiState { client: ApiClient; user: LoginResponse | null; setUser: (user: LoginResponse | null) => void; + logout: () => Promise; + login: (username: string, password: string) => Promise>; } diff --git a/frontend/lib/api/gen/openapi.ts b/frontend/lib/api/gen/openapi.ts index 4a924025f..dceebf556 100644 --- a/frontend/lib/api/gen/openapi.ts +++ b/frontend/lib/api/gen/openapi.ts @@ -107,10 +107,10 @@ export type USER_CREATE_REQUEST_PARAMS = Record; export type USER_CREATE_REQUEST_PATH = `/api/user`; export type USER_CREATE_REQUEST_BODY = CreateUserRequest; -// /api/user/change-password -export type CHANGE_PASSWORD_REQUEST_PARAMS = Record; -export type CHANGE_PASSWORD_REQUEST_PATH = `/api/user/change-password`; -export type CHANGE_PASSWORD_REQUEST_BODY = ChangePasswordRequest; +// /api/user/account +export type ACCOUNT_UPDATE_REQUEST_PARAMS = Record; +export type ACCOUNT_UPDATE_REQUEST_PATH = `/api/user/account`; +export type ACCOUNT_UPDATE_REQUEST_BODY = AccountUpdateRequest; // /api/user/login export type LOGIN_REQUEST_PARAMS = Record; @@ -135,22 +135,40 @@ export interface USER_UPDATE_REQUEST_PARAMS { } export type USER_UPDATE_REQUEST_PATH = `/api/user/${number}`; export type USER_UPDATE_REQUEST_BODY = UpdateUserRequest; +export interface USER_DELETE_REQUEST_PARAMS { + user_id: number; +} +export type USER_DELETE_REQUEST_PATH = `/api/user/${number}`; /** TYPES **/ +/** + * Contains information about the enactment of article P 9 of the Kieswet. + */ +export interface AbsoluteMajorityChange { + pg_assigned_seat: number; + pg_retracted_seat: number; +} + +export interface AccountUpdateRequest { + fullname?: string; + password: string; + username: string; +} + /** * The result of the apportionment procedure. This contains the number of seats and the quota -that was used. It then contains the initial standing after whole seats were assigned, +that was used. It then contains the initial standing after full seats were assigned, and each of the changes and intermediate standings. The final standing contains the number of seats per political group that was assigned after all seats were assigned. */ export interface ApportionmentResult { final_standing: PoliticalGroupSeatAssignment[]; + full_seats: number; quota: Fraction; residual_seats: number; seats: number; steps: ApportionmentStep[]; - whole_seats: number; } /** @@ -167,8 +185,9 @@ export interface ApportionmentStep { * Records the political group and specific change for a specific residual seat */ export type AssignedSeat = - | (HighestAverageAssignedSeat & { assigned_by: "HighestAverage" }) - | (HighestSurplusAssignedSeat & { assigned_by: "HighestSurplus" }); + | (LargestAverageAssignedSeat & { assigned_by: "LargestAverage" }) + | (LargestRemainderAssignedSeat & { assigned_by: "LargestRemainder" }) + | (AbsoluteMajorityChange & { assigned_by: "AbsoluteMajorityChange" }); /** * Candidate @@ -194,12 +213,6 @@ export interface CandidateVotes { votes: number; } -export interface ChangePasswordRequest { - new_password: string; - password: string; - username: string; -} - export interface CreateUserRequest { fullname?: string; role: Role; @@ -366,7 +379,10 @@ export type ErrorReference = | "PollingStationResultsAlreadyFinalised" | "PollingStationSecondEntryAlreadyFinalised" | "PollingStationValidationErrors" - | "UserNotFound"; + | "UserNotFound" + | "UsernameNotUnique" + | "Unauthorized" + | "PasswordRejection"; /** * Response structure for errors @@ -398,24 +414,29 @@ export interface GetDataEntryResponse { } /** - * Contains the details for an assigned seat, assigned through the highest average method. + * Contains the details for an assigned seat, assigned through the largest average method. */ -export interface HighestAverageAssignedSeat { +export interface LargestAverageAssignedSeat { + pg_assigned: number[]; pg_options: number[]; selected_pg_number: number; votes_per_seat: Fraction; } /** - * Contains the details for an assigned seat, assigned through the highest surplus method. + * Contains the details for an assigned seat, assigned through the largest remainder method. */ -export interface HighestSurplusAssignedSeat { +export interface LargestRemainderAssignedSeat { + pg_assigned: number[]; pg_options: number[]; + remainder_votes: Fraction; selected_pg_number: number; - surplus_votes: Fraction; } export interface LoginResponse { + fullname?: string; + needs_password_change: boolean; + role: Role; user_id: number; username: string; } @@ -433,13 +454,13 @@ export interface PoliticalGroup { * Contains information about the final assignment of seats for a specific political group. */ export interface PoliticalGroupSeatAssignment { - meets_surplus_threshold: boolean; + full_seats: number; + meets_remainder_threshold: boolean; pg_number: number; + remainder_votes: Fraction; residual_seats: number; - surplus_votes: Fraction; total_seats: number; votes_cast: number; - whole_seats: number; } /** @@ -447,13 +468,13 @@ export interface PoliticalGroupSeatAssignment { that is needed to compute the apportionment for that specific political group. */ export interface PoliticalGroupStanding { - meets_surplus_threshold: boolean; + full_seats: number; + meets_remainder_threshold: boolean; next_votes_per_seat: Fraction; pg_number: number; + remainder_votes: Fraction; residual_seats: number; - surplus_votes: Fraction; votes_cast: number; - whole_seats: number; } export interface PoliticalGroupVotes { diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts index b4cb67d98..3c3772ac9 100644 --- a/frontend/lib/api/index.ts +++ b/frontend/lib/api/index.ts @@ -7,6 +7,8 @@ export * from "./ApiClient"; export * from "./ApiError"; export * from "./ApiProvider"; export * from "./ApiResponseStatus"; +export * from "./ApiProviderContext"; +export * from "./TestUserProvider"; export * from "./useApi"; export * from "./useApiRequest"; export * from "./useApiState"; @@ -15,3 +17,4 @@ export * from "./useElectionDataRequest"; export * from "./useElectionListRequest"; export * from "./useElectionStatusRequest"; export * from "./useUser"; +export * from "./useUserRole"; diff --git a/frontend/lib/api/useSessionState.ts b/frontend/lib/api/useSessionState.ts index f8fb42f34..82c0836c8 100644 --- a/frontend/lib/api/useSessionState.ts +++ b/frontend/lib/api/useSessionState.ts @@ -1,12 +1,21 @@ import { useEffect, useState } from "react"; +import { AnyApiError, ApiResult } from "./api.types"; import { ApiClient, DEFAULT_CANCEL_REASON } from "./ApiClient"; import { isSuccess } from "./ApiError"; -import { LoginResponse, WHOAMI_REQUEST_PATH } from "./gen/openapi"; +import { + LOGIN_REQUEST_BODY, + LOGIN_REQUEST_PATH, + LoginResponse, + LOGOUT_REQUEST_PATH, + WHOAMI_REQUEST_PATH, +} from "./gen/openapi"; export interface SessionState { user: LoginResponse | null; setUser: (user: LoginResponse | null) => void; + logout: () => Promise; + login: (username: string, password: string) => Promise>; } // Keep track of the currently logged-in user @@ -14,7 +23,43 @@ export interface SessionState { // and then updating it when the user logs in or out export default function useSessionState(fetchInitialUser: boolean): SessionState { const [user, setUser] = useState(null); + const [error, setError] = useState(null); + // Propagate any unexpected API errors to the router + useEffect(() => { + if (error) { + throw error; + } + }, [error]); + + // Log out the current user + const logout = async () => { + const path: LOGOUT_REQUEST_PATH = "/api/user/logout"; + const client = new ApiClient(); + const response = await client.postRequest(path); + + if (isSuccess(response)) { + setUser(null); + } else { + setError(response); + } + }; + + // Log in the user with the given credentials + const login = async (username: string, password: string) => { + const requestPath: LOGIN_REQUEST_PATH = "/api/user/login"; + const requestBody: LOGIN_REQUEST_BODY = { username, password }; + const client = new ApiClient(); + const response = await client.postRequest(requestPath, requestBody); + + if (isSuccess(response)) { + setUser(response.data); + } + + return response; + }; + + // Fetch the user data from the server when the component mounts useEffect(() => { if (fetchInitialUser) { const abortController = new AbortController(); @@ -37,5 +82,5 @@ export default function useSessionState(fetchInitialUser: boolean): SessionState } }, [fetchInitialUser]); - return { user, setUser }; + return { user, setUser, login, logout }; } diff --git a/frontend/lib/api/useUserRole.ts b/frontend/lib/api/useUserRole.ts new file mode 100644 index 000000000..dc3a6cb5d --- /dev/null +++ b/frontend/lib/api/useUserRole.ts @@ -0,0 +1,11 @@ +import { useUser } from "./useUser"; + +export function useUserRole() { + const user = useUser(); + + return { + isTypist: user?.role === "typist", + isAdministrator: user?.role === "administrator", + isCoordinator: user?.role === "coordinator", + }; +} diff --git a/frontend/lib/i18n/locales/nl/user.json b/frontend/lib/i18n/locales/nl/account.json similarity index 88% rename from frontend/lib/i18n/locales/nl/user.json rename to frontend/lib/i18n/locales/nl/account.json index 78eba0580..fcd3651c9 100644 --- a/frontend/lib/i18n/locales/nl/user.json +++ b/frontend/lib/i18n/locales/nl/account.json @@ -1,26 +1,25 @@ { - "personalize_account": "Personaliseer je account", - "username": "Gebruikersnaam", - "username_hint": "Je kan deze niet aanpassen. Log volgende keer weer met deze gebruikersnaam in.", - "username_login_hint": "De naam op het briefje dat je van de coördinator hebt gekregen.", - "username_default": "Gebruiker01", + "account": "Account", + "account_setup": "Account instellen", + "change_password": "Wachtwoord wijzigen", + "current_password_hint": "Vul je huidige wachtwoord in", + "login": "Inloggen", + "login_success": "Inloggen gelukt", + "logout": "Afmelden", "name": "Jouw naam", - "name_subtext": "(roepnaam + achternaam)", "name_hint": "Bijvoorbeeld Karel van Tellingen. Je naam wordt opgenomen in het verslag van deze invoersessie.", + "name_subtext": "(roepnaam + achternaam)", "password": "Wachtwoord", + "password_changed": "Je wachtwoord is succesvol gewijzigd", "password_hint": "Je hebt dit wachtwoord nodig als je na een pauze opnieuw wilt inloggen. Gebruik minimaal 8 letters en 2 cijfers.", "password_login_hint": "Eerder ingelogd? Vul het wachtwoord in dat je zelf hebt ingesteld. Nog niet eerder ingelogd? Gebruik het wachtwoord dat je van de coördinator hebt gekregen.", + "password_mismatch": "De wachtwoorden komen niet overeen", "password_new": "Kies nieuw wachtwoord", "password_repeat": "Herhaal het wachtwoord dat je net hebt ingevuld", - "password_mismatch": "De wachtwoorden komen niet overeen", - "password_changed": "Je wachtwoord is succesvol gewijzigd", - "login": "Inloggen", - "login_success": "Inloggen gelukt", - "account": "Account", - "account_setup": "Account instellen", - "change_password": "Wachtwoord wijzigen", - "current_password_hint": "Vul je huidige wachtwoord in", - "phrases": { - "setting_up_account": "We gaan je account instellen voor gebruik. Vul onderstaande gegevens in om verder te gaan." - } + "personalize_account": "Personaliseer je account", + "setting_up_account": "We gaan je account instellen voor gebruik. Vul onderstaande gegevens in om verder te gaan.", + "username": "Gebruikersnaam", + "username_default": "Gebruiker01", + "username_hint": "Je kan deze niet aanpassen. Log volgende keer weer met deze gebruikersnaam in.", + "username_login_hint": "De naam op het briefje dat je van de coördinator hebt gekregen." } diff --git a/frontend/lib/i18n/locales/nl/apportionment.json b/frontend/lib/i18n/locales/nl/apportionment.json index 97b318642..37aaa9df2 100644 --- a/frontend/lib/i18n/locales/nl/apportionment.json +++ b/frontend/lib/i18n/locales/nl/apportionment.json @@ -1,17 +1,23 @@ { + "absolute_majority_change": "Overeenkomstig artikel P 9 van de Kieswet (volstrekte meerderheid) wordt aan lijst {pg_assigned_seat} alsnog één zetel toegewezen en vervalt daartegenover één zetel, die eerder was toegewezen aan lijst {pg_retracted_seat}.", "average": "Gemiddelde", "details_residual_seats": "Verdeling van de restzetels", - "details_whole_seats": "Verdeling van de volle zetels", + "details_full_seats": "Verdeling van de volle zetels", "election_summary": "Kengetallen", + "full_seat": { + "singular": "volle zetel", + "plural": "Volle zetels" + }, + "full_seats_count": "Aantal volle zetels", + "full_seats_information": "Per politieke groepering wordt berekend hoe vaak de kiesdeler in het aantal stemmen past. Het resultaat van deze deling geeft het aantal volle zetels dat is behaald.", + "full_seats_information_link": { + "singular": "Na het verdelen van de volle zetels is er nog {num_residual_seats} restzetel.", + "plural": "Na het verdelen van de volle zetels zijn er nog {num_residual_seats} restzetels." + }, "how_many_residual_seats": "Hoeveel restzetels zijn er te verdelen?", "how_often_is_quota_met": "Hoe vaak haalde elke partij de kiesdeler?", "information_largest_averages": "Er zijn 19 of meer zetels. Daarom worden restzetels toegewezen volgens het systeem van de grootste gemiddelden. De partij die na toewijzing van een restzetel het hoogste aantal stemmen per zetel heeft, krijgt de restzetel.\nIn de tabel is het gemiddeld aantal stemmen per zetel te vinden dat elke lijst zou krijgen als een restzetel zou worden toegekend.", - "information_largest_surpluses": "Er zijn minder dan 19 zetels. Daarom worden restzetels toegewezen volgens het systeem van de grootste overschotten. De restzetels zijn één voor één toegewezen aan de lijsten die na verdeling van de zetels het grootste overschot aan stemmen hebben. Lijsten die minder dan 75% van de kiesdeler hebben gehaald, komen niet in aanmerking voor een restzetel.", - "leftover_residual_seats_assignment": "Verdeling overige restzetels", - "leftover_residual_seats_amount_and_information": { - "singular": "Hierna was er nog {num_seats} restzetel te verdelen. Deze zetel is toegewezen aan de lijst die met een zetel erbij het grootste gemiddelde aantal stemmen per zetel zouden hebben. Hierbij heeft iedere lijst niet meer dan één zetel toegewezen gekregen.", - "plural": "Hierna waren er nog {num_seats} restzetels te verdelen. Deze zetels zijn toegewezen aan de lijsten die met een zetel erbij het grootste gemiddelde aantal stemmen per zetel zouden hebben. Hierbij heeft iedere lijst niet meer dan één zetel toegewezen gekregen." - }, + "information_largest_remainders": "Er zijn minder dan 19 zetels. Daarom worden restzetels toegewezen volgens het systeem van de grootste overschotten. De restzetels zijn één voor één toegewezen aan de lijsten die na verdeling van de zetels het grootste overschot aan stemmen hebben. Lijsten die minder dan 75% van de kiesdeler hebben gehaald, komen niet in aanmerking voor een restzetel.", "minus": "min", "not_available": "Zetelverdeling is nog niet beschikbaar", "no_residual_seats_to_assign": "Er zijn geen restzetels te verdelen.", @@ -20,6 +26,12 @@ "preference_threshold_description": "{percentage}% van de kiesdeler", "quota": "Kiesdeler", "quota_description": "Benodigde stemmen per volle zetel", + "remainder": "Overschot", + "remaining_residual_seats_assignment": "Verdeling overige restzetels", + "remaining_residual_seats_amount_and_information": { + "singular": "Hierna was er nog {num_seats} restzetel te verdelen. Deze zetel is toegewezen aan de lijst die met een zetel erbij het grootste gemiddelde aantal stemmen per zetel zouden hebben. Hierbij heeft iedere lijst niet meer dan één zetel toegewezen gekregen.", + "plural": "Hierna waren er nog {num_seats} restzetels te verdelen. Deze zetels zijn toegewezen aan de lijsten die met een zetel erbij het grootste gemiddelde aantal stemmen per zetel zouden hebben. Hierbij heeft iedere lijst niet meer dan één zetel toegewezen gekregen." + }, "residual_seat": { "singular": "Restzetel", "plural": "Restzetels" @@ -31,31 +43,20 @@ "plural": "Er zijn {num_residual_seats} restzetels te verdelen." }, "residual_seats_information_largest_averages": "Omdat het totaal aantal zetels groter of gelijk is aan 19, gebeurt dat via het systeem van de grootste gemiddelden.", - "residual_seats_information_largest_surpluses": "Omdat het totaal aantal zetels kleiner is dan 19, gebeurt dat via het systeem van de grootste overschotten.", + "residual_seats_information_largest_remainders": "Omdat het totaal aantal zetels kleiner is dan 19, gebeurt dat via het systeem van de grootste overschotten.", "residual_seats_largest_averages": "De restzetels gaan naar de partijen met de grootste gemiddelden", - "residual_seats_largest_surpluses": "De restzetels gaan naar de partijen met de grootste overschotten", + "residual_seats_largest_remainders": "De restzetels gaan naar de partijen met de grootste overschotten", "seats_assigned": { "singular": "{num_seat} zetel werd als {type_seat} toegewezen", "plural": "{num_seat} zetels werden als {type_seat} toegewezen" }, - "surplus": "Overschot", "title": "Zetelverdeling", "total": "Totaal", "total_number_seats": "Totaal aantal zetels", - "total_number_assigned_whole_seats": "Totaal aantal toegewezen volle zetels", + "total_number_assigned_full_seats": "Totaal aantal toegewezen volle zetels", "total_seats": "Totaal zetels", "total_votes_cast_count": "Getelde stembiljetten", "turnout": "Opkomst", "view_details": "bekijk details", - "voters": "Kiesgerechtigden", - "whole_seat": { - "singular": "volle zetel", - "plural": "Volle zetels" - }, - "whole_seats_count": "Aantal volle zetels", - "whole_seats_information": "Per politieke groepering wordt berekend hoe vaak de kiesdeler in het aantal stemmen past. Het resultaat van deze deling geeft het aantal volle zetels dat is behaald.", - "whole_seats_information_link": { - "singular": "Na het verdelen van de volle zetels is er nog {num_residual_seats} restzetel.", - "plural": "Na het verdelen van de volle zetels zijn er nog {num_residual_seats} restzetels." - } + "voters": "Kiesgerechtigden" } diff --git a/frontend/lib/i18n/locales/nl/error.json b/frontend/lib/i18n/locales/nl/error.json index ed4ccc44f..55a8357e9 100644 --- a/frontend/lib/i18n/locales/nl/error.json +++ b/frontend/lib/i18n/locales/nl/error.json @@ -38,6 +38,9 @@ "PollingStationResultsAlreadyFinalised": "De invoer voor dit stembureau is al definitief", "PollingStationSecondEntryAlreadyFinalised": "De tweede invoer voor dit stembureau is al definitief", "PollingStationValidationErrors": "Er zijn fouten opgetreden bij het valideren van het stembureau", - "UserNotFound": "De gebruiker is niet gevonden" + "Unauthorized": "Je hebt geen toestemming om deze actie uit te voeren", + "UserNotFound": "De gebruiker is niet gevonden", + "UsernameNotUnique": "De gebruikersnaam is al in gebruik", + "PasswordRejection": "Het opgegeven wachtwoord voldoet niet aan de eisen" } } diff --git a/frontend/lib/i18n/locales/nl/nl.ts b/frontend/lib/i18n/locales/nl/nl.ts index ff9b15d83..7f6d39809 100644 --- a/frontend/lib/i18n/locales/nl/nl.ts +++ b/frontend/lib/i18n/locales/nl/nl.ts @@ -1,3 +1,4 @@ +import account from "./account.json"; import apportionment from "./apportionment.json"; import candidates_votes from "./candidates_votes.json"; import check_and_save from "./check_and_save.json"; @@ -15,13 +16,13 @@ import polling_station from "./polling_station.json"; import polling_station_choice from "./polling_station_choice.json"; import recounted from "./recounted.json"; import status from "./status.json"; -import user from "./user.json"; import users from "./users.json"; import voters_and_votes from "./voters_and_votes.json"; import workstations from "./workstations.json"; const nl = { ...generic, + account, apportionment, candidates_votes, check_and_save, @@ -38,7 +39,6 @@ const nl = { polling_station_choice, recounted, status, - user, users, voters_and_votes, workstations, diff --git a/frontend/lib/i18n/locales/nl/users.json b/frontend/lib/i18n/locales/nl/users.json index 3f79e0fb0..663de0eb2 100644 --- a/frontend/lib/i18n/locales/nl/users.json +++ b/frontend/lib/i18n/locales/nl/users.json @@ -1,12 +1,18 @@ { "add": "Gebruiker toevoegen", "add_role": "{role} toevoegen", + "change_password": "Wijzig wachtwoord", + "change_password_hint": "De gebruiker heeft een nieuw wachtwoord gekozen. Als het nodig is, kan het wachtwoord gereset worden.", + "delete": "Gebruiker verwijderen", + "delete_are_you_sure": "Je kan dit niet ongedaan maken. Weet je zeker dat je deze gebruiker wilt verwijderen?", "details_title": "Details van het account", "fullname": "Volledige naam", "fullname_hint": "Deze is terug te zien in de logs", "last_activity": "Laatste activiteit", "management": "Gebruikersbeheer", + "new_password": "Nieuw wachtwoord", "not_used": "Nog niet gebruikt", + "password": "Wachtwoord", "role_administrator_hint": "Verkiezingen voorbereiden, gebruikers aanmaken en uitslagen downloaden", "role_coordinator_hint": "Problemen en fouten tijdens het invoeren oplossen", "role_hint": "Kies de rol van de nieuwe gebruiker. De rol kan na het aanmaken van de gebruiker niet meer aangepast worden.", @@ -14,16 +20,21 @@ "role_title": "Welke rol krijgt de nieuwe gebruiker?", "role_typist_hint": "Tellingen invoeren", "temporary_password": "Tijdelijk wachtwoord", - "temporary_password_hint": "Gebruik minimaal {min_length} karakters. Het wachtwoord is hoofdlettergevoelig. De gebruiker moet na eerste keer inloggen zelf een nieuw wachtwoord kiezen.", "temporary_password_error_min_length": "Dit wachtwoord is niet lang genoeg. Gebruik minimaal {min_length} karakters", + "temporary_password_hint": "Gebruik minimaal {min_length} karakters. Het wachtwoord is hoofdlettergevoelig. De gebruiker moet na eerste keer inloggen zelf een nieuw wachtwoord kiezen.", "type_anonymous": "Anonieme gebruikersnaam (bijvoorbeeld 'Invoerder01')", "type_fullname": "Op naam (bijvoorbeeld 'MariekeDeJager')", "type_hint": "Gebruikers met een anoniem account moeten bij de eerste keer inloggen hun echte naam invoeren. Anonieme accounts zijn vooral handig als je veel invoerders verwacht, en nog niet precies weet wie wel en wie niet aanwezig zal zijn.", "type_title": "Type account", "user_created": "Gebruiker toegevoegd", "user_created_details": "{username} is toegevoegd met de rol {role}", + "user_deleted": "Gebruiker verwijderd", + "user_deleted_details": "Het account van {fullname} is verwijderd", + "user_updated": "Wijzigingen opgeslagen", + "user_updated_details": "De wijzigingen in het account van {fullname} zijn opgeslagen", "username": "Gebruikersnaam", "username_hint": "Dit is de naam waarmee de gebruiker kan inloggen. De invoer is niet hoofdlettergevoelig", + "username_hint_disabled": "Dit is de naam waarmee de gebruiker kan inloggen. De gebruikersnaam kan niet gewijzigd worden.", "username_not_unique_error": "Er bestaat al een gebruiker met gebruikersnaam {username}", "username_unique": "De gebruikersnaam moet uniek zijn", "users": "Gebruikers" diff --git a/frontend/lib/ui/Badge/Badge.e2e.ts b/frontend/lib/ui/Badge/Badge.e2e.ts index a44ec03dd..1d28f186d 100644 --- a/frontend/lib/ui/Badge/Badge.e2e.ts +++ b/frontend/lib/ui/Badge/Badge.e2e.ts @@ -1,7 +1,8 @@ import { expect, test } from "@playwright/test"; test("badges are visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=badge--all-badges"); + await page.goto("/?story=badge--all-badges"); + await page.waitForSelector("[data-storyloaded]"); const notStartedBadge = page.getByTestId("first_entry_not_started"); await expect(notStartedBadge).toBeVisible(); diff --git a/frontend/lib/ui/BottomBar/BottomBar.e2e.ts b/frontend/lib/ui/BottomBar/BottomBar.e2e.ts index 95a760201..73b5ebb49 100644 --- a/frontend/lib/ui/BottomBar/BottomBar.e2e.ts +++ b/frontend/lib/ui/BottomBar/BottomBar.e2e.ts @@ -1,7 +1,8 @@ import { expect, test } from "@playwright/test"; test("bottom bar with button and button hint is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=bottom-bar--bottom-bar-form"); + await page.goto("/?story=bottom-bar--bottom-bar-form"); + await page.waitForSelector("[data-storyloaded]"); const buttonElement = page.getByRole("button", { name: "Click me" }); const shiftElement = page.getByText("Shift"); diff --git a/frontend/lib/ui/Button/Button.e2e.ts b/frontend/lib/ui/Button/Button.e2e.ts index 8bc154dbf..63e77ac78 100644 --- a/frontend/lib/ui/Button/Button.e2e.ts +++ b/frontend/lib/ui/Button/Button.e2e.ts @@ -1,7 +1,8 @@ import { expect, test } from "@playwright/test"; test("buttons are visible and enabled", async ({ page }) => { - await page.goto("http://localhost:61000/?story=button--buttons"); + await page.goto("/?story=button--buttons"); + await page.waitForSelector("[data-storyloaded]"); const buttons = await page .getByRole("button", { @@ -18,7 +19,8 @@ test("buttons are visible and enabled", async ({ page }) => { }); test("click disabled button does nothing", async ({ page }) => { - await page.goto("http://localhost:61000/?story=button--buttons&arg-disabled=true"); + await page.goto("/?story=button--buttons&arg-disabled=true"); + await page.waitForSelector("[data-storyloaded]"); const buttons = await page .getByRole("button", { diff --git a/frontend/lib/ui/Form/FormLayout.module.css b/frontend/lib/ui/Form/FormLayout.module.css index 0ce5e71e0..0a1013552 100644 --- a/frontend/lib/ui/Form/FormLayout.module.css +++ b/frontend/lib/ui/Form/FormLayout.module.css @@ -22,9 +22,17 @@ } .form-field { + display: flex; + flex-direction: column; + gap: var(--space-md); + margin-bottom: var(--space-lg); } +.form-field > label { + font-weight: bold; +} + .root-fieldset { border: none; padding: 0; diff --git a/frontend/lib/ui/Form/FormLayout.tsx b/frontend/lib/ui/Form/FormLayout.tsx index 6155fe5ab..1de8fb7d6 100644 --- a/frontend/lib/ui/Form/FormLayout.tsx +++ b/frontend/lib/ui/Form/FormLayout.tsx @@ -33,8 +33,13 @@ function FormRow({ children }: { children: React.ReactNode }) { return
{children}
; } -function FormField({ children }: { children: React.ReactNode }) { - return
{children}
; +function FormField({ label, children }: { children: React.ReactNode; label?: string }) { + return ( +
+ {label && } + {children} +
+ ); } function FormControls({ children }: { children: React.ReactNode }) { diff --git a/frontend/lib/ui/InputField/InputField.e2e.ts b/frontend/lib/ui/InputField/InputField.e2e.ts index be3f0b8c5..1baf18807 100644 --- a/frontend/lib/ui/InputField/InputField.e2e.ts +++ b/frontend/lib/ui/InputField/InputField.e2e.ts @@ -1,7 +1,8 @@ import { expect, test } from "@playwright/test"; test("default small wide input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--wide-input-field"); + await page.goto("/?story=input-field--wide-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Default Small Wide" }); @@ -11,7 +12,8 @@ test("default small wide input field is visible", async ({ page }) => { }); test("error large wide input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--wide-input-field"); + await page.goto("/?story=input-field--wide-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Error Large Wide" }); @@ -22,7 +24,8 @@ test("error large wide input field is visible", async ({ page }) => { }); test("default medium narrow input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--narrow-input-field"); + await page.goto("/?story=input-field--narrow-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Default Medium Narrow" }); @@ -32,7 +35,8 @@ test("default medium narrow input field is visible", async ({ page }) => { }); test("error large narrow input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--narrow-input-field"); + await page.goto("/?story=input-field--narrow-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Error Large Narrow" }); @@ -43,7 +47,8 @@ test("error large narrow input field is visible", async ({ page }) => { }); test("default text area input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--text-area-input-field"); + await page.goto("/?story=input-field--text-area-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Default Text Area" }); @@ -53,7 +58,8 @@ test("default text area input field is visible", async ({ page }) => { }); test("error text area input field is visible", async ({ page }) => { - await page.goto("http://localhost:61000/?story=input-field--text-area-input-field"); + await page.goto("/?story=input-field--text-area-input-field"); + await page.waitForSelector("[data-storyloaded]"); const input = page.getByRole("textbox", { name: "Error Text Area" }); diff --git a/frontend/lib/ui/InputGrid/InputGrid.e2e.ts b/frontend/lib/ui/InputGrid/InputGrid.e2e.ts index 78b574c7a..2a4d3f55f 100644 --- a/frontend/lib/ui/InputGrid/InputGrid.e2e.ts +++ b/frontend/lib/ui/InputGrid/InputGrid.e2e.ts @@ -2,7 +2,9 @@ import { test as base, expect, Locator } from "@playwright/test"; const test = base.extend<{ gridPage: Locator }>({ gridPage: async ({ page }, use) => { - await page.goto("http://localhost:61000/?story=input-grid--default-grid"); + await page.goto("/?story=input-grid--default-grid"); + await page.waitForSelector("[data-storyloaded]"); + const main = page.locator("main.ladle-main"); const grid = main.locator("table"); await grid.waitFor(); diff --git a/frontend/lib/util/compare.ts b/frontend/lib/util/compare.ts index 322e8d28a..ca17c7807 100644 --- a/frontend/lib/util/compare.ts +++ b/frontend/lib/util/compare.ts @@ -14,12 +14,8 @@ export function deepEqual(obj1: unknown, obj2: unknown, zeroIsEqualToEmptyString if (isPrimitive(obj1) || isPrimitive(obj2)) return obj1 === obj2; - if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) { - return false; - } - - const keys1 = Object.keys(obj1) as Array; - const keys2 = Object.keys(obj2) as Array; + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); if (keys1.length !== keys2.length) return false; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e903f1764..c98dc7810 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,8 @@ "postcss-nesting": "^13.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.1.3", - "vite": "^6.1.1" + "react-router": "^7.2.0", + "vite": "^6.2.0" }, "devDependencies": { "@codecov/vite-plugin": "^1.9.0", @@ -26,30 +26,30 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/node": "^22.13.5", + "@types/node": "^22.13.8", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@typescript-eslint/eslint-plugin": "^8.24.1", - "@typescript-eslint/parser": "^8.24.1", + "@typescript-eslint/eslint-plugin": "^8.26.0", + "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react-swc": "^3.8.0", - "@vitest/coverage-v8": "^3.0.6", + "@vitest/coverage-v8": "^3.0.7", "@xstate/graph": "^3.0.2", "cross-env": "^7.0.3", "eslint": "^9.21.0", - "eslint-config-prettier": "^10.0.1", + "eslint-config-prettier": "^10.0.2", "eslint-import-resolver-typescript": "^3.8.3", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-playwright": "^2.2.0", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "jsdom": "^26.0.0", - "lefthook": "^1.11.0", - "msw": "^2.7.1", - "prettier": "^3.5.2", - "typescript": "^5.7.3", + "lefthook": "^1.11.2", + "msw": "^2.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.2", "vite-node": "^3.0.4", "vitest": "^3.0.4", "vitest-fail-on-console": "^0.7.1", @@ -750,9 +750,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -766,9 +766,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -782,9 +782,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -798,9 +798,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -814,9 +814,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -830,9 +830,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -846,9 +846,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -862,9 +862,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -878,9 +878,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -894,9 +894,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -910,9 +910,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -926,9 +926,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -942,9 +942,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -958,9 +958,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -974,9 +974,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -990,9 +990,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -1006,9 +1006,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -1022,9 +1022,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", "cpu": [ "arm64" ], @@ -1038,9 +1038,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -1054,9 +1054,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", "cpu": [ "arm64" ], @@ -1070,9 +1070,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -1086,9 +1086,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -1102,9 +1102,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -1118,9 +1118,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -1134,9 +1134,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -2928,9 +2928,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", - "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "version": "22.13.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz", + "integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2992,17 +2992,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", - "integrity": "sha512-ll1StnKtBigWIGqvYDVuDmXJHVH4zLVot1yQ4fJtLpL7qacwkxJc1T0bptqw+miBQ/QfUbhl1TcQ4accW5KUyA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", + "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/type-utils": "8.24.1", - "@typescript-eslint/utils": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/type-utils": "8.26.0", + "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -3018,20 +3018,20 @@ "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.24.1.tgz", - "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", + "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/typescript-estree": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "debug": "^4.3.4" }, "engines": { @@ -3043,18 +3043,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.1.tgz", - "integrity": "sha512-OdQr6BNBzwRjNEXMQyaGyZzgg7wzjYKfX2ZBV3E04hUCBDv3GQCHiz9RpqdUIiVrMgJGkXm3tcEh4vFSHreS2Q==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", + "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1" + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3065,14 +3065,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.24.1.tgz", - "integrity": "sha512-/Do9fmNgCsQ+K4rCz0STI7lYB4phTtEXqqCAs3gZW0pnK7lWNkvWd5iW545GSmApm4AzmQXmSqXPO565B4WVrw==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", + "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.24.1", - "@typescript-eslint/utils": "8.24.1", + "@typescript-eslint/typescript-estree": "8.26.0", + "@typescript-eslint/utils": "8.26.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -3085,13 +3085,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.1.tgz", - "integrity": "sha512-9kqJ+2DkUXiuhoiYIUvIYjGcwle8pcPpdlfkemGvTObzgmYfJ5d0Qm6jwb4NBXP9W1I5tss0VIAnWFumz3mC5A==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", + "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", "dev": true, "license": "MIT", "engines": { @@ -3103,14 +3103,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.1.tgz", - "integrity": "sha512-UPyy4MJ/0RE648DSKQe9g0VDSehPINiejjA6ElqnFaFIhI6ZEiZAkUI0D5MCk0bQcTf/LVqZStvQ6K4lPn/BRg==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", + "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/visitor-keys": "8.24.1", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/visitor-keys": "8.26.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3126,7 +3126,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { @@ -3143,16 +3143,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.1.tgz", - "integrity": "sha512-OOcg3PMMQx9EXspId5iktsI3eMaXVwlhC8BvNnX6B5w9a4dVgpkQZuU8Hy67TolKcl+iFWq0XX+jbDGN4xWxjQ==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", + "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.24.1", - "@typescript-eslint/types": "8.24.1", - "@typescript-eslint/typescript-estree": "8.24.1" + "@typescript-eslint/scope-manager": "8.26.0", + "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/typescript-estree": "8.26.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3163,17 +3163,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.24.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.1.tgz", - "integrity": "sha512-EwVHlp5l+2vp8CoqJm9KikPZgi3gbdZAtabKT9KPShGeOcJhsv4Zdo3oc8T8I0uKEmYoU4ItyxbptjF08enaxg==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", + "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.1", + "@typescript-eslint/types": "8.26.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -3238,9 +3238,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz", - "integrity": "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3261,8 +3261,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.6", - "vitest": "3.0.6" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3271,14 +3271,14 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz", - "integrity": "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.6", - "@vitest/utils": "3.0.6", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -3287,13 +3287,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.6.tgz", - "integrity": "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.6", + "@vitest/spy": "3.0.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -3314,9 +3314,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.6.tgz", - "integrity": "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { @@ -3327,13 +3327,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.6.tgz", - "integrity": "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.6", + "@vitest/utils": "3.0.7", "pathe": "^2.0.3" }, "funding": { @@ -3341,13 +3341,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.6.tgz", - "integrity": "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.6", + "@vitest/pretty-format": "3.0.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -3356,9 +3356,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.6.tgz", - "integrity": "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { @@ -3369,13 +3369,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.6.tgz", - "integrity": "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.6", + "@vitest/pretty-format": "3.0.7", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -5072,9 +5072,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -5084,31 +5084,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -5188,10 +5188,11 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.1.tgz", - "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.0.2.tgz", + "integrity": "sha512-1105/17ZIMjmCOJOPNfVdbXafLCLj3hPmkmB7dLgt7XsQ/zkxSuDerE/xgO3RxoHysR1N1whmquY0lSn2O0VLg==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "build/bin/cli.js" }, @@ -5538,10 +5539,11 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", - "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -7786,9 +7788,9 @@ } }, "node_modules/lefthook": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-1.11.0.tgz", - "integrity": "sha512-FSNRtrcFIe0FUxqEs/0ZYyY7yUvAXLrY8Fic1CRagcSpcC7MByjAV2utkTaslGo4+GPLaZnZL4JD1lNdN/8r2A==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook/-/lefthook-1.11.2.tgz", + "integrity": "sha512-/5royc/WbL2KTfFJ54wEdvxUZOBXwc54v/fW2Bz4LMOkAA3LWIxnoUiybSiauu+nhdTG98qERxH1YHwF2wZlAA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7796,22 +7798,22 @@ "lefthook": "bin/index.js" }, "optionalDependencies": { - "lefthook-darwin-arm64": "1.11.0", - "lefthook-darwin-x64": "1.11.0", - "lefthook-freebsd-arm64": "1.11.0", - "lefthook-freebsd-x64": "1.11.0", - "lefthook-linux-arm64": "1.11.0", - "lefthook-linux-x64": "1.11.0", - "lefthook-openbsd-arm64": "1.11.0", - "lefthook-openbsd-x64": "1.11.0", - "lefthook-windows-arm64": "1.11.0", - "lefthook-windows-x64": "1.11.0" + "lefthook-darwin-arm64": "1.11.2", + "lefthook-darwin-x64": "1.11.2", + "lefthook-freebsd-arm64": "1.11.2", + "lefthook-freebsd-x64": "1.11.2", + "lefthook-linux-arm64": "1.11.2", + "lefthook-linux-x64": "1.11.2", + "lefthook-openbsd-arm64": "1.11.2", + "lefthook-openbsd-x64": "1.11.2", + "lefthook-windows-arm64": "1.11.2", + "lefthook-windows-x64": "1.11.2" } }, "node_modules/lefthook-darwin-arm64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.11.0.tgz", - "integrity": "sha512-1Man6kmkFIB6Kn0vjrnPaBvIena3cZp/BjN58mCV3ErS5OjYDWtuUe2bl+y1uYDoNzOjiQQvdGWplroYD7Lslw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.11.2.tgz", + "integrity": "sha512-8DpvrybtWdt6UmfZk+hA8daYXr6zkpJVogZ8M49BQx6ISSKUaC03xzO1m4MrAsoKok77ka4JAidYhOa2gCu15A==", "cpu": [ "arm64" ], @@ -7823,9 +7825,9 @@ ] }, "node_modules/lefthook-darwin-x64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-1.11.0.tgz", - "integrity": "sha512-rQ7JJlLjaJNEk/mw7hp2/yGSdsmD2ZcQoH3slSQKfNaMjpZX3HXyBjZODUWtWqx7XqHqgU/SURMNmh/neBirdA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-1.11.2.tgz", + "integrity": "sha512-DrL1SOT8lJksjudRu6fTZTp3M0EbpCP2RQ22MDT71clS8BMrFL8x3h9Ziw+uNH76j9zA241tW5zMxWMSv+foAA==", "cpu": [ "x64" ], @@ -7837,9 +7839,9 @@ ] }, "node_modules/lefthook-freebsd-arm64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-1.11.0.tgz", - "integrity": "sha512-4FzuYhwtVdekslo6sa3p8ZR2q2qsFtwaNPCUXoisre1aKdL0k8QNQlk0fCZ20/WK7lu+N21CUOKP3LI8iY1dHQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-1.11.2.tgz", + "integrity": "sha512-AliG4Wi8BNC27hCSnuFBeUXh/eA3fppnUbQQPISy/G94yfwRkzyml9MZzvb7HKmUpw1LT0sq9RQ6FQPxBZ2DYA==", "cpu": [ "arm64" ], @@ -7851,9 +7853,9 @@ ] }, "node_modules/lefthook-freebsd-x64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-1.11.0.tgz", - "integrity": "sha512-26bOviVrePVJ9pOTFsJgRaS1ZigbTPj3OZFbMlLT+0Cu54uFKZWe5BNg1AZ2y4ioNrg16pilWZYNyc5wL0DlIg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-1.11.2.tgz", + "integrity": "sha512-V6cgRCoi5+jcq6XBIdRYraeEOK1UhBrtL/XZlNypAIkhPoBtfTP9u2wSprGMDzZvJCRriLXZxV/d0v94laKXzA==", "cpu": [ "x64" ], @@ -7865,9 +7867,9 @@ ] }, "node_modules/lefthook-linux-arm64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-1.11.0.tgz", - "integrity": "sha512-iNfa92n4M9EGMKGC8j3l6nlAjX8o7KnuHMA1pWudiSnPwfJwJozNcZH/jhe+A0eqYLkvZ18OlAmJfXFrVBQ0Lg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-1.11.2.tgz", + "integrity": "sha512-VKcK7sjIK8UpXX/qK6Fxa0Lnwr4gzRtlXDS17jzxThcyFk8iGBpQ+9ZnPLv2yAaEIzmGhJUG9sDgOb9IQ5kpBQ==", "cpu": [ "arm64" ], @@ -7879,9 +7881,9 @@ ] }, "node_modules/lefthook-linux-x64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-1.11.0.tgz", - "integrity": "sha512-2NtenHWnPbv27CufQwxVj6HaHqvZmP4qfFwgt+yMmghLml9WLmY3fHjw+CDhAKvlKsBv1mPuE+sGVEhnz7R0zA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-1.11.2.tgz", + "integrity": "sha512-aGa2Krph14YwSW7KF0PrlCBK9P7V/Z4oFklonmz3r2Fjm8EdhA750y7OQvA9KerXRleIb5SaUH/cz1azG/izeQ==", "cpu": [ "x64" ], @@ -7893,9 +7895,9 @@ ] }, "node_modules/lefthook-openbsd-arm64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-1.11.0.tgz", - "integrity": "sha512-j4kxeWUWDcofJHZs4yF0JnoRRuE92xekxh2i22mdufVmCBNOSiWGOT78hT5UwfQucjVjKY3oCtGH29ItiY0J1g==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-1.11.2.tgz", + "integrity": "sha512-f7owNQ9Ki6Y07KBgdXdH28EYO0eBdZuGTpIggMeHNhYFVDavxuINP2BjmbXtzpUu8K5BX6exGx0umtWhRhXbvQ==", "cpu": [ "arm64" ], @@ -7907,9 +7909,9 @@ ] }, "node_modules/lefthook-openbsd-x64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-1.11.0.tgz", - "integrity": "sha512-QaTbLaDpdSjHLsNronxYqrEVEMV2+wRnF8kvffAiWIoMl1Z15acxHNrGtXNywZ4OhsKifqt6w+CLQyoZfm7u7g==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-1.11.2.tgz", + "integrity": "sha512-HKv6PV64vOjqPrlxAqo07N9+Z34jdPDBfeExqi0ldR7vACFaBJFIdhWCLLP+3uQUrNKc8GXlikqplZn8MgRSQw==", "cpu": [ "x64" ], @@ -7921,9 +7923,9 @@ ] }, "node_modules/lefthook-windows-arm64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-1.11.0.tgz", - "integrity": "sha512-dxtQ+JCOaHJBuC+I4y0ati0cT62vui2WUlhc4AS/UPv3kp/UZC8ahdnYPAlg3xVLmAgb0nBOyHm7pYVq3A62MQ==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-1.11.2.tgz", + "integrity": "sha512-042jCKZ/H+lS6XYoMIf2FWMP2hxXqfAT52UW6lYObIOvQ5xu/epUXFjtmXRyYxCv57No3JYYMg1Yr06xdzTKkQ==", "cpu": [ "arm64" ], @@ -7935,9 +7937,9 @@ ] }, "node_modules/lefthook-windows-x64": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-1.11.0.tgz", - "integrity": "sha512-7ca/WQS3TYBwL2puzTFHM8wj6zZGuRlZKYkzLQncJ3+9UjVL+kcT9PuL4kOMs7sXyCsAYsdhNuko70w/28nTRA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-1.11.2.tgz", + "integrity": "sha512-1Map6Ck2AyfY6ptN9T19N41HFKFqRTzmILtGaRGJABEzHiE4+gSWcq5YT1R6cCtkVlewD3Lx+J/80D/Kb/cVtw==", "cpu": [ "x64" ], @@ -9294,9 +9296,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.1.tgz", - "integrity": "sha512-TVT65uoWt9LE4lMTLBdClHBQVwvZv5ofac1YyE119nCrNyXf4ktdeVnWH9Fyt94Ifmiedhw6Npp4DSuVRSuRpw==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.3.tgz", + "integrity": "sha512-+mycXv8l2fEAjFZ5sjrtjJDmm2ceKGjrNbBr1durRg6VkU9fNUE/gsmQ51hWbHqs+l35W1iM+ZsmOD9Fd6lspw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9992,9 +9994,9 @@ } }, "node_modules/prettier": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz", - "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", "bin": { @@ -10217,9 +10219,10 @@ } }, "node_modules/react-router": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.3.tgz", - "integrity": "sha512-EezYymLY6Guk/zLQ2vRA8WvdUhWFEj5fcE3RfWihhxXBW7+cd1LsIiA3lmx+KCmneAGQuyBv820o44L2+TtkSA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.2.0.tgz", + "integrity": "sha512-fXyqzPgCPZbqhrk7k3hPcCpYIlQ2ugIXDboHUzhJISFVy2DEPsmHgN588MyGmkIOv3jDgNfUE3kJi83L28s/LQ==", + "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", @@ -11813,10 +11816,11 @@ } }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12115,13 +12119,13 @@ } }, "node_modules/vite": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.1.tgz", - "integrity": "sha512-4GgM54XrwRfrOp297aIYspIti66k56v16ZnqHvrIM7mG+HjDlAwS7p+Srr7J6fGvEdOJ5JcQ/D9T7HhtdXDTzA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "license": "MIT", "dependencies": { - "esbuild": "^0.24.2", - "postcss": "^8.5.2", + "esbuild": "^0.25.0", + "postcss": "^8.5.3", "rollup": "^4.30.1" }, "bin": { @@ -12186,9 +12190,9 @@ } }, "node_modules/vite-node": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.6.tgz", - "integrity": "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { @@ -12228,19 +12232,19 @@ } }, "node_modules/vitest": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz", - "integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.6", - "@vitest/mocker": "3.0.6", - "@vitest/pretty-format": "^3.0.6", - "@vitest/runner": "3.0.6", - "@vitest/snapshot": "3.0.6", - "@vitest/spy": "3.0.6", - "@vitest/utils": "3.0.6", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", @@ -12252,7 +12256,7 @@ "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.6", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { @@ -12268,8 +12272,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.6", - "@vitest/ui": "3.0.6", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, diff --git a/frontend/package.json b/frontend/package.json index 7144fc390..7f7417e83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,8 +35,8 @@ "postcss-nesting": "^13.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.1.3", - "vite": "^6.1.1" + "react-router": "^7.2.0", + "vite": "^6.2.0" }, "devDependencies": { "@codecov/vite-plugin": "^1.9.0", @@ -47,30 +47,30 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/node": "^22.13.5", + "@types/node": "^22.13.8", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@typescript-eslint/eslint-plugin": "^8.24.1", - "@typescript-eslint/parser": "^8.24.1", + "@typescript-eslint/eslint-plugin": "^8.26.0", + "@typescript-eslint/parser": "^8.26.0", "@vitejs/plugin-react-swc": "^3.8.0", - "@vitest/coverage-v8": "^3.0.6", + "@vitest/coverage-v8": "^3.0.7", "@xstate/graph": "^3.0.2", "cross-env": "^7.0.3", "eslint": "^9.21.0", - "eslint-config-prettier": "^10.0.1", + "eslint-config-prettier": "^10.0.2", "eslint-import-resolver-typescript": "^3.8.3", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-playwright": "^2.2.0", "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "jsdom": "^26.0.0", - "lefthook": "^1.11.0", - "msw": "^2.7.1", - "prettier": "^3.5.2", - "typescript": "^5.7.3", + "lefthook": "^1.11.2", + "msw": "^2.7.3", + "prettier": "^3.5.3", + "typescript": "^5.8.2", "vite-node": "^3.0.4", "vitest": "^3.0.4", "vitest-fail-on-console": "^0.7.1", diff --git a/frontend/playwright.common.config.ts b/frontend/playwright.common.config.ts index 966dfc381..cd86603c4 100644 --- a/frontend/playwright.common.config.ts +++ b/frontend/playwright.common.config.ts @@ -13,6 +13,7 @@ const commonConfig: PlaywrightTestConfig = defineConfig({ // Increase the test timeout on CI, which is usually slower timeout: process.env.CI ? 30_000 : 10_000, fullyParallel: true, + globalSetup: "./e2e-tests/setup.ts", use: { // Local runs don't have retries, so we have a trace of each failure. On CI we do have retries, so keeping the trace of the first failure allows us to investigate flaky tests. trace: "retain-on-first-failure", diff --git a/frontend/playwright.e2e.config.ts b/frontend/playwright.e2e.config.ts index 41ca9377e..91c363bc8 100644 --- a/frontend/playwright.e2e.config.ts +++ b/frontend/playwright.e2e.config.ts @@ -6,12 +6,12 @@ function returnWebserverCommand(): string { if (process.env.CI) { // CI: use existing backend build, reset and seed database const binary = process.platform === "win32" ? "..\\builds\\backend\\abacus.exe" : "../builds/backend/abacus"; - return `${binary} --reset-database --port 8081`; + return `${binary} --reset-database --seed-data --port 8081`; } else if (process.env.LOCAL_CI) { // LOCAL CI: build frontend, then build and run backend with database reset and seed playwright-specific database return `npm run build && cd ../backend && - npx cross-env ASSET_DIR=$PWD/../frontend/dist cargo run --features memory-serve -- --database target/debug/playwright.sqlite --reset-database --port 8081`; + npx cross-env ASSET_DIR=$PWD/../frontend/dist cargo run --features memory-serve -- --database target/debug/playwright.sqlite --reset-database --seed-data --port 8081`; } else { // DEV: expects frontend build and playwright-specific database setup/seeding to have been done return `cd ../backend && npx cross-env ASSET_DIR=$PWD/../frontend/dist cargo run --features memory-serve -- --database ../backend/target/debug/playwright.sqlite --port 8081`; diff --git a/frontend/playwright.ladle.config.ts b/frontend/playwright.ladle.config.ts index 0ae185c23..8ea880f53 100644 --- a/frontend/playwright.ladle.config.ts +++ b/frontend/playwright.ladle.config.ts @@ -11,12 +11,12 @@ const config: PlaywrightTestConfig = defineConfig({ testMatch: /\.e2e\.ts/, use: { ...commonConfig.use, - baseURL: "http://localhost:61000", + baseURL: "http://localhost:61000/ladle/", }, webServer: [ { command: "npm run ladle", - port: 61000, + url: "http://localhost:61000/ladle/", }, ], }); diff --git a/frontend/scripts/openapi/generator.ts b/frontend/scripts/openapi/generator.ts index 3987f517c..147656b35 100644 --- a/frontend/scripts/openapi/generator.ts +++ b/frontend/scripts/openapi/generator.ts @@ -1,7 +1,14 @@ import assert from "assert"; import { format, resolveConfig, resolveConfigFile } from "prettier"; -import { OpenAPIV3, OperationObject, PathsObject, ReferenceObject, SchemaObject } from "./openapi"; +import { + NonArraySchemaObjectType, + OpenAPIV3, + OperationObject, + PathsObject, + ReferenceObject, + SchemaObject, +} from "./openapi"; export async function generate(openApiString: string): Promise { const spec = JSON.parse(openApiString) as OpenAPIV3; @@ -134,6 +141,7 @@ function tsType(s: ReferenceObject | SchemaObject | undefined): string { } let type = "unknown"; + switch (s.type) { case "string": case "boolean": @@ -160,6 +168,19 @@ function tsType(s: ReferenceObject | SchemaObject | undefined): string { if (s.nullable) { type += " | null"; } + + if (Array.isArray(s.type)) { + type = s.type + .map((t: NonArraySchemaObjectType | "null") => { + if (t === "null") { + return "null"; + } + + return tsType({ type: t }); + }) + .join(" | "); + } + return type; } diff --git a/sigrid.yaml b/sigrid.yaml index a6cef189e..a2679b2f4 100644 --- a/sigrid.yaml +++ b/sigrid.yaml @@ -50,6 +50,9 @@ components: - ".*/frontend/app/main.tsx" - ".*/frontend/app/routes.test.tsx" - ".*/frontend/app/routes.tsx" + - name: "(Frontend) Components/apportionment" + include: + - ".*/frontend/app/component/apportionment/.*" - name: "(Frontend) Components/election" include: - ".*/frontend/app/component/election/.*" @@ -85,6 +88,9 @@ components: - name: "(Frontend) Modules/account" include: - ".*/frontend/app/module/account/.*" + - name: "(Frontend) Modules/apportionment" + include: + - ".*/frontend/app/module/apportionment/.*" - name: "(Frontend) Modules/data_entry" include: - ".*/frontend/app/module/data_entry/.*"