diff --git a/crates/defguard_common/src/db/models/wizard.rs b/crates/defguard_common/src/db/models/wizard.rs index 004be1866e..e0ffe72739 100644 --- a/crates/defguard_common/src/db/models/wizard.rs +++ b/crates/defguard_common/src/db/models/wizard.rs @@ -132,6 +132,10 @@ impl Wizard { wizard.save(executor).await?; + if active_wizard == ActiveWizard::AutoAdoption { + AutoAdoptionWizardState::default().save(executor).await?; + } + Ok(wizard) } diff --git a/crates/defguard_setup/src/handlers/initial_wizard.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs index da8501d574..b802a2f33d 100644 --- a/crates/defguard_setup/src/handlers/initial_wizard.rs +++ b/crates/defguard_setup/src/handlers/initial_wizard.rs @@ -484,12 +484,23 @@ pub async fn get_wizard_state(Extension(pool): Extension) -> ApiResult { auto_adoption_state: Option, } + let initial_setup_state = if wizard.active_wizard == ActiveWizard::Initial { + InitialSetupState::get(&pool).await? + } else { + None + }; + let auto_adoption_state = if wizard.active_wizard == ActiveWizard::AutoAdoption { + AutoAdoptionWizardState::get(&pool).await? + } else { + None + }; + Ok(ApiResponse::json( WizardStateResponse { active_wizard: wizard.active_wizard, completed: wizard.completed, - initial_setup_state: InitialSetupState::get(&pool).await?, - auto_adoption_state: AutoAdoptionWizardState::get(&pool).await?, + initial_setup_state, + auto_adoption_state, }, StatusCode::OK, )) diff --git a/crates/defguard_setup/tests/auto_adoption_wizard.rs b/crates/defguard_setup/tests/auto_adoption_wizard.rs new file mode 100644 index 0000000000..10f6732eb1 --- /dev/null +++ b/crates/defguard_setup/tests/auto_adoption_wizard.rs @@ -0,0 +1,381 @@ +use defguard_common::db::{ + models::{ + Settings, WireguardNetwork, + settings::initialize_current_settings, + setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, + wireguard::{LocationMfaMode, ServiceLocationMode}, + wizard::{ActiveWizard, Wizard}, + }, + setup_pool, +}; +use ipnetwork::IpNetwork; +use reqwest::{ + Client, StatusCode, + header::{HeaderMap, HeaderValue, USER_AGENT}, +}; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +mod common; +use common::make_setup_test_client; + +const SESSION_COOKIE_NAME: &str = "defguard_session"; + +async fn assert_auto_adoption_step(pool: &sqlx::PgPool, expected: AutoAdoptionWizardStep) { + let state = AutoAdoptionWizardState::get(pool) + .await + .expect("Failed to fetch auto adoption state") + .unwrap_or_default(); + assert_eq!( + state.step, expected, + "Expected auto-adoption step {expected:?}, got {:?}", + state.step + ); +} + +/// Seed a minimal WireguardNetwork row required by the auto-adoption VPN/MFA steps. +async fn seed_wireguard_network(pool: &sqlx::PgPool) -> WireguardNetwork { + WireguardNetwork::new( + "auto-net".to_string(), + vec!["10.0.0.0/24".parse::().unwrap()], + 51820, + "1.2.3.4".to_string(), + None, + 1280, + 0, + vec!["0.0.0.0/0".parse::().unwrap()], + 25, + 180, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .save(pool) + .await + .expect("Failed to save wireguard network") +} + +#[sqlx::test] +async fn test_auto_adoption_full_flow(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + // Auto-adoption requires a pre-existing network to configure + let network = seed_wireguard_network(&pool).await; + + Wizard::init(&pool, true) + .await + .expect("Failed to init wizard"); + + let (client, shutdown_rx) = make_setup_test_client(pool.clone()).await; + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::Welcome).await; + + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "auto_admin", + "email": "auto_admin@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + let _session_cookie = resp + .cookies() + .find(|c| c.name() == SESSION_COOKIE_NAME) + .expect("Session cookie not set after admin creation"); + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::UrlSettings).await; + + let user = defguard_common::db::models::User::find_by_username(&pool, "auto_admin") + .await + .expect("DB query failed") + .expect("Admin user not found in DB"); + assert_eq!(user.email, "auto_admin@example.com"); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/url_settings") + .json(&json!({ + "defguard_url": "https://auto.example.com", + "public_proxy_url": "https://proxy.auto.example.com" + })) + .send() + .await + .expect("Failed to set URL settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::VpnSettings).await; + + let settings = Settings::get_current_settings(); + assert_eq!(settings.defguard_url, "https://auto.example.com"); + assert_eq!(settings.public_proxy_url, "https://proxy.auto.example.com"); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/vpn_settings") + .json(&json!({ + "vpn_public_ip": "5.5.5.5", + "vpn_wireguard_port": 51820, + "vpn_gateway_address": "10.10.0.1/24", + "vpn_allowed_ips": "0.0.0.0/0", + "vpn_dns_server_ip": "8.8.8.8" + })) + .send() + .await + .expect("Failed to set VPN settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::MfaSettings).await; + + let updated_network = WireguardNetwork::find_by_id(&pool, network.id) + .await + .expect("DB query failed") + .expect("Network not found after VPN settings update"); + assert_eq!(updated_network.endpoint, "5.5.5.5"); + assert_eq!(updated_network.port, 51820); + assert_eq!(updated_network.dns, Some("8.8.8.8".to_string())); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/mfa_settings") + .json(&json!({ "vpn_mfa_mode": "disabled" })) + .send() + .await + .expect("Failed to set MFA settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::Summary).await; + + let updated_network = WireguardNetwork::find_by_id(&pool, network.id) + .await + .expect("DB query failed") + .expect("Network not found after MFA settings update"); + assert_eq!(updated_network.location_mfa_mode, LocationMfaMode::Disabled); + + let resp = client + .get("/api/v1/initial_setup/auto_adoption") + .send() + .await + .expect("Failed to get auto adoption result"); + assert_eq!(resp.status(), StatusCode::OK); + let result: serde_json::Value = resp + .json() + .await + .expect("Failed to parse auto adoption result"); + assert_eq!(result["step"], "summary"); + + let resp = client + .post("/api/v1/initial_setup/finish") + .send() + .await + .expect("Failed to finish setup"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed); + assert_eq!(wizard.active_wizard, ActiveWizard::None); + + let shutdown_signal = + tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + assert!( + matches!(shutdown_signal, Ok(Ok(()))), + "Setup server should have sent shutdown signal after finish" + ); +} + +#[sqlx::test] +async fn test_auto_adoption_auth_enforcement(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + seed_wireguard_network(&pool).await; + Wizard::init(&pool, true) + .await + .expect("Failed to init wizard"); + + // Use a fresh client (no cookie jar state) to simulate unauthenticated access + let unauthenticated_client = { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("test/0.0")); + Client::builder() + .default_headers(headers) + .build() + .expect("Failed to build unauthenticated reqwest client") + }; + + let (client_with_session, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let base_url = client_with_session.base_url(); + + let resp = unauthenticated_client + .post(format!("{base_url}/api/v1/initial_setup/admin")) + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "auth_test_admin", + "email": "auth_test@example.com", + "password": "Passw0rd!" + })) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed to POST admin"); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "Admin creation should be allowed without auth at Welcome step" + ); + + let resp = unauthenticated_client + .post(format!( + "{base_url}/api/v1/initial_setup/auto_wizard/url_settings" + )) + .json(&json!({ + "defguard_url": "https://example.com", + "public_proxy_url": "https://proxy.example.com" + })) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed to POST url_settings"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "url_settings should require auth after admin has been created" + ); + + // vpn_settings also blocked + let resp = unauthenticated_client + .post(format!( + "{base_url}/api/v1/initial_setup/auto_wizard/vpn_settings" + )) + .json(&json!({ + "vpn_public_ip": "1.2.3.4", + "vpn_wireguard_port": 51820, + "vpn_gateway_address": "10.0.0.1/24", + "vpn_allowed_ips": "", + "vpn_dns_server_ip": "" + })) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed to POST vpn_settings"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "vpn_settings should require auth" + ); + + // mfa_settings also blocked + let resp = unauthenticated_client + .post(format!( + "{base_url}/api/v1/initial_setup/auto_wizard/mfa_settings" + )) + .json(&json!({ "vpn_mfa_mode": "disabled" })) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed to POST mfa_settings"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "mfa_settings should require auth" + ); + + let resp = client_with_session + .post("/api/v1/initial_setup/login") + .json(&json!({ + "username": "auth_test_admin", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to login"); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client_with_session + .post("/api/v1/initial_setup/auto_wizard/url_settings") + .json(&json!({ + "defguard_url": "https://example.com", + "public_proxy_url": "https://proxy.example.com" + })) + .send() + .await + .expect("Failed to set URL settings after login"); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "url_settings should succeed after login" + ); +} + +#[sqlx::test] +async fn test_auto_adoption_vpn_settings_missing_network( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + Wizard::init(&pool, true) + .await + .expect("Failed to init wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + // Create admin (no auth required yet) + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "no_net_admin", + "email": "no_net@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + + // Set URL settings (requires auth — cookie jar carries session) + let resp = client + .post("/api/v1/initial_setup/auto_wizard/url_settings") + .json(&json!({ + "defguard_url": "https://example.com", + "public_proxy_url": "https://proxy.example.com" + })) + .send() + .await + .expect("Failed to set URL settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + // VPN settings must fail because no network exists + let resp = client + .post("/api/v1/initial_setup/auto_wizard/vpn_settings") + .json(&json!({ + "vpn_public_ip": "1.2.3.4", + "vpn_wireguard_port": 51820, + "vpn_gateway_address": "10.0.0.1/24", + "vpn_allowed_ips": "", + "vpn_dns_server_ip": "" + })) + .send() + .await + .expect("Failed to POST vpn_settings"); + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "Should return 404 when no network exists to configure" + ); + + // Step must NOT have advanced past VpnSettings + assert_auto_adoption_step(&pool, AutoAdoptionWizardStep::VpnSettings).await; +} diff --git a/crates/defguard_setup/tests/common/mod.rs b/crates/defguard_setup/tests/common/mod.rs new file mode 100644 index 0000000000..e8abf68469 --- /dev/null +++ b/crates/defguard_setup/tests/common/mod.rs @@ -0,0 +1,177 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::Arc, +}; + +use axum::serve; +use defguard_common::{ + VERSION, + config::{DefGuardConfig, SERVER_CONFIG}, + db::{ + Id, + models::{ + Settings, User, + group::Group, + settings::{initialize_current_settings, update_current_settings}, + }, + }, +}; +use defguard_setup::{migration::build_migration_webapp, setup_server::build_setup_webapp}; +use reqwest::{ + Client, + cookie::Jar, + header::{HeaderMap, HeaderValue, USER_AGENT}, +}; +use semver::Version; +use sqlx::PgPool; +use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; + +#[allow(dead_code)] +pub const TEST_SECRET_KEY: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +pub struct TestClient { + pub client: Client, + pub _jar: Arc, + pub port: u16, + pub _task: JoinHandle<()>, +} + +impl TestClient { + pub fn new(router: axum::Router, listener: TcpListener) -> Self { + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .expect("server error"); + }); + + let jar = Arc::new(Jar::default()); + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("test/0.0")); + let client = Client::builder() + .default_headers(headers) + .cookie_provider(jar.clone()) + .build() + .expect("Failed to build reqwest client"); + + Self { + client, + _jar: jar, + port, + _task: task, + } + } + + pub fn base_url(&self) -> String { + format!("http://localhost:{}", self.port) + } + + pub fn get(&self, path: &str) -> reqwest::RequestBuilder { + self.client.get(format!("{}{}", self.base_url(), path)) + } + + pub fn post(&self, path: &str) -> reqwest::RequestBuilder { + self.client.post(format!("{}{}", self.base_url(), path)) + } + + #[allow(dead_code)] + pub fn put(&self, path: &str) -> reqwest::RequestBuilder { + self.client.put(format!("{}{}", self.base_url(), path)) + } +} + +#[allow(dead_code)] +pub async fn make_setup_test_client(pool: PgPool) -> (TestClient, oneshot::Receiver<()>) { + let (setup_shutdown_tx, setup_shutdown_rx) = oneshot::channel::<()>(); + let app = build_setup_webapp( + pool, + Version::parse(VERSION).expect("Invalid version"), + setup_shutdown_tx, + ); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let listener = TcpListener::bind(addr) + .await + .expect("Could not bind ephemeral socket"); + (TestClient::new(app, listener), setup_shutdown_rx) +} + +#[allow(dead_code)] +pub async fn make_migration_test_client( + pool: PgPool, +) -> ( + TestClient, + oneshot::Receiver<()>, + defguard_setup::migration::MigrationWebapp, +) { + let (setup_shutdown_tx, setup_shutdown_rx) = oneshot::channel::<()>(); + let webapp = build_migration_webapp( + pool, + Version::parse(VERSION).expect("Invalid version"), + setup_shutdown_tx, + ); + // We must keep `webapp` alive to prevent its event receiver channels from + // being dropped — if they are dropped the `emit_event` call in the auth + // handler will fail with "channel closed". + let router = webapp.router.clone(); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let listener = TcpListener::bind(addr) + .await + .expect("Could not bind ephemeral socket"); + (TestClient::new(router, listener), setup_shutdown_rx, webapp) +} + +/// Initialise settings with a known secret key so `build_migration_webapp` can +/// call `secret_key_required()` without panicking. Also initialises SERVER_CONFIG +/// so the auth handler can call `server_config()`. +#[allow(dead_code)] +pub async fn init_settings_with_secret_key(pool: &PgPool) { + initialize_current_settings(pool) + .await + .expect("Failed to initialize settings"); + let mut settings = Settings::get_current_settings(); + settings.secret_key = Some(TEST_SECRET_KEY.to_string()); + settings.defguard_url = "http://localhost:8000".to_string(); + settings.webauthn_rp_id = Some("localhost".to_string()); + update_current_settings(pool, settings) + .await + .expect("Failed to update settings"); + + let mut config = DefGuardConfig::new_test_config(); + config.cookie_insecure = true; + config.initialize_post_settings(); + let _ = SERVER_CONFIG.set(config); +} + +/// Creates an admin group + admin user and returns the user. +/// `User::is_admin()` checks group membership, not a column flag. +#[allow(dead_code)] +pub async fn seed_admin_user(pool: &PgPool, username: &str, password: &str) -> User { + let mut admin_group = Group::new("admins"); + admin_group.is_admin = true; + let admin_group = admin_group + .save(pool) + .await + .expect("Failed to save admin group"); + + let user = User::new( + username, + Some(password), + "Admin", + "Migration", + &format!("{username}@example.com"), + None, + ) + .save(pool) + .await + .expect("Failed to save admin user"); + + user.add_to_group(pool, &admin_group) + .await + .expect("Failed to add user to admin group"); + + user +} diff --git a/crates/defguard_setup/tests/initial_setup.rs b/crates/defguard_setup/tests/initial_setup.rs index 87ffbc7270..013b5c5a20 100644 --- a/crates/defguard_setup/tests/initial_setup.rs +++ b/crates/defguard_setup/tests/initial_setup.rs @@ -30,9 +30,11 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::{ net::TcpListener, sync::{Notify, oneshot}, - task::JoinHandle, }; +mod common; +use common::make_setup_test_client; + const SESSION_COOKIE_NAME: &str = "defguard_session"; async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { @@ -43,70 +45,6 @@ async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { assert_eq!(step, expected); } -struct TestClient { - client: Client, - _jar: Arc, - port: u16, - _task: JoinHandle<()>, -} - -impl TestClient { - fn new(app: axum::Router, listener: TcpListener) -> Self { - let port = listener.local_addr().unwrap().port(); - let task = tokio::spawn(async move { - let server = serve( - listener, - app.into_make_service_with_connect_info::(), - ); - server.await.expect("server error"); - }); - - let jar = Arc::new(Jar::default()); - let mut headers = HeaderMap::new(); - headers.insert(USER_AGENT, HeaderValue::from_static("test/0.0")); - - let client = Client::builder() - .default_headers(headers) - .cookie_provider(jar.clone()) - .build() - .expect("Failed to build reqwest client"); - - Self { - client, - _jar: jar, - port, - _task: task, - } - } - - fn base_url(&self) -> String { - format!("http://localhost:{}", self.port) - } - - fn get(&self, path: &str) -> reqwest::RequestBuilder { - self.client.get(format!("{}{}", self.base_url(), path)) - } - - fn post(&self, path: &str) -> reqwest::RequestBuilder { - self.client.post(format!("{}{}", self.base_url(), path)) - } -} - -async fn make_setup_test_client(pool: sqlx::PgPool) -> (TestClient, oneshot::Receiver<()>) { - let (setup_shutdown_tx, setup_shutdown_rx) = oneshot::channel::<()>(); - let app = build_setup_webapp( - pool, - Version::parse(VERSION).expect("Invalid version"), - setup_shutdown_tx, - ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); - let listener = TcpListener::bind(addr) - .await - .expect("Could not bind ephemeral socket"); - - (TestClient::new(app, listener), setup_shutdown_rx) -} - #[sqlx::test] async fn test_create_admin(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_setup/tests/migration_wizard.rs b/crates/defguard_setup/tests/migration_wizard.rs new file mode 100644 index 0000000000..b4faff3e8c --- /dev/null +++ b/crates/defguard_setup/tests/migration_wizard.rs @@ -0,0 +1,291 @@ +use defguard_common::db::{ + models::{ + Settings, + migration_wizard::MigrationWizardState, + wizard::{ActiveWizard, Wizard}, + }, + setup_pool, +}; +use reqwest::{ + Client, StatusCode, + header::{HeaderMap, HeaderValue, USER_AGENT}, +}; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +mod common; +use common::{init_settings_with_secret_key, make_migration_test_client, seed_admin_user}; + +async fn assert_migration_step(pool: &sqlx::PgPool, expected_variant: &str) { + let state = MigrationWizardState::get(pool) + .await + .expect("Failed to fetch migration wizard state") + .unwrap_or_default(); + let serialized = + serde_json::to_value(&state.current_step).expect("Failed to serialize migration step"); + assert_eq!( + serialized, + serde_json::Value::String(expected_variant.to_string()), + "Expected migration step '{expected_variant}', got {serialized}" + ); +} + +#[sqlx::test] +async fn test_migration_full_flow(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_settings_with_secret_key(&pool).await; + + seed_admin_user(&pool, "migration_admin", "Passw0rd!").await; + + Wizard::init(&pool, false) + .await + .expect("Failed to init wizard"); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert_eq!(wizard.active_wizard, ActiveWizard::Migration); + + let (client, shutdown_rx, _webapp) = make_migration_test_client(pool.clone()).await; + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!(body["active_wizard"], "migration"); + assert_eq!(body["authorized"], false); + + let resp = client + .post("/api/v1/auth") + .json(&json!({ + "username": "migration_admin", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to authenticate"); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .get("/api/v1/migration/state") + .send() + .await + .expect("Failed to GET /api/v1/migration/state"); + assert_eq!(resp.status(), StatusCode::OK); + let state: serde_json::Value = resp.json().await.expect("Failed to parse migration state"); + assert_eq!( + state["current_step"], "welcome", + "Initial migration step should be 'welcome'" + ); + assert!( + state["location_state"].is_null(), + "location_state should be null initially" + ); + + let resp = client + .put("/api/v1/migration/state") + .json(&json!({ + "current_step": "general", + "location_state": null + })) + .send() + .await + .expect("Failed to PUT /api/v1/migration/state"); + assert_eq!(resp.status(), StatusCode::OK); + + assert_migration_step(&pool, "general").await; + + let resp = client + .post("/api/v1/migration/general_config") + .json(&json!({ + "defguard_url": "https://migration.example.com", + "default_admin_group_name": "admins", + "default_authentication": 14, + "default_mfa_code_lifetime": 120, + "public_proxy_url": "https://proxy.migration.example.com" + })) + .send() + .await + .expect("Failed to POST /api/v1/migration/general_config"); + assert_eq!(resp.status(), StatusCode::OK); + + let settings = Settings::get(&pool) + .await + .expect("Failed to fetch settings") + .expect("Settings not found"); + assert_eq!(settings.defguard_url, "https://migration.example.com"); + assert_eq!(settings.authentication_period_days, 14); + assert_eq!(settings.mfa_code_timeout_seconds, 120); + + let resp = client + .post("/api/v1/migration/ca") + .json(&json!({ + "common_name": "Migration CA", + "email": "ca@migration.example.com", + "validity_period_years": 1 + })) + .send() + .await + .expect("Failed to POST /api/v1/migration/ca"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let settings = Settings::get(&pool) + .await + .expect("Failed to fetch settings") + .expect("Settings not found"); + assert!(settings.ca_cert_der.is_some(), "CA cert should be set"); + assert!(settings.ca_key_der.is_some(), "CA key should be set"); + assert!(settings.ca_expiry.is_some(), "CA expiry should be set"); + + let resp = client + .put("/api/v1/migration/state") + .json(&json!({ + "current_step": "confirmation", + "location_state": null + })) + .send() + .await + .expect("Failed to PUT migration state to confirmation"); + assert_eq!(resp.status(), StatusCode::OK); + assert_migration_step(&pool, "confirmation").await; + + let resp = client + .post("/api/v1/migration/finish") + .send() + .await + .expect("Failed to POST /api/v1/migration/finish"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed, "Wizard should be completed after finish"); + assert_eq!(wizard.active_wizard, ActiveWizard::None); + + let migration_state = MigrationWizardState::get(&pool) + .await + .expect("Failed to get migration state"); + assert!( + migration_state.is_none(), + "Migration wizard state should be cleared after finish" + ); + + let shutdown_signal = + tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + assert!( + matches!(shutdown_signal, Ok(Ok(()))), + "Migration server should have sent shutdown signal after finish" + ); +} + +#[sqlx::test] +async fn test_migration_auth_enforcement(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_settings_with_secret_key(&pool).await; + + seed_admin_user(&pool, "auth_migration_admin", "Passw0rd!").await; + Wizard::init(&pool, false) + .await + .expect("Failed to init wizard"); + + let (client, _shutdown_rx, _webapp) = make_migration_test_client(pool.clone()).await; + + let unauth = { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, HeaderValue::from_static("test/0.0")); + Client::builder() + .default_headers(headers) + .build() + .expect("Failed to build unauthenticated client") + }; + let base = client.base_url(); + + let resp = unauth + .get(format!("{base}/api/v1/migration/state")) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed GET migration/state"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "GET /migration/state should require auth" + ); + + let resp = unauth + .put(format!("{base}/api/v1/migration/state")) + .header(USER_AGENT, "test/0.0") + .json(&json!({"current_step": "general", "location_state": null})) + .send() + .await + .expect("Failed PUT migration/state"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "PUT /migration/state should require auth" + ); + + let resp = unauth + .post(format!("{base}/api/v1/migration/general_config")) + .header(USER_AGENT, "test/0.0") + .json(&json!({ + "defguard_url": "https://x.example.com", + "default_admin_group_name": "admins", + "default_authentication": 14, + "default_mfa_code_lifetime": 120, + "public_proxy_url": "https://px.example.com" + })) + .send() + .await + .expect("Failed POST migration/general_config"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "POST /migration/general_config should require auth" + ); + + let resp = unauth + .post(format!("{base}/api/v1/migration/finish")) + .header(USER_AGENT, "test/0.0") + .send() + .await + .expect("Failed POST migration/finish"); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "POST /migration/finish should require auth" + ); + + let resp = client + .post("/api/v1/auth") + .json(&json!({ + "username": "auth_migration_admin", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to authenticate"); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .get("/api/v1/migration/state") + .send() + .await + .expect("Failed GET migration/state after login"); + assert_eq!( + resp.status(), + StatusCode::OK, + "GET /migration/state should succeed after login" + ); + + let resp = client + .put("/api/v1/migration/state") + .json(&json!({"current_step": "general", "location_state": null})) + .send() + .await + .expect("Failed PUT migration/state after login"); + assert_eq!( + resp.status(), + StatusCode::OK, + "PUT /migration/state should succeed after login" + ); +} diff --git a/crates/defguard_setup/tests/session_info.rs b/crates/defguard_setup/tests/session_info.rs new file mode 100644 index 0000000000..b90ad54d80 --- /dev/null +++ b/crates/defguard_setup/tests/session_info.rs @@ -0,0 +1,184 @@ +use defguard_common::db::{ + models::{ + User, + group::Group, + settings::initialize_current_settings, + wizard::{ActiveWizard, Wizard}, + }, + setup_pool, +}; +use reqwest::StatusCode; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +mod common; +use common::{init_settings_with_secret_key, make_migration_test_client, make_setup_test_client}; + +#[sqlx::test] +async fn test_session_info_setup_server(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!(body["active_wizard"], "initial"); + assert_eq!(body["authorized"], false); + assert_eq!(body["is_admin"], false); + + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!", + "automatically_assign_group": true + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info after admin creation"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!( + body["active_wizard"], "initial", + "Wizard should still be 'initial' mid-flow" + ); + assert_eq!(body["authorized"], true); + assert_eq!(body["is_admin"], true); + + let resp = client + .post("/api/v1/initial_setup/finish") + .send() + .await + .expect("Failed to finish setup"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed); + assert_eq!(wizard.active_wizard, ActiveWizard::None); +} + +#[sqlx::test] +async fn test_session_info_auto_adoption_wizard(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + // has_auto_adopt_flags = true: AutoAdoption wizard + Wizard::init(&pool, true) + .await + .expect("Failed to initialize wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!( + body["active_wizard"], "auto_adoption", + "Should report auto_adoption wizard" + ); + assert_eq!(body["authorized"], false); +} + +#[sqlx::test] +async fn test_session_info_migration_server(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + init_settings_with_secret_key(&pool).await; + + let user = User::new( + "migrating_admin", + Some("Passw0rd!"), + "Admin", + "Migrating", + "migrating_admin@example.com", + None, + ) + .save(&pool) + .await + .expect("Failed to save admin user"); + + // Make that user an admin via group membership (is_admin is group-based, not a column) + let mut admin_group = Group::new("admins"); + admin_group.is_admin = true; + let admin_group = admin_group + .save(&pool) + .await + .expect("Failed to save admin group"); + user.add_to_group(&pool, &admin_group) + .await + .expect("Failed to add user to admin group"); + + Wizard::init(&pool, false) + .await + .expect("Failed to initialize wizard"); + + let (client, _shutdown_rx, _webapp) = make_migration_test_client(pool.clone()).await; + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!(body["active_wizard"], "migration"); + assert_eq!(body["authorized"], false); + + let resp = client + .post("/api/v1/auth") + .json(&json!({ + "username": "migrating_admin", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to authenticate"); + assert_eq!(resp.status(), StatusCode::OK); + + let resp = client + .get("/api/v1/session-info") + .send() + .await + .expect("Failed to get session-info after login"); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = resp.json().await.expect("Failed to parse session-info"); + assert_eq!(body["active_wizard"], "migration"); + assert_eq!(body["authorized"], true); + assert_eq!(body["is_admin"], true); + + let resp = client + .post("/api/v1/migration/finish") + .send() + .await + .expect("Failed to finish migration"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed); + assert_eq!(wizard.active_wizard, ActiveWizard::None); +} diff --git a/crates/defguard_setup/tests/wizard_init.rs b/crates/defguard_setup/tests/wizard_init.rs new file mode 100644 index 0000000000..8881acd769 --- /dev/null +++ b/crates/defguard_setup/tests/wizard_init.rs @@ -0,0 +1,166 @@ +use defguard_common::db::{ + models::{ + User, + settings::initialize_current_settings, + wizard::{ActiveWizard, Wizard}, + }, + setup_pool, +}; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +#[sqlx::test] +async fn test_wizard_init_fresh_db(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + // Fresh DB + no auto-adopt flags: Initial wizard + let wizard = Wizard::init(&pool, false) + .await + .expect("Failed to init wizard"); + + assert_eq!(wizard.active_wizard, ActiveWizard::Initial); + assert!(!wizard.completed); + assert!(wizard.is_active()); + + // requires_auth returns false at the Welcome step (no admin created yet) + let requires_auth = wizard + .requires_auth(&pool) + .await + .expect("Failed to check requires_auth"); + assert!( + !requires_auth, + "Initial wizard at Welcome step should not require auth yet" + ); + + let wizard_from_db = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert_eq!(wizard_from_db.active_wizard, ActiveWizard::Initial); + assert!(!wizard_from_db.completed); +} + +#[sqlx::test] +async fn test_wizard_init_auto_adopt_flags(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + // Fresh DB + auto-adopt flags: AutoAdoption wizard + let wizard = Wizard::init(&pool, true) + .await + .expect("Failed to init wizard"); + + assert_eq!(wizard.active_wizard, ActiveWizard::AutoAdoption); + assert!(!wizard.completed); + assert!(wizard.is_active()); + + // requires_auth returns false at the Welcome step + let requires_auth = wizard + .requires_auth(&pool) + .await + .expect("Failed to check requires_auth"); + assert!( + !requires_auth, + "AutoAdoption wizard at Welcome step should not require auth yet" + ); + + let wizard_from_db = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert_eq!(wizard_from_db.active_wizard, ActiveWizard::AutoAdoption); + assert!(!wizard_from_db.completed); +} + +#[sqlx::test] +async fn test_wizard_init_existing_data(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + User::new( + "existing_user", + Some("Passw0rd!"), + "Existing", + "User", + "existing@example.com", + None, + ) + .save(&pool) + .await + .expect("Failed to save user"); + + // Existing data + no auto-adopt flags: Migration wizard + let wizard = Wizard::init(&pool, false) + .await + .expect("Failed to init wizard"); + + assert_eq!(wizard.active_wizard, ActiveWizard::Migration); + assert!(!wizard.completed); + assert!(wizard.is_active()); + + // Migration wizard always requires auth (admin must log in) + let requires_auth = wizard + .requires_auth(&pool) + .await + .expect("Failed to check requires_auth"); + assert!(requires_auth, "Migration wizard should always require auth"); + + let wizard_from_db = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert_eq!(wizard_from_db.active_wizard, ActiveWizard::Migration); +} + +#[sqlx::test] +async fn test_wizard_init_idempotent(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + let first = Wizard::init(&pool, false) + .await + .expect("Failed to first init"); + assert_eq!(first.active_wizard, ActiveWizard::Initial); + + let second = Wizard::init(&pool, false) + .await + .expect("Failed to second init"); + assert_eq!( + second.active_wizard, + ActiveWizard::Initial, + "Second init should resume the already-active wizard" + ); + + let third = Wizard::init(&pool, true) + .await + .expect("Failed to third init"); + assert_eq!( + third.active_wizard, + ActiveWizard::Initial, + "Already-active wizard should not be switched by flags" + ); + + // Simulate completion: mark wizard as done + let mut wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + wizard.completed = true; + wizard.active_wizard = ActiveWizard::None; + wizard.save(&pool).await.expect("Failed to save wizard"); + + // Init after completion: completed flag is respected, nothing changes + let after_completion = Wizard::init(&pool, false) + .await + .expect("Failed to init after completion"); + assert!( + after_completion.completed, + "Completed wizard should stay completed" + ); + assert_eq!( + after_completion.active_wizard, + ActiveWizard::None, + "Active wizard should remain None after completion" + ); + assert!(!after_completion.is_active()); + + let wizard_from_db = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard_from_db.completed); + assert_eq!(wizard_from_db.active_wizard, ActiveWizard::None); +} diff --git a/crates/defguard_setup/tests/wizard_state.rs b/crates/defguard_setup/tests/wizard_state.rs new file mode 100644 index 0000000000..cc816a001a --- /dev/null +++ b/crates/defguard_setup/tests/wizard_state.rs @@ -0,0 +1,282 @@ +use defguard_common::db::{ + models::{ + settings::initialize_current_settings, + setup_auto_adoption::AutoAdoptionWizardStep, + wireguard::{LocationMfaMode, ServiceLocationMode, WireguardNetwork}, + wizard::{ActiveWizard, Wizard}, + }, + setup_pool, +}; +use reqwest::StatusCode; +use serde_json::json; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; + +mod common; +use common::make_setup_test_client; + +#[sqlx::test] +async fn test_wizard_state_initial(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + Wizard::init(&pool, false) + .await + .expect("Failed to init wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + let resp = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard"); + assert_eq!(resp.status(), StatusCode::OK); + let state: serde_json::Value = resp.json().await.expect("Failed to parse wizard state"); + + assert_eq!(state["active_wizard"], "initial"); + assert_eq!(state["completed"], false); + assert_eq!( + state["initial_setup_state"]["step"], "welcome", + "Initial step should be 'welcome' before any action" + ); + assert!( + state["auto_adoption_state"].is_null(), + "auto_adoption_state should be null for Initial wizard" + ); + + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let state: serde_json::Value = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard") + .json() + .await + .expect("Failed to parse wizard state"); + assert_eq!( + state["initial_setup_state"]["step"], + "general_configuration" + ); + + let resp = client + .post("/api/v1/initial_setup/general_config") + .json(&json!({ + "defguard_url": "https://example.com", + "default_admin_group_name": "admins", + "default_authentication": 14, + "default_mfa_code_lifetime": 120, + "public_proxy_url": "https://proxy.example.com", + "admin_username": "admin1" + })) + .send() + .await + .expect("Failed to set general config"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let state: serde_json::Value = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard") + .json() + .await + .expect("Failed to parse wizard state"); + assert_eq!(state["initial_setup_state"]["step"], "ca"); + + let resp = client + .post("/api/v1/initial_setup/ca") + .json(&json!({ + "common_name": "Test CA", + "email": "ca@example.com", + "validity_period_years": 1 + })) + .send() + .await + .expect("Failed to create CA"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let state: serde_json::Value = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard") + .json() + .await + .expect("Failed to parse wizard state"); + assert_eq!(state["initial_setup_state"]["step"], "ca_summary"); + + let resp = client + .post("/api/v1/initial_setup/finish") + .send() + .await + .expect("Failed to finish setup"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed); + assert_eq!(wizard.active_wizard, ActiveWizard::None); +} + +#[sqlx::test] +async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); + + WireguardNetwork::new( + "auto-net".to_string(), + vec!["10.0.0.0/24".parse().unwrap()], + 51820, + "1.2.3.4".to_string(), + None, + 1280, + 0, + vec!["0.0.0.0/0".parse().unwrap()], + 25, + 180, + false, + false, + LocationMfaMode::Disabled, + ServiceLocationMode::Disabled, + ) + .save(&pool) + .await + .expect("Failed to seed wireguard network"); + + Wizard::init(&pool, true) + .await + .expect("Failed to init wizard"); + + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + let state: serde_json::Value = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard") + .json() + .await + .expect("Failed to parse wizard state"); + + assert_eq!(state["active_wizard"], "auto_adoption"); + assert_eq!(state["completed"], false); + assert_eq!( + state["auto_adoption_state"]["step"], "welcome", + "Initial auto-adoption step should be 'welcome'" + ); + assert!( + state["initial_setup_state"].is_null(), + "initial_setup_state should be null for AutoAdoption wizard" + ); + + let resp = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let state: serde_json::Value = client + .get("/api/v1/wizard") + .send() + .await + .expect("Failed to GET /api/v1/wizard") + .json() + .await + .expect("Failed to parse wizard state"); + assert_eq!(state["active_wizard"], "auto_adoption"); + + let auto_state = + defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .unwrap_or_default(); + assert_eq!(auto_state.step, AutoAdoptionWizardStep::UrlSettings); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/url_settings") + .json(&json!({ + "defguard_url": "https://example.com", + "public_proxy_url": "https://proxy.example.com" + })) + .send() + .await + .expect("Failed to set URL settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let auto_state = + defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); + assert_eq!(auto_state.step, AutoAdoptionWizardStep::VpnSettings); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/vpn_settings") + .json(&json!({ + "vpn_public_ip": "1.2.3.4", + "vpn_wireguard_port": 51820, + "vpn_gateway_address": "10.0.0.1/24", + "vpn_allowed_ips": "0.0.0.0/0", + "vpn_dns_server_ip": "8.8.8.8" + })) + .send() + .await + .expect("Failed to set VPN settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let auto_state = + defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); + assert_eq!(auto_state.step, AutoAdoptionWizardStep::MfaSettings); + + let resp = client + .post("/api/v1/initial_setup/auto_wizard/mfa_settings") + .json(&json!({ "vpn_mfa_mode": "disabled" })) + .send() + .await + .expect("Failed to set MFA settings"); + assert_eq!(resp.status(), StatusCode::CREATED); + + let auto_state = + defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); + assert_eq!(auto_state.step, AutoAdoptionWizardStep::Summary); + + let resp = client + .post("/api/v1/initial_setup/finish") + .send() + .await + .expect("Failed to finish setup"); + assert_eq!(resp.status(), StatusCode::OK); + + let wizard = Wizard::get(&pool).await.expect("Failed to get wizard"); + assert!(wizard.completed); + assert_eq!(wizard.active_wizard, ActiveWizard::None); +}