mod common; 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; let app = TestApp::new().await?; let password = "s3cret"; app.insert_user("alice", password, "admin").await?; let (login, _) = login_with_session(&app, "alice", password).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)?; assert_eq!(user.username, "alice"); 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 )) } }