diff --git a/tests/cli/helpers.rs b/tests/cli/helpers.rs index 7261629..9a4bc7b 100644 --- a/tests/cli/helpers.rs +++ b/tests/cli/helpers.rs @@ -8,11 +8,9 @@ use tempfile::TempDir; /// Fields prefixed with `_` are kept alive to prevent cleanup: /// - `_temp_dir`: keeps temporary database file from being deleted /// - `_process`: keeps server process running -/// - `_db_url`: retained for consistency struct SharedServer { address: String, - admin_password: String, - _db_url: String, + db_url: String, _temp_dir: TempDir, _process: std::process::Child, } @@ -40,11 +38,10 @@ fn ensure_server_started() -> Result<(String, String), String> { let temp_dir = TempDir::new().map_err(|e| format!("Failed to create temp dir: {}", e))?; let db_path = temp_dir.path().join("test.db"); let db_url = format!("sqlite:{}", db_path.display()); - let admin_password = "test_admin_password"; // Start server on a random port let port = portpicker::pick_unused_port().ok_or("No ports available")?; - let address = format!("http://127.0.0.1:{}", port); + let address = format!("http://localhost:{}", port); eprintln!("Starting server on {}", address); @@ -57,8 +54,10 @@ fn ensure_server_started() -> Result<(String, String), String> { "--database-url", &db_url, ]) - .env("BREWLOG_ADMIN_PASSWORD", admin_password) - .env("BREWLOG_ADMIN_USERNAME", "admin") + .env("BREWLOG_RP_ID", "localhost") + .env("BREWLOG_RP_ORIGIN", &address) + .env("BREWLOG_OPENROUTER_API_KEY", "test-key") + .env("BREWLOG_FOURSQUARE_API_KEY", "test-key") .env("RUST_LOG", "error") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -97,55 +96,70 @@ fn ensure_server_started() -> Result<(String, String), String> { *server = Some(SharedServer { address: address.clone(), - admin_password: admin_password.to_string(), - _db_url: db_url, + db_url: db_url.clone(), _temp_dir: temp_dir, _process: process, }); } let srv = server.as_ref().unwrap(); - Ok((srv.address.clone(), srv.admin_password.clone())) + Ok((srv.address.clone(), srv.db_url.clone())) } -/// Get the shared server address and admin password +/// Get the shared server address and database URL pub fn server_info() -> (String, String) { ensure_server_started().expect("Failed to start test server") } -/// Create a token for testing using the CLI +/// Create a token for testing by directly inserting into the database pub fn create_token(name: &str) -> String { - let (_, password) = ensure_server_started().expect("Failed to start test server"); + let (_, db_url) = ensure_server_started().expect("Failed to start test server"); - let output = run_brewlog( - &[ - "token", - "create", - "--name", - name, - "--username", - "admin", - "--password", - &password, - ], - &[], - ); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); - if !output.status.success() { - panic!( - "Failed to create token: {}", - String::from_utf8_lossy(&output.stderr) - ); - } + rt.block_on(async { + let pool = sqlx::SqlitePool::connect(&db_url) + .await + .expect("Failed to connect to test database"); - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if let Some(token) = line.trim().strip_prefix("export BREWLOG_TOKEN=") { - return token.to_string(); - } - } + // Ensure a user exists (INSERT OR IGNORE to handle concurrent test threads) + let uuid = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT OR IGNORE INTO users (username, uuid, created_at) VALUES (?, ?, datetime('now'))", + ) + .bind("admin") + .bind(&uuid) + .execute(&pool) + .await + .expect("Failed to ensure test user"); - panic!("Could not find token in output: {}", stdout); + let user_id: i64 = + sqlx::query_scalar::<_, i64>("SELECT id FROM users WHERE username = ?") + .bind("admin") + .fetch_one(&pool) + .await + .expect("Failed to query test user"); + + // Generate and insert a bearer token + let token_value = + brewlog::infrastructure::auth::generate_token().expect("Failed to generate token"); + let token_hash = brewlog::infrastructure::auth::hash_token(&token_value); + + sqlx::query( + "INSERT INTO tokens (user_id, token_hash, name, created_at) VALUES (?, ?, ?, datetime('now'))", + ) + .bind(user_id) + .bind(&token_hash) + .bind(name) + .execute(&pool) + .await + .expect("Failed to insert token"); + + token_value + }) } /// Run a brewlog CLI command and return the output diff --git a/tests/server/auth_api.rs b/tests/server/auth_api.rs index eedcf45..83ebbbe 100644 --- a/tests/server/auth_api.rs +++ b/tests/server/auth_api.rs @@ -4,18 +4,32 @@ use serde_json::json; use crate::helpers::spawn_app_with_auth; #[tokio::test] -async fn test_create_token_with_valid_credentials() { +async fn test_create_token_requires_authentication() { let app = spawn_app_with_auth().await; let client = Client::new(); - // Create a token + // Token creation now requires session or bearer token auth let response = client .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "test_password", - "name": "test-token" - })) + .json(&json!({ "name": "test-token" })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_create_token_with_bearer_auth() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); + + // Create a token using bearer auth + let response = client + .post(&app.api_url("/tokens")) + .bearer_auth(auth_token) + .json(&json!({ "name": "new-token" })) .send() .await .expect("Failed to send request"); @@ -24,7 +38,7 @@ async fn test_create_token_with_valid_credentials() { let body: serde_json::Value = response.json().await.expect("Failed to parse response"); assert!(body.get("id").is_some()); - assert_eq!(body.get("name").unwrap(), "test-token"); + assert_eq!(body.get("name").unwrap(), "new-token"); assert!(body.get("token").is_some()); // Token should be a non-empty string @@ -32,26 +46,6 @@ async fn test_create_token_with_valid_credentials() { assert!(!token.is_empty()); } -#[tokio::test] -async fn test_create_token_with_invalid_credentials() { - let app = spawn_app_with_auth().await; - let client = Client::new(); - - // Try to create a token with wrong password - let response = client - .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "wrong_password", - "name": "test-token" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); -} - #[tokio::test] async fn test_list_tokens_requires_authentication() { let app = spawn_app_with_auth().await; @@ -71,29 +65,12 @@ async fn test_list_tokens_requires_authentication() { async fn test_list_tokens_with_authentication() { let app = spawn_app_with_auth().await; let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); - // First, create a token - let create_response = client - .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "test_password", - "name": "test-token" - })) - .send() - .await - .expect("Failed to send request"); - - let create_body: serde_json::Value = create_response - .json() - .await - .expect("Failed to parse response"); - let token = create_body.get("token").unwrap().as_str().unwrap(); - - // Now list tokens with authentication + // List tokens with authentication let response = client .get(&app.api_url("/tokens")) - .header("Authorization", format!("Bearer {}", token)) + .bearer_auth(auth_token) .send() .await .expect("Failed to send request"); @@ -101,9 +78,11 @@ async fn test_list_tokens_with_authentication() { assert_eq!(response.status(), StatusCode::OK); let tokens: Vec = response.json().await.expect("Failed to parse response"); - // We expect 2 tokens: the one created by spawn_app_with_auth() and the one we just created - assert_eq!(tokens.len(), 2); - // Find the token we created + // We expect at least 1 token (the one created by spawn_app_with_auth) + assert!( + !tokens.is_empty(), + "Should have at least one token from test setup" + ); let test_token = tokens .iter() .find(|t| t.get("name").unwrap() == "test-token") @@ -115,15 +94,13 @@ async fn test_list_tokens_with_authentication() { async fn test_revoke_token() { let app = spawn_app_with_auth().await; let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); - // First, create a token + // Create a new token to revoke let create_response = client .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "test_password", - "name": "test-token" - })) + .bearer_auth(auth_token) + .json(&json!({ "name": "token-to-revoke" })) .send() .await .expect("Failed to send request"); @@ -132,13 +109,12 @@ async fn test_revoke_token() { .json() .await .expect("Failed to parse response"); - let token = create_body.get("token").unwrap().as_str().unwrap(); let token_id = create_body.get("id").unwrap().as_i64().unwrap(); // Revoke the token let response = client .post(&app.api_url(&format!("/tokens/{}/revoke", token_id))) - .header("Authorization", format!("Bearer {}", token)) + .bearer_auth(auth_token) .send() .await .expect("Failed to send request"); @@ -153,15 +129,13 @@ async fn test_revoke_token() { async fn test_revoked_token_cannot_be_used() { let app = spawn_app_with_auth().await; let client = Client::new(); + let auth_token = app.auth_token.as_ref().unwrap(); - // Create a token + // Create a new token let create_response = client .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "test_password", - "name": "test-token" - })) + .bearer_auth(auth_token) + .json(&json!({ "name": "will-be-revoked" })) .send() .await .expect("Failed to send request"); @@ -170,13 +144,13 @@ async fn test_revoked_token_cannot_be_used() { .json() .await .expect("Failed to parse response"); - let token = create_body.get("token").unwrap().as_str().unwrap(); + let new_token = create_body.get("token").unwrap().as_str().unwrap(); let token_id = create_body.get("id").unwrap().as_i64().unwrap(); - // Revoke the token + // Revoke it using the original auth token client .post(&app.api_url(&format!("/tokens/{}/revoke", token_id))) - .header("Authorization", format!("Bearer {}", token)) + .bearer_auth(auth_token) .send() .await .expect("Failed to send request"); @@ -184,7 +158,7 @@ async fn test_revoked_token_cannot_be_used() { // Try to use the revoked token let response = client .get(&app.api_url("/tokens")) - .header("Authorization", format!("Bearer {}", token)) + .header("Authorization", format!("Bearer {}", new_token)) .send() .await .expect("Failed to send request"); @@ -215,29 +189,12 @@ async fn test_protected_endpoints_require_authentication() { async fn test_protected_endpoints_work_with_authentication() { let app = spawn_app_with_auth().await; let client = Client::new(); - - // Create a token - let create_response = client - .post(&app.api_url("/tokens")) - .json(&json!({ - "username": "admin", - "password": "test_password", - "name": "test-token" - })) - .send() - .await - .expect("Failed to send request"); - - let create_body: serde_json::Value = create_response - .json() - .await - .expect("Failed to parse response"); - let token = create_body.get("token").unwrap().as_str().unwrap(); + let auth_token = app.auth_token.as_ref().unwrap(); // Create a roaster with authentication let response = client .post(&app.api_url("/roasters")) - .header("Authorization", format!("Bearer {}", token)) + .bearer_auth(auth_token) .json(&json!({ "name": "Test Roaster", "country": "UK" @@ -249,45 +206,6 @@ async fn test_protected_endpoints_work_with_authentication() { assert_eq!(response.status(), StatusCode::CREATED); } -#[tokio::test] -async fn test_session_authentication_via_login() { - let app = spawn_app_with_auth().await; - let client = reqwest::Client::builder() - .cookie_store(true) - .build() - .unwrap(); - - // Login to create a session - let login_response = client - .post(&format!("{}/login", app.address)) - .form(&[("username", "admin"), ("password", "test_password")]) - .send() - .await - .expect("Failed to send login request"); - - assert!( - login_response.status().is_redirection() || login_response.status().is_success(), - "Login should succeed" - ); - - // Use the session cookie to create a roaster (no Bearer token needed) - let response = client - .post(&app.api_url("/roasters")) - .json(&json!({ - "name": "Session Test Roaster", - "country": "US" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!( - response.status(), - StatusCode::CREATED, - "Session cookie should authenticate API request" - ); -} - #[tokio::test] async fn test_invalid_session_cookie_fails() { let app = spawn_app_with_auth().await; @@ -314,64 +232,6 @@ async fn test_invalid_session_cookie_fails() { ); } -#[tokio::test] -async fn test_logout_invalidates_session() { - let app = spawn_app_with_auth().await; - let client = reqwest::Client::builder() - .cookie_store(true) - .build() - .unwrap(); - - // Login to create a session - let login_response = client - .post(&format!("{}/login", app.address)) - .form(&[("username", "admin"), ("password", "test_password")]) - .send() - .await - .expect("Failed to send login request"); - - assert!(login_response.status().is_redirection() || login_response.status().is_success()); - - // Verify the session works - let auth_response = client - .post(&app.api_url("/roasters")) - .json(&json!({ - "name": "Pre-Logout Roaster", - "country": "FR" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!(auth_response.status(), StatusCode::CREATED); - - // Logout - let logout_response = client - .post(&format!("{}/logout", app.address)) - .send() - .await - .expect("Failed to send logout request"); - - assert!(logout_response.status().is_redirection() || logout_response.status().is_success()); - - // Try to use the session after logout - let post_logout_response = client - .post(&app.api_url("/roasters")) - .json(&json!({ - "name": "Post-Logout Roaster", - "country": "DE" - })) - .send() - .await - .expect("Failed to send request"); - - assert_eq!( - post_logout_response.status(), - StatusCode::UNAUTHORIZED, - "Session should be invalidated after logout" - ); -} - #[tokio::test] async fn test_fake_session_cookie_fails() { let app = spawn_app_with_auth().await; diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 22c8023..427b0ee 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -4,12 +4,11 @@ use brewlog::application::routes::app_router; use brewlog::application::server::AppState; use brewlog::domain::cafes::{Cafe, NewCafe}; use brewlog::domain::repositories::{ - CafeRepository, RoastRepository, RoasterRepository, SessionRepository, TimelineEventRepository, - TokenRepository, UserRepository, + CafeRepository, PasskeyCredentialRepository, RegistrationTokenRepository, RoastRepository, + RoasterRepository, SessionRepository, TimelineEventRepository, TokenRepository, UserRepository, }; use brewlog::domain::roasters::{NewRoaster, Roaster}; use brewlog::domain::users::NewUser; -use brewlog::infrastructure::auth::hash_password; use brewlog::infrastructure::backup::BackupService; use brewlog::infrastructure::database::Database; use brewlog::infrastructure::repositories::bags::SqlBagRepository; @@ -17,15 +16,19 @@ use brewlog::infrastructure::repositories::brews::SqlBrewRepository; use brewlog::infrastructure::repositories::cafes::SqlCafeRepository; use brewlog::infrastructure::repositories::cups::SqlCupRepository; use brewlog::infrastructure::repositories::gear::SqlGearRepository; +use brewlog::infrastructure::repositories::passkey_credentials::SqlPasskeyCredentialRepository; +use brewlog::infrastructure::repositories::registration_tokens::SqlRegistrationTokenRepository; use brewlog::infrastructure::repositories::roasters::SqlRoasterRepository; use brewlog::infrastructure::repositories::roasts::SqlRoastRepository; use brewlog::infrastructure::repositories::sessions::SqlSessionRepository; use brewlog::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; use brewlog::infrastructure::repositories::tokens::SqlTokenRepository; use brewlog::infrastructure::repositories::users::SqlUserRepository; +use brewlog::infrastructure::webauthn::ChallengeStore; use reqwest::Client; use tokio::net::TcpListener; use tokio::task::AbortHandle; +use webauthn_rs::prelude::*; pub struct TestApp { pub address: String, @@ -57,6 +60,19 @@ impl Drop for TestApp { } } +fn test_webauthn() -> Arc { + #[allow(clippy::expect_used)] + let rp_origin = url::Url::parse("http://localhost:0").expect("valid URL"); + #[allow(clippy::expect_used)] + Arc::new( + WebauthnBuilder::new("localhost", &rp_origin) + .expect("valid RP config") + .rp_name("Brewlog Test") + .build() + .expect("valid WebAuthn"), + ) +} + pub async fn spawn_app() -> TestApp { // Use in-memory SQLite database for testing let database = Database::connect("sqlite::memory:") @@ -84,6 +100,10 @@ pub async fn spawn_app() -> TestApp { Arc::new(SqlTokenRepository::new(database.clone_pool())); let session_repo: Arc = Arc::new(SqlSessionRepository::new(database.clone_pool())); + let passkey_repo: Arc = + Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); + let registration_token_repo: Arc = + Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); spawn_app_inner( database, @@ -98,6 +118,8 @@ pub async fn spawn_app() -> TestApp { user_repo, token_repo, session_repo, + passkey_repo, + registration_token_repo, brewlog::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), String::new(), None, @@ -119,6 +141,8 @@ async fn spawn_app_inner( user_repo: Arc, token_repo: Arc, session_repo: Arc, + passkey_repo: Arc, + registration_token_repo: Arc, foursquare_url: String, foursquare_api_key: String, mock_server: Option, @@ -126,25 +150,29 @@ async fn spawn_app_inner( let backup_service = Arc::new(BackupService::new(_database.clone_pool())); // Create application state - let state = AppState::new( - roaster_repo.clone(), - roast_repo.clone(), - bag_repo.clone(), - gear_repo.clone(), - brew_repo.clone(), - cafe_repo.clone(), - cup_repo.clone(), - timeline_repo.clone(), - user_repo.clone(), - token_repo.clone(), + let state = AppState { + roaster_repo: roaster_repo.clone(), + roast_repo: roast_repo.clone(), + bag_repo: bag_repo.clone(), + gear_repo: gear_repo.clone(), + brew_repo: brew_repo.clone(), + cafe_repo: cafe_repo.clone(), + cup_repo: cup_repo.clone(), + timeline_repo: timeline_repo.clone(), + user_repo: user_repo.clone(), + token_repo: token_repo.clone(), session_repo, - reqwest::Client::new(), + passkey_repo, + registration_token_repo, + webauthn: test_webauthn(), + challenge_store: Arc::new(ChallengeStore::new()), + http_client: reqwest::Client::new(), foursquare_url, foursquare_api_key, - String::new(), - "openrouter/free".to_string(), + openrouter_api_key: String::new(), + openrouter_model: "openrouter/free".to_string(), backup_service, - ); + }; // Create router let app = app_router(state); @@ -211,6 +239,10 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { Arc::new(SqlTokenRepository::new(database.clone_pool())); let session_repo: Arc = Arc::new(SqlSessionRepository::new(database.clone_pool())); + let passkey_repo: Arc = + Arc::new(SqlPasskeyCredentialRepository::new(database.clone_pool())); + let registration_token_repo: Arc = + Arc::new(SqlRegistrationTokenRepository::new(database.clone_pool())); let app = spawn_app_inner( database, @@ -225,6 +257,8 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { user_repo, token_repo, session_repo, + passkey_repo, + registration_token_repo, foursquare_url, "test-api-key".to_string(), Some(mock_server), @@ -235,9 +269,9 @@ pub async fn spawn_app_with_foursquare_mock() -> TestApp { } async fn add_auth_to_app(mut app: TestApp) -> TestApp { - // Create admin user with known password - let password_hash = hash_password("test_password").expect("Failed to hash password"); - let admin_user = NewUser::new("admin".to_string(), password_hash); + // Create user with UUID (no password) + let user_uuid = uuid::Uuid::new_v4().to_string(); + let admin_user = NewUser::new("admin".to_string(), user_uuid); let admin_user = app .user_repo @@ -247,7 +281,7 @@ async fn add_auth_to_app(mut app: TestApp) -> TestApp { .await .expect("Failed to create admin user"); - // Create a token for testing + // Create a token for testing via direct DB insert use brewlog::domain::tokens::NewToken; use brewlog::infrastructure::auth::{generate_token, hash_token};