Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions playwright/tests/csb-import.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
21 changes: 21 additions & 0 deletions playwright/tests/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ 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;
deleteExistingCandidateLists: Page;
provincialCouncilElection: Page;
provincialCouncilFrisianElection: Page;
waterAuthorityElection: Page;
csbLogin: CsbLogin;
};

export const test = base.extend<Fixtures>({
Expand All @@ -23,6 +30,20 @@ export const test = base.extend<Fixtures>({
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");
Expand Down
8 changes: 4 additions & 4 deletions src/fixtures/mod.rs
Original file line number Diff line number Diff line change
@@ -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<DisplayName>) -> Result<(), AppError> {
let person_count = store.get_person_count();
let candidate_list_count = store.get_candidate_list_count();

Expand All @@ -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(())
}
Expand All @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions src/fixtures/political_groups.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
use crate::{
AppError, PgStore,
common::{Address, DutchAddress, FullName},
common::{Address, DisplayName, DutchAddress, FullName},
list_designation::ListDesignation,
list_submitters::{ListSubmitter, ListSubmitterId},
name_authorisations::{NameAuthorisation, NameAuthorisationId},
political_groups::PoliticalGroup,
};
use uuid::Uuid;

pub async fn load(store: &PgStore) -> Result<(), AppError> {
pub async fn load(store: &PgStore, display_name: Option<DisplayName>) -> Result<(), AppError> {
let agent_id: NameAuthorisationId =
Uuid::new_v5(&Uuid::NAMESPACE_OID, b"fixture_authorised_agent").into();

Expand All @@ -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,
};
Expand Down Expand Up @@ -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());
Expand Down
50 changes: 42 additions & 8 deletions src/middleware/dev_login.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";

Expand All @@ -28,6 +33,7 @@ pub struct DevLoginQuery {
fixtures: Option<bool>,
select_election: Option<bool>,
csb: Option<bool>,
name: Option<String>,
}

/// Dev login. By default the session and its stream are scoped to
Expand Down Expand Up @@ -58,7 +64,15 @@ async fn perform_dev_login(
query: DevLoginQuery,
headers: axum::http::HeaderMap,
scope: Scope,
) -> Result<impl IntoResponse, AppError> {
) -> Result<Response, AppError> {
let fixture_name: Option<DisplayName> = 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()
Expand All @@ -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 => {
Expand All @@ -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()
Expand All @@ -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<DisplayName>,
election: ElectionConfig,
) -> Result<(Store<PgStoreData>, bool), AppError> {
let store = state
Expand All @@ -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))
}
34 changes: 34 additions & 0 deletions src/middleware/dev_login_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
})
Expand Down