From a0ebbdd5c66a83e6942e2a68386efabe6fa48099 Mon Sep 17 00:00:00 2001 From: Marlon Baeten Date: Mon, 3 Aug 2026 10:57:56 +0200 Subject: [PATCH] Allow loading fixture pg in csb dev login --- playwright/tests/csb-import.spec.ts | 21 ++++++++++++ playwright/tests/fixtures.ts | 21 ++++++++++++ src/fixtures/mod.rs | 8 ++--- src/fixtures/political_groups.rs | 10 +++--- src/middleware/dev_login.rs | 50 ++++++++++++++++++++++++----- src/middleware/dev_login_tests.rs | 34 ++++++++++++++++++++ src/state.rs | 2 +- 7 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 playwright/tests/csb-import.spec.ts diff --git a/playwright/tests/csb-import.spec.ts b/playwright/tests/csb-import.spec.ts new file mode 100644 index 000000000..19f85444b --- /dev/null +++ b/playwright/tests/csb-import.spec.ts @@ -0,0 +1,21 @@ +import { expect } from "@playwright/test"; +import { test } from "./fixtures.ts"; + +test("import a political group in the CSB dashboard by hash", async ({ + csbLogin, +}) => { + const { page, groupName, lastEventHash } = csbLogin; + expect(lastEventHash).not.toBe(""); + + await page.goto("/csb/import"); + await page + .getByLabel("Voer het begin van de hash code in") + .fill(lastEventHash); + + await Promise.all([ + page.waitForURL(/\/csb\/examination\/[^/]+/), + page.getByRole("button", { name: "Importeren" }).click(), + ]); + + await expect(page.getByRole("heading", { name: groupName })).toBeVisible(); +}); diff --git a/playwright/tests/fixtures.ts b/playwright/tests/fixtures.ts index 09e3fcee9..e143e334e 100644 --- a/playwright/tests/fixtures.ts +++ b/playwright/tests/fixtures.ts @@ -3,6 +3,12 @@ import { CandidateListsOverviewPage } from "./pages/candidateListsOverviewPage"; import { ManageCandidateListPage } from "./pages/manageCandidateListPage"; import { SelectElectionPage } from "./pages/selectElectionPage"; +type CsbLogin = { + page: Page; + groupName: string; + lastEventHash: string; +}; + type Fixtures = { login: Page; noExistingData: Page; @@ -10,6 +16,7 @@ type Fixtures = { provincialCouncilElection: Page; provincialCouncilFrisianElection: Page; waterAuthorityElection: Page; + csbLogin: CsbLogin; }; export const test = base.extend({ @@ -23,6 +30,20 @@ export const test = base.extend({ await use(page); }, + // Load fixtures into a fresh political-group stream with a unique name and + // capture the chain hash of its last event, then log in as CSB. The hash can + // be entered on the CSB import page to import the group. + csbLogin: async ({ page }, use) => { + const groupName = `Test Partij ${Math.random().toString(36).slice(2, 10)}`; + const response = await page.request.get( + `/dev/login?fixtures=true&name=${encodeURIComponent(groupName)}`, + { maxRedirects: 0 }, + ); + const lastEventHash = response.headers()["x-last-event-hash"] ?? ""; + await page.goto("/dev/login?csb=true"); + await use({ page, groupName, lastEventHash }); + }, + deleteExistingCandidateLists: async ({ page }, use) => { await page.goto(`/dev/login?fixtures=true`); await page.goto("/candidate-lists"); diff --git a/src/fixtures/mod.rs b/src/fixtures/mod.rs index 667aca834..b26cc14fd 100644 --- a/src/fixtures/mod.rs +++ b/src/fixtures/mod.rs @@ -1,10 +1,10 @@ -use crate::{AppError, PgStore}; +use crate::{AppError, PgStore, common::DisplayName}; mod candidate_list; mod persons; mod political_groups; -pub async fn load(store: &PgStore) -> Result<(), AppError> { +pub async fn load(store: &PgStore, display_name: Option) -> Result<(), AppError> { let person_count = store.get_person_count(); let candidate_list_count = store.get_candidate_list_count(); @@ -16,7 +16,7 @@ pub async fn load(store: &PgStore) -> Result<(), AppError> { persons::load(store).await?; candidate_list::load(store).await?; - political_groups::load(store).await?; + political_groups::load(store, display_name).await?; Ok(()) } @@ -28,7 +28,7 @@ mod tests { #[tokio::test] async fn test_load_all_fixtures() { let store = PgStore::new_for_test(); - load(&store).await.unwrap(); + load(&store, None).await.unwrap(); let persons = crate::persons::Person::list( &store, 50, diff --git a/src/fixtures/political_groups.rs b/src/fixtures/political_groups.rs index 8459c1556..05d0cf6d9 100644 --- a/src/fixtures/political_groups.rs +++ b/src/fixtures/political_groups.rs @@ -1,6 +1,6 @@ use crate::{ AppError, PgStore, - common::{Address, DutchAddress, FullName}, + common::{Address, DisplayName, DutchAddress, FullName}, list_designation::ListDesignation, list_submitters::{ListSubmitter, ListSubmitterId}, name_authorisations::{NameAuthorisation, NameAuthorisationId}, @@ -8,7 +8,7 @@ use crate::{ }; use uuid::Uuid; -pub async fn load(store: &PgStore) -> Result<(), AppError> { +pub async fn load(store: &PgStore, display_name: Option) -> Result<(), AppError> { let agent_id: NameAuthorisationId = Uuid::new_v5(&Uuid::NAMESPACE_OID, b"fixture_authorised_agent").into(); @@ -21,7 +21,9 @@ pub async fn load(store: &PgStore) -> Result<(), AppError> { Uuid::new_v5(&Uuid::NAMESPACE_OID, b"fixture_substitute_submitter_2").into(); let political_group = PoliticalGroup { - display_name: Some("Kiesraad Demo".parse().expect("display name")), + display_name: Some( + display_name.unwrap_or_else(|| "Kiesraad Demo".parse().expect("display name")), + ), list_designation: Some(ListDesignation::Standalone), previous_election_results: None, }; @@ -115,7 +117,7 @@ mod tests { #[tokio::test] async fn test_load() { let store = PgStore::new_for_test(); - load(&store).await.unwrap(); + load(&store, None).await.unwrap(); let list_submitter = store.get_list_submitter(); assert!(list_submitter.get_problems(()).is_all_good()); diff --git a/src/middleware/dev_login.rs b/src/middleware/dev_login.rs index 96e80124f..b39a6fb56 100644 --- a/src/middleware/dev_login.rs +++ b/src/middleware/dev_login.rs @@ -1,6 +1,7 @@ use axum::{ extract::{Query, State}, - response::{IntoResponse, Redirect}, + http::{HeaderName, HeaderValue}, + response::{IntoResponse, Redirect, Response}, }; use axum_extra::extract::CookieJar; use secrecy::SecretString; @@ -10,15 +11,19 @@ use crate::{ AppError, AppState, CsbMainEvent, ElectionConfig, Locale, PgEvent, PgStoreData, Scope, Session, StreamId, auth::session_extractor::{build_session_cookie, user_agent_hash}, - common::{IndexPath, SelectElectionPath}, + common::{DisplayName, IndexPath, SelectElectionPath}, csb::index::CsbIndexPath, political_groups::PoliticalGroup, store::Store, - utils::random_bsn, + utils::{format_hash, random_bsn}, }; pub const DEV_LOGIN_PATH: &str = "/dev/login"; +/// Response header on the dev-login redirect carrying the chain hash of the +/// stream's last event, so end-to-end tests can drive the CSB import flow. +pub const LAST_EVENT_HASH_HEADER: HeaderName = HeaderName::from_static("x-last-event-hash"); + /// Placeholder `NameID` for dev-login sessions, which skip the SAML flow. const DEV_LOGIN_NAME_ID: &str = "dev-login-placeholder-name-id"; @@ -28,6 +33,7 @@ pub struct DevLoginQuery { fixtures: Option, select_election: Option, csb: Option, + name: Option, } /// Dev login. By default the session and its stream are scoped to @@ -58,7 +64,15 @@ async fn perform_dev_login( query: DevLoginQuery, headers: axum::http::HeaderMap, scope: Scope, -) -> Result { +) -> Result { + let fixture_name: Option = query + .name + .as_deref() + .filter(|s| !s.is_empty()) + .map(str::parse) + .transpose() + .map_err(|_| AppError::UserError("invalid political group name".to_string()))?; + let id_code: SecretString = query .bsn .as_deref() @@ -84,6 +98,7 @@ async fn perform_dev_login( session.saml_name_id = DEV_LOGIN_NAME_ID.to_string(); let load_fixtures = query.fixtures.unwrap_or(false); + let mut last_event_hash = None; let redirect_to = match scope { Scope::CentralElectoralCommittee => { @@ -110,11 +125,18 @@ async fn perform_dev_login( } else { let election = ElectionConfig::EK27; let (store, was_new) = - ensure_dev_store(&state, stream_id, load_fixtures, election).await?; + ensure_dev_store(&state, stream_id, load_fixtures, fixture_name, election) + .await?; if was_new { store.update(PgEvent::DeveloperLogin { stream_id }).await?; } + last_event_hash = store + .data + .read() + .events + .last() + .map(|e| format_hash(&e.hash, false)); session.set_current_election(election); IndexPath.to_string() @@ -125,16 +147,26 @@ async fn perform_dev_login( state.sessions.cleanup_expired().await; state.sessions.insert(session.clone()).await; - Ok(( + let mut response = ( jar.add(build_session_cookie(&session)), Redirect::to(&redirect_to), - )) + ) + .into_response(); + + if let Some(hash) = last_event_hash { + // The hash is uppercase hex with spaces, always a valid header value + let value = HeaderValue::from_str(&hash).map_err(|_| AppError::InternalServerError)?; + response.headers_mut().insert(LAST_EVENT_HASH_HEADER, value); + } + + Ok(response) } async fn ensure_dev_store( state: &AppState, stream_id: StreamId, load_fixtures: bool, + fixture_name: Option, election: ElectionConfig, ) -> Result<(Store, bool), AppError> { let store = state @@ -152,10 +184,12 @@ async fn ensure_dev_store( if load_fixtures { #[cfg(feature = "fixtures")] { - crate::fixtures::load(&crate::PgStore::own(store.clone())).await?; + crate::fixtures::load(&crate::PgStore::own(store.clone()), fixture_name).await?; return Ok((store, store_is_empty)); } } + #[cfg(not(feature = "fixtures"))] + let _ = fixture_name; Ok((store, store_is_empty)) } diff --git a/src/middleware/dev_login_tests.rs b/src/middleware/dev_login_tests.rs index fc1ce60f4..787b3dc17 100644 --- a/src/middleware/dev_login_tests.rs +++ b/src/middleware/dev_login_tests.rs @@ -217,6 +217,40 @@ async fn dev_login_with_fixtures_loads_fixture_data() { assert!(store.get_candidate_list_count() > 0); } +/// The `name` query sets the fixture group's display name and the redirect +/// carries the chain hash of the stream's last event. +#[cfg(feature = "fixtures")] +#[tokio::test] +async fn dev_login_with_fixtures_uses_name_and_returns_last_event_hash() { + let (state, app) = test_app().await; + + let response = app + .oneshot(dev_login_request("fixtures=true&name=Unieke%20Groep")) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + + let store = open_store(&state).await; + assert_eq!( + store + .get_political_group() + .display_name + .expect("display name") + .to_string(), + "Unieke Groep" + ); + + let last_hash = store.get_events().last().expect("events").hash; + let header = response + .headers() + .get(crate::middleware::dev_login::LAST_EVENT_HASH_HEADER) + .expect("hash header") + .to_str() + .expect("ascii header"); + assert_eq!(header, crate::utils::format_hash(&last_hash, false)); +} + #[tokio::test] async fn dev_login_scopes_session_to_political_group() { let (state, app) = test_app().await; diff --git a/src/state.rs b/src/state.rs index 4498ec9d6..074a6ae2e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -112,7 +112,7 @@ impl AppState { self.store_registry .get_or_create_with_init(stream_id, election, |store| async move { if store.data.read().events.is_empty() && load_fixtures { - crate::fixtures::load(&PgStore::own(store)).await?; + crate::fixtures::load(&PgStore::own(store), None).await?; } Ok(()) })