3218fc62bd
The /api/oneliners time window is now read per-user from users.toml (window_hours, default 72h). Clients no longer carry a window: client.toml oneliner_window_hours, SyncConfig.window_hours, the ?hours=N query param, and the render_case_list cutoff filter are gone. ETag suffix keeps the effective hours so an admin edit to users.toml invalidates client caches on the next request. OnelinersResponse.window_hours stays in the wire format, but now exists solely to anchor client reconciliation.
260 lines
7.6 KiB
Rust
260 lines
7.6 KiB
Rust
use std::collections::HashMap;
|
|
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::config::{Config, User};
|
|
use doctate_server::magic_link::{MagicLinkStore, PendingMagicLink};
|
|
use doctate_server::{
|
|
AnalyzeBusy, AppState, OnelinerHealBusy, TranscribeBusy, analyze, transcribe, web_session,
|
|
};
|
|
|
|
const API_KEY: &str = "key-dr_a";
|
|
|
|
fn make_user() -> User {
|
|
User {
|
|
slug: "dr_a".into(),
|
|
api_key: API_KEY.into(),
|
|
web_password: bcrypt::hash("unused", 4).unwrap(),
|
|
role: "doctor".into(),
|
|
whisper: Default::default(),
|
|
retention: Default::default(),
|
|
window_hours: 72,
|
|
}
|
|
}
|
|
|
|
fn build_state() -> (AppState, Arc<Config>) {
|
|
let user = make_user();
|
|
let data_path = std::env::temp_dir().join(format!(
|
|
"doctate-magic-test-{}-{}",
|
|
std::process::id(),
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
let mut api_keys: HashMap<String, String> = HashMap::new();
|
|
api_keys.insert(user.api_key.clone(), user.slug.clone());
|
|
|
|
let config = Arc::new(Config {
|
|
data_path,
|
|
users: vec![user],
|
|
api_keys,
|
|
..Config::test_default()
|
|
});
|
|
|
|
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
|
|
let (analyze_tx, _analyze_rx) = analyze::channel();
|
|
let magic_link_store = doctate_server::magic_link::new_store();
|
|
|
|
let state = AppState {
|
|
config: config.clone(),
|
|
transcribe_tx,
|
|
analyze_tx,
|
|
session_store: web_session::new_store(),
|
|
magic_link_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))),
|
|
events_tx: doctate_server::events::channel(),
|
|
http_client: reqwest::Client::new(),
|
|
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
|
};
|
|
|
|
(state, config)
|
|
}
|
|
|
|
fn create_request(body: &str, api_key: Option<&str>) -> Request<Body> {
|
|
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<Body> {
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(format!("/web/magic?token={token}"))
|
|
.body(Body::empty())
|
|
.unwrap()
|
|
}
|
|
|
|
async fn body_json(response: axum::response::Response) -> serde_json::Value {
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_without_api_key_returns_401() {
|
|
let (state, _) = build_state();
|
|
let app = doctate_server::create_router_with_state(state);
|
|
|
|
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 (state, _) = build_state();
|
|
let app = doctate_server::create_router_with_state(state);
|
|
|
|
let resp = app
|
|
.oneshot(create_request(
|
|
r#"{"return_to":"/web/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 (state, _) = build_state();
|
|
let app = doctate_server::create_router_with_state(state);
|
|
|
|
// Step 1: issue token via API.
|
|
let create_resp = app
|
|
.clone()
|
|
.oneshot(create_request(
|
|
r#"{"return_to":"/web/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(),
|
|
"/web/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 (state, _) = build_state();
|
|
let app = doctate_server::create_router_with_state(state);
|
|
|
|
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(),
|
|
"/web/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(),
|
|
"/web/login"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_rejects_open_redirect_return_to() {
|
|
let (state, _) = build_state();
|
|
let app = doctate_server::create_router_with_state(state);
|
|
|
|
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();
|
|
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: "/web/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(), "/web/login");
|
|
|
|
// And the token must be removed from the store, even though it expired.
|
|
assert!(store.read().await.get(&token).is_none());
|
|
}
|