From 92c491b742bf22b8e4b6e29ed3d8531b380dd520 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 28 Oct 2025 02:27:10 +0100 Subject: [PATCH] auth tests --- backend/src/routes/auth.rs | 10 +- backend/src/routes/folders.rs | 5 +- backend/tests/auth_flow.rs | 271 +++++++++++++++++++++++++++++++++- backend/tests/common/mod.rs | 15 +- 4 files changed, 291 insertions(+), 10 deletions(-) diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 1f04662..679a6b0 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -80,9 +80,15 @@ pub async fn login( ) -> AppResult { let mut conn = state.db_unscoped()?; - let user: User = dsl::users + let user: Option = dsl::users .filter(dsl::username.eq(&payload.username)) - .first(&mut conn)?; + .first(&mut conn) + .optional()?; + + let user = match user { + Some(user) => user, + None => return Err(AppError::unauthorized()), + }; let valid = password::verify_password(&payload.password, &user.password_hash) .map_err(|_| AppError::unauthorized())?; diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 6ee3225..52d05ba 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -15,12 +15,11 @@ use crate::{ error::{AppError, AppResult}, }; +use super::documents::{to_document_response, DocumentResponse}; use crate::documents::{ - asset::load_primary_assets, - correspondents::load_correspondents_for_documents, + asset::load_primary_assets, correspondents::load_correspondents_for_documents, tags::load_tags_for_documents, }; -use super::documents::{to_document_response, DocumentResponse}; use crate::utils::{ json::{classify_nullable, NullableValue}, time::to_iso, diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 973a763..bd11bbd 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -1,15 +1,49 @@ mod common; -use anyhow::Result; -use axum::http::StatusCode; +use anyhow::{anyhow, Context, Result}; +use axum::body::Body; +use axum::http::{header::SET_COOKIE, StatusCode}; +use backend::models::NewUserMembership; +use backend::schema::{tenants, user_memberships}; use common::{acquire_db_lock, body_to_vec, TestApp}; +use diesel::prelude::*; use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; #[derive(Deserialize)] struct AuthenticatedUser { username: String, } +#[derive(Deserialize)] +struct ErrorResponse { + error: String, +} + +#[derive(Deserialize)] +struct LoginTenant { + slug: String, +} + +#[derive(Deserialize)] +struct LoginResponse { + access_token: String, + tenant: LoginTenant, +} + +#[derive(Deserialize)] +struct TenantSelectionResponse { + access_token: String, + tenants: Vec, +} + +#[derive(Deserialize)] +struct TenantSummary { + tenant_id: Uuid, + slug: String, +} + #[tokio::test] async fn login_and_me_roundtrip() -> Result<()> { let _lock = acquire_db_lock().await; @@ -18,9 +52,9 @@ async fn login_and_me_roundtrip() -> Result<()> { let password = "s3cret"; app.insert_user("alice", password, "admin").await?; - let token = app.login_token("alice", password).await?; + let (login, _) = login_with_session(&app, "alice", password).await?; - let response = app.get("/api/auth/me", Some(&token)).await?; + let response = app.get("/api/auth/me", Some(&login.access_token)).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_vec(response.into_body()).await?; let user: AuthenticatedUser = serde_json::from_slice(&body)?; @@ -30,3 +64,232 @@ async fn login_and_me_roundtrip() -> Result<()> { app.cleanup().await?; Ok(()) } + +#[tokio::test] +async fn login_rejects_unknown_user() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = json!({ "username": "ghost", "password": "nope" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = body_to_vec(response.into_body()).await?; + let err: ErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "unauthorized"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_rejects_invalid_password() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "valid"; + app.insert_user("robin", password, "admin").await?; + + let payload = json!({ "username": "robin", "password": "wrong" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = body_to_vec(response.into_body()).await?; + let err: ErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "unauthorized"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn refresh_rotates_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "rotate"; + app.insert_user("rita", password, "admin").await?; + + let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?; + + let response = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let new_cookie = extract_refresh_cookie(response.headers())?; + let body = body_to_vec(response.into_body()).await?; + let refreshed: LoginResponse = serde_json::from_slice(&body)?; + assert_eq!(refreshed.tenant.slug, login.tenant.slug); + + let me_response = app + .get("/api/auth/me", Some(&refreshed.access_token)) + .await?; + assert_eq!(me_response.status(), StatusCode::OK); + + let retry = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(retry.status(), StatusCode::UNAUTHORIZED); + + // new cookie should differ from old to avoid reuse + assert_ne!(new_cookie, refresh_cookie); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn logout_revokes_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "logout"; + app.insert_user("logan", password, "admin").await?; + + let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?; + + let response = app + .post_json_with_cookie( + "/api/auth/logout", + &json!({}), + Some(&login.access_token), + Some(&refresh_cookie), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let cleared_cookie = extract_refresh_cookie(response.headers())?; + assert!(cleared_cookie.ends_with("=")); + + let after_logout = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn me_requires_authentication() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let unauthenticated = app.get("/api/auth/me", None).await?; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let invalid = app.get("/api/auth/me", Some("invalid")).await?; + assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "multipass"; + let user_id = app.insert_user("multipass", password, "admin").await?; + + let secondary_slug = "secondary".to_string(); + let slug_for_insert = secondary_slug.clone(); + let secondary_id = Uuid::new_v4(); + app.with_conn(move |conn| { + diesel::insert_into(tenants::table) + .values(( + tenants::id.eq(secondary_id), + tenants::slug.eq(&slug_for_insert), + )) + .execute(conn)?; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id, + tenant_id: secondary_id, + role: "admin".to_string(), + }; + + diesel::insert_into(user_memberships::table) + .values(&membership) + .execute(conn)?; + Ok(()) + }) + .await?; + + let payload = json!({ "username": "multipass", "password": password }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; + assert!(selection.tenants.len() >= 2); + let secondary = selection + .tenants + .iter() + .find(|tenant| tenant.slug == secondary_slug) + .map(|t| t.tenant_id) + .context("secondary tenant missing from selection")?; + + let select_response = app + .post_json( + "/api/auth/select-tenant", + &json!({ "tenant_id": secondary }), + Some(&selection.access_token), + ) + .await?; + assert_eq!(select_response.status(), StatusCode::OK); + let session_cookie = extract_refresh_cookie(select_response.headers())?; + let select_body = body_to_vec(select_response.into_body()).await?; + let login: LoginResponse = serde_json::from_slice(&select_body)?; + assert_eq!(login.tenant.slug, secondary_slug); + + let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?; + assert_eq!(me_response.status(), StatusCode::OK); + + let refresh_response = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie)) + .await?; + assert_eq!(refresh_response.status(), StatusCode::OK); + + app.cleanup().await?; + Ok(()) +} + +async fn login_with_session( + app: &TestApp, + username: &str, + password: &str, +) -> Result<(LoginResponse, String)> { + let payload = json!({ "username": username, "password": password }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + ensure_status(&response, StatusCode::OK)?; + let refresh_cookie = extract_refresh_cookie(response.headers())?; + let body = body_to_vec(response.into_body()).await?; + let login: LoginResponse = serde_json::from_slice(&body) + .map_err(|_| anyhow!("expected login response with session"))?; + Ok((login, refresh_cookie)) +} + +fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { + let header_value = headers + .get(SET_COOKIE) + .context("missing set-cookie header")? + .to_str() + .context("invalid set-cookie header")?; + let cookie = header_value + .split(';') + .next() + .context("set-cookie missing cookie value")? + .to_string(); + Ok(cookie) +} + +fn ensure_status(response: &hyper::Response, expected: StatusCode) -> Result<()> { + if response.status() == expected { + Ok(()) + } else { + Err(anyhow!( + "unexpected status: got {}, expected {}", + response.status(), + expected + )) + } +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index b9b7cbf..a1eaddf 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -6,7 +6,7 @@ use std::time::Duration; use anyhow::{anyhow, ensure, Context, Result}; use async_trait::async_trait; use axum::body::Body; -use axum::http::{Method, Request, StatusCode}; +use axum::http::{header, Method, Request, StatusCode}; use axum::Router; use backend::auth::jwt::JwtService; use backend::config::AppConfig; @@ -396,6 +396,16 @@ impl TestApp { path: &str, payload: &T, token: Option<&str>, + ) -> Result> { + self.post_json_with_cookie(path, payload, token, None).await + } + + pub async fn post_json_with_cookie( + &self, + path: &str, + payload: &T, + token: Option<&str>, + cookie: Option<&str>, ) -> Result> { let body = serde_json::to_vec(payload)?; let mut builder = Request::builder() @@ -405,6 +415,9 @@ impl TestApp { if let Some(token) = token { builder = builder.header("authorization", format!("Bearer {token}")); } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } let request = builder.body(Body::from(body))?; Ok(self .router