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.
This commit is contained in:
Jon Seager 2026-02-13 13:06:27 +00:00
parent 09bd7e61e4
commit 590868e51f
No known key found for this signature in database
6 changed files with 437 additions and 4 deletions

View file

@ -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<serde_json::Value> = 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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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)
}

View file

@ -19,3 +19,4 @@ pub mod scan_api;
pub mod stats_api;
pub mod test_macros;
pub mod timeline;
pub mod webauthn_api;

View file

@ -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}"
);
}