From 590868e51fba85749755523b6dbc973e343ed43c Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Fri, 13 Feb 2026 13:06:27 +0000 Subject: [PATCH] test: add integration tests for webauthn, passkeys, and input validation Add webauthn API tests (register, auth, passkey add flows), passkey endpoint auth tests, bag validation (zero/negative amount, invalid date), brew validation (zero weight, negative grind, zero water, temp > 100), and fix flaky date assertion in bag close test. --- tests/server/auth_api.rs | 65 +++++++++++++- tests/server/bags_api.rs | 81 +++++++++++++++++- tests/server/brews_api.rs | 132 +++++++++++++++++++++++++++++ tests/server/helpers.rs | 4 + tests/server/main.rs | 1 + tests/server/webauthn_api.rs | 158 +++++++++++++++++++++++++++++++++++ 6 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 tests/server/webauthn_api.rs diff --git a/tests/server/auth_api.rs b/tests/server/auth_api.rs index 83ebbbe..8042d1c 100644 --- a/tests/server/auth_api.rs +++ b/tests/server/auth_api.rs @@ -1,7 +1,7 @@ use reqwest::{Client, StatusCode}; use serde_json::json; -use crate::helpers::spawn_app_with_auth; +use crate::helpers::{spawn_app, spawn_app_with_auth}; #[tokio::test] async fn test_create_token_requires_authentication() { @@ -273,3 +273,66 @@ async fn test_read_endpoints_dont_require_authentication() { assert_eq!(response.status(), StatusCode::OK); } + +// --- Admin passkey endpoint tests --- + +#[tokio::test] +async fn list_passkeys_requires_auth() { + let app = spawn_app().await; + let client = Client::new(); + + let response = client + .get(&app.api_url("/passkeys")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn list_passkeys_returns_empty_for_user_without_passkeys() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let response = client + .get(&app.api_url("/passkeys")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::OK); + + let passkeys: Vec = response.json().await.expect("Failed to parse response"); + assert!(passkeys.is_empty(), "expected empty passkeys list"); +} + +#[tokio::test] +async fn delete_passkey_nonexistent_returns_404() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let response = client + .delete(&app.api_url("/passkeys/99999")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_passkey_requires_auth() { + let app = spawn_app().await; + let client = Client::new(); + + let response = client + .delete(&app.api_url("/passkeys/1")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/server/bags_api.rs b/tests/server/bags_api.rs index dd11705..ee4055e 100644 --- a/tests/server/bags_api.rs +++ b/tests/server/bags_api.rs @@ -2,9 +2,16 @@ use crate::helpers::{ create_default_bag, create_default_gear, create_default_roast, create_default_roaster, spawn_app, spawn_app_with_auth, }; +use crate::test_macros::define_crud_tests; use brewlog::domain::bags::{Bag, BagWithRoast, NewBag, UpdateBag}; use chrono::NaiveDate; +define_crud_tests!( + entity: bag, + path: "/bags", + list_type: BagWithRoast +); + #[tokio::test] async fn creating_a_bag_returns_a_201_for_valid_data() { // Arrange @@ -298,9 +305,13 @@ async fn closing_a_bag_automatically_sets_finished_at() { let updated_bag: Bag = response.json().await.expect("Failed to parse response"); assert!(updated_bag.closed); assert!(updated_bag.finished_at.is_some()); - assert_eq!( - updated_bag.finished_at.unwrap(), - chrono::Utc::now().date_naive() + // Accept today or yesterday to avoid midnight-boundary flakiness + let today = chrono::Utc::now().date_naive(); + let yesterday = today - chrono::Duration::days(1); + let finished = updated_bag.finished_at.unwrap(); + assert!( + finished == today || finished == yesterday, + "expected finished_at to be today or yesterday, got {finished}" ); } @@ -358,3 +369,67 @@ async fn updating_bag_amount_recomputes_remaining() { assert_eq!(updated_bag.amount, 500.0); assert_eq!(updated_bag.remaining, 485.0); } + +#[tokio::test] +async fn creating_bag_with_zero_amount_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/bags")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&serde_json::json!({ + "roast_id": roast.id, + "amount": 0.0 + })) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn creating_bag_with_negative_amount_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/bags")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&serde_json::json!({ + "roast_id": roast.id, + "amount": -100.0 + })) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn creating_bag_with_invalid_date_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/bags")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&serde_json::json!({ + "roast_id": roast.id, + "roast_date": "not-a-date", + "amount": 250.0 + })) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} diff --git a/tests/server/brews_api.rs b/tests/server/brews_api.rs index a808c26..16fd6ed 100644 --- a/tests/server/brews_api.rs +++ b/tests/server/brews_api.rs @@ -643,3 +643,135 @@ async fn updating_a_nonexistent_brew_returns_404() { assert_eq!(response.status(), 404); } + +#[tokio::test] +async fn creating_brew_with_zero_coffee_weight_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let bag = create_default_bag(&app, roast.id).await; + let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await; + let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&NewBrew { + bag_id: bag.id, + coffee_weight: 0.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn creating_brew_with_negative_grind_setting_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let bag = create_default_bag(&app, roast.id).await; + let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await; + let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: -1.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn creating_brew_with_zero_water_volume_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let bag = create_default_bag(&app, roast.id).await; + let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await; + let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 0, + water_temp: 92.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn creating_brew_with_water_temp_over_100_returns_400() { + let app = spawn_app_with_auth().await; + let roaster = create_default_roaster(&app).await; + let roast = create_default_roast(&app, roaster.id).await; + let bag = create_default_bag(&app, roast.id).await; + let grinder = create_default_gear(&app, "grinder", "Comandante", "C40 MK4").await; + let brewer = create_default_gear(&app, "brewer", "Hario", "V60 02").await; + let client = reqwest::Client::new(); + + let response = client + .post(app.api_url("/brews")) + .bearer_auth(app.auth_token.as_ref().unwrap()) + .json(&NewBrew { + bag_id: bag.id, + coffee_weight: 15.0, + grinder_id: grinder.id, + grind_setting: 24.0, + brewer_id: brewer.id, + filter_paper_id: None, + water_volume: 250, + water_temp: 101.0, + quick_notes: Vec::new(), + brew_time: None, + created_at: None, + }) + .send() + .await + .expect("Failed to execute request"); + + assert_eq!(response.status(), 400); +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 6fa5477..bbc0595 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -40,6 +40,10 @@ impl TestApp { format!("{}/api/v1{}", self.address, path) } + pub fn webauthn_url(&self, path: &str) -> String { + format!("{}/api/v1/webauthn{}", self.address, path) + } + pub fn page_url(&self, path: &str) -> String { format!("{}{}", self.address, path) } diff --git a/tests/server/main.rs b/tests/server/main.rs index fa0224f..838403b 100644 --- a/tests/server/main.rs +++ b/tests/server/main.rs @@ -19,3 +19,4 @@ pub mod scan_api; pub mod stats_api; pub mod test_macros; pub mod timeline; +pub mod webauthn_api; diff --git a/tests/server/webauthn_api.rs b/tests/server/webauthn_api.rs new file mode 100644 index 0000000..53d0967 --- /dev/null +++ b/tests/server/webauthn_api.rs @@ -0,0 +1,158 @@ +use reqwest::{Client, StatusCode}; +use serde_json::json; + +use crate::helpers::spawn_app_with_auth; + +#[tokio::test] +async fn register_start_with_missing_token_returns_400() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // POST without a token field — Axum returns 422 for missing fields + let response = client + .post(app.webauthn_url("/register/start")) + .json(&json!({ "display_name": "Alice" })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn register_start_with_invalid_token_returns_401_or_gone() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + let response = client + .post(app.webauthn_url("/register/start")) + .json(&json!({ + "token": "totally-invalid-token", + "display_name": "Alice" + })) + .send() + .await + .expect("Failed to send request"); + + // Expired/missing token → 401 (token lookup fails) or 410 (token expired) + let status = response.status().as_u16(); + assert!( + status == 401 || status == 410, + "expected 401 or 410, got {status}" + ); +} + +#[tokio::test] +async fn register_finish_with_invalid_challenge_returns_400() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // `credential: {}` is invalid for RegisterPublicKeyCredential, so Axum returns 422 + let response = client + .post(app.webauthn_url("/register/finish")) + .json(&json!({ + "challenge_id": "nonexistent-challenge-id", + "passkey_name": "my-key", + "credential": {} + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn auth_start_with_no_passkeys_returns_404() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // No passkeys have been registered, so auth start should fail. + // auth/start is a GET endpoint. + let response = client + .get(app.webauthn_url("/auth/start")) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn auth_finish_with_invalid_challenge_returns_400() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // `credential: {}` is invalid for PublicKeyCredential, so Axum returns 422 + let response = client + .post(app.webauthn_url("/auth/finish")) + .json(&json!({ + "challenge_id": "nonexistent-challenge-id", + "credential": {} + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn passkey_add_start_requires_auth() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // No auth header → 401 + let response = client + .post(app.webauthn_url("/passkey/start")) + .json(&json!({ "name": "my-passkey" })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn passkey_add_finish_requires_auth() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // No auth header → 401 + let response = client + .post(app.webauthn_url("/passkey/finish")) + .json(&json!({ + "challenge_id": "nonexistent-challenge-id", + "name": "my-passkey", + "credential": {} + })) + .send() + .await + .expect("Failed to send request"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn auth_start_with_non_localhost_cli_callback_returns_400_or_404() { + let app = spawn_app_with_auth().await; + let client = Client::new(); + + // auth/start is GET. Without passkeys, we get 404 before the callback validation. + // With passkeys, a non-localhost callback would return 400. + // We verify the endpoint handles these params without crashing. + let response = client + .get(&format!( + "{}/auth/start?cli_callback=http://evil.com/steal&state=abc&token_name=test", + app.webauthn_url("") + )) + .send() + .await + .expect("Failed to send request"); + + let status = response.status().as_u16(); + assert!( + status == 400 || status == 404, + "expected 400 or 404, got {status}" + ); +}