mod common; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::{Duration, Instant}; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use serde_json::json; use tower::util::ServiceExt; use doctate_common::API_KEY_HEADER; use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink}; use doctate_server::{ AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session, }; use common::{TestConfig, body_json, test_user}; const API_KEY: &str = "key-dr_a"; fn create_request(body: &str, api_key: Option<&str>) -> Request { let mut b = Request::builder() .method("POST") .uri("/api/auth/magic-link") .header(header::CONTENT_TYPE, "application/json"); if let Some(k) = api_key { b = b.header(API_KEY_HEADER, k); } b.body(Body::from(body.to_owned())).unwrap() } fn consume_request(token: &str) -> Request { Request::builder() .method("GET") .uri(common::paths::magic(token)) .body(Body::empty()) .unwrap() } /// Most tests only need a router. Only the expiry test keeps a handle /// to the `MagicLinkStore` so it can insert a pre-expired token. fn test_app() -> axum::Router { let cfg = TestConfig::new() .with_label("magic") .with_user(test_user("dr_a")) .build(); doctate_server::create_router(cfg) } /// For the expiry test: build the full `AppState` so we retain a /// reference to `magic_link_store` after the router is constructed. fn build_state_with_stores() -> AppState { let cfg = TestConfig::new() .with_label("magic") .with_user(test_user("dr_a")) .build(); let (transcribe_tx, _transcribe_rx) = transcribe::channel(); let (analyze_tx, _analyze_rx) = analyze::channel(); AppState { config: cfg, settings: Arc::new(doctate_server::settings::Settings::default()), transcribe_tx, analyze_tx, session_store: web_session::new_store(), magic_link_store: doctate_server::magic_link::new_store(), analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))), oneliner_locks: doctate_server::oneliner_locks::OnelinerLocks::new(), events_tx: doctate_server::events::channel(), http_client: reqwest::Client::new(), vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()), boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH), analyze_in_flight: doctate_server::analyze::InFlight::new(), } } #[tokio::test] async fn create_without_api_key_returns_401() { let app = test_app(); let resp = app.oneshot(create_request("{}", None)).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn create_with_valid_key_returns_token() { let app = test_app(); let resp = app .oneshot(create_request( r#"{"return_to":"/cases/abc"}"#, Some(API_KEY), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; let token = body["token"].as_str().expect("token field"); assert!(token.len() >= 30, "token looks short: {token}"); } #[tokio::test] async fn consume_valid_token_sets_cookie_and_redirects() { let app = test_app(); // Step 1: issue token via API. let create_resp = app .clone() .oneshot(create_request( r#"{"return_to":"/cases/abc"}"#, Some(API_KEY), )) .await .unwrap(); let token = body_json(create_resp).await["token"] .as_str() .unwrap() .to_owned(); // Step 2: consume. let resp = app.oneshot(consume_request(&token)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers() .get(header::LOCATION) .unwrap() .to_str() .unwrap(), "/cases/abc" ); assert_eq!( resp.headers() .get(header::REFERRER_POLICY) .unwrap() .to_str() .unwrap(), "no-referrer" ); // Cookie must be set with HttpOnly and SameSite=Strict. let cookie = resp .headers() .get(header::SET_COOKIE) .expect("session cookie missing") .to_str() .unwrap(); assert!(cookie.starts_with("session=")); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Strict")); } #[tokio::test] async fn consume_token_is_one_time_use() { let app = test_app(); let token = body_json( app.clone() .oneshot(create_request("{}", Some(API_KEY))) .await .unwrap(), ) .await["token"] .as_str() .unwrap() .to_owned(); let first = app.clone().oneshot(consume_request(&token)).await.unwrap(); assert_eq!(first.status(), StatusCode::SEE_OTHER); assert_eq!( first.headers().get(header::LOCATION).unwrap(), "/cases" // default ); let second = app.oneshot(consume_request(&token)).await.unwrap(); // Second use: token gone, redirect to login. assert_eq!(second.status(), StatusCode::SEE_OTHER); assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/login"); } #[tokio::test] async fn create_rejects_open_redirect_return_to() { let app = test_app(); for evil in [ "https://evil.com/", "//evil.com/", "/api/upload", "javascript:alert(1)", ] { let body = json!({ "return_to": evil }).to_string(); let resp = app .clone() .oneshot(create_request(&body, Some(API_KEY))) .await .unwrap(); assert_eq!( resp.status(), StatusCode::BAD_REQUEST, "should reject return_to={evil}" ); } } #[tokio::test] async fn consume_expired_token_redirects_to_login() { let state = build_state_with_stores(); let store: MagicLinkStore = state.magic_link_store.clone(); // Insert a token that expired 1s ago — bypassing the API to control time. let token = "test-token-expired".to_owned(); { let mut w = store.write().await; w.insert( token.clone(), PendingMagicLink { slug: "dr_a".into(), role: "doctor".into(), return_to: "/cases".into(), expires_at: Instant::now() - Duration::from_secs(1), }, ); } let app = doctate_server::create_router_with_state(state); let resp = app.oneshot(consume_request(&token)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "/login"); // And the token must be removed from the store, even though it expired. assert!(store.read().await.get(&token).is_none()); }