use std::collections::HashMap; use std::sync::Arc; use axum::body::Body; use axum::http::{header, Request, StatusCode}; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; fn make_user(slug: &str, password_plain: &str) -> User { User { slug: slug.into(), api_key: format!("key-{slug}"), web_password: bcrypt::hash(password_plain, 4).unwrap(), role: "doctor".into(), whisper: Default::default(), } } fn test_config_with_users(users: Vec) -> Arc { let data_path = std::env::temp_dir().join(format!( "doctate-login-test-{}-{}", std::process::id(), uuid::Uuid::new_v4() )); let api_keys: HashMap = users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect(); Arc::new(Config { server_port: 3000, data_path, log_level: "info".into(), log_path: "/tmp/doctate-test/logs".into(), log_max_days: 90, users, api_keys, retention_audio_days: 30, retention_transcript_days: 30, retention_document_days: 0, whisper_url: "http://localhost:10300".into(), whisper_timeout_seconds: 120, ollama_url: "http://localhost:11434".into(), ollama_model: "gemma3:4b".into(), ollama_keep_alive: 0, llm_url: String::new(), llm_api_key: String::new(), llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, cookie_secure: false, }) } async fn body_to_string(response: axum::response::Response) -> String { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } /// Extract the session cookie (name=value) from a Set-Cookie response. fn extract_session_cookie(resp: &axum::response::Response) -> Option { for v in resp.headers().get_all(header::SET_COOKIE).iter() { let s = v.to_str().ok()?; if let Some(pair) = s.split(';').next() && pair.starts_with("session=") { return Some(pair.to_string()); } } None } fn login_request(slug: &str, password: &str) -> Request { let body = format!("slug={slug}&password={password}"); Request::builder() .method("POST") .uri("/web/login") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body)) .unwrap() } #[tokio::test] async fn login_success_sets_session_cookie_and_redirects() { let config = test_config_with_users(vec![make_user("dr_a", "secret")]); let app = doctate_server::create_router(config); let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16(), "got {:?}", resp.status()); let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(); assert_eq!(loc, "/web/cases"); let cookie = extract_session_cookie(&resp).expect("no session cookie"); assert!(cookie.starts_with("session=")); assert!(cookie.len() > "session=".len() + 20, "token looks too short: {cookie}"); } #[tokio::test] async fn login_wrong_password_shows_error() { let config = test_config_with_users(vec![make_user("dr_a", "secret")]); let app = doctate_server::create_router(config); let resp = app.oneshot(login_request("dr_a", "wrong")).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(extract_session_cookie(&resp).is_none()); let body = body_to_string(resp).await; assert!(body.contains("Login fehlgeschlagen"), "got: {body}"); } #[tokio::test] async fn login_unknown_user_shows_error() { let config = test_config_with_users(vec![make_user("dr_a", "secret")]); let app = doctate_server::create_router(config); let resp = app.oneshot(login_request("ghost", "anything")).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(extract_session_cookie(&resp).is_none()); } #[tokio::test] async fn cases_without_cookie_redirects_to_login() { let config = test_config_with_users(vec![make_user("dr_a", "secret")]); let app = doctate_server::create_router(config); let resp = app .oneshot(Request::builder().uri("/web/cases").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); assert_eq!( resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), "/web/login" ); } #[tokio::test] async fn cases_with_valid_cookie_shows_only_own_cases() { let config = test_config_with_users(vec![ make_user("dr_a", "s"), make_user("dr_b", "s"), ]); // Seed fixtures: dr_a has an open case, dr_b has one too. let case_a = config.data_path.join("dr_a/open/11111111-1111-1111-1111-111111111111"); let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_a).unwrap(); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); let app = doctate_server::create_router(config); // Log in as dr_a. let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let resp = app .oneshot( Request::builder() .uri("/web/cases") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_to_string(resp).await; assert!(body.contains("11111111"), "own case missing: {body}"); assert!(!body.contains("22222222"), "foreign case leaked: {body}"); } #[tokio::test] async fn case_detail_of_foreign_case_returns_404() { let config = test_config_with_users(vec![ make_user("dr_a", "s"), make_user("dr_b", "s"), ]); let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); std::fs::create_dir_all(&case_b).unwrap(); std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); let app = doctate_server::create_router(config); let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let resp = app .oneshot( Request::builder() .uri("/web/cases/22222222-2222-2222-2222-222222222222") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn logout_clears_session() { let config = test_config_with_users(vec![make_user("dr_a", "s")]); let app = doctate_server::create_router(config); let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); let cookie = extract_session_cookie(&login).unwrap(); let logout = app .clone() .oneshot( Request::builder() .method("POST") .uri("/web/logout") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(logout.status(), StatusCode::SEE_OTHER.as_u16()); // Same cookie must no longer be accepted. let resp = app .oneshot( Request::builder() .uri("/web/cases") .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FOUND); }