Files
doctate/server/tests/sse_integration.rs
T
Brummel e5a62d8f10 feat: add per-user retention settings to users.toml
New [user.retention] TOML sub-block with auto_close_days and
auto_delete_days. Both default to 0, which disables the respective
sweep — a conservative default that preserves current behaviour for
existing deployments and lets admins test auto-close in isolation.

Only parsing + the test User literals are touched here; the actual
lazy sweep consuming these values lands in the next commit.
2026-04-21 10:48:24 +02:00

138 lines
4.0 KiB
Rust

//! Integration tests for the `/web/events` SSE route.
//!
//! We test two things end-to-end:
//! 1. Without a session cookie, the route redirects to the login page
//! (the web-auth extractor's standard rejection).
//! 2. With a valid session, the route returns a streaming response with
//! `Content-Type: text/event-stream`.
//!
//! Actual event-filtering logic (per-user scoping, admin sees-all) is
//! covered at the unit level in `src/events.rs`; wiring the broadcast
//! channel through an HTTP test would require reading a never-ending
//! body, which these tests deliberately avoid.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use tower::util::ServiceExt;
use doctate_server::config::{Config, User};
fn unique_data_path() -> PathBuf {
std::env::temp_dir().join(format!(
"doctate-sse-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
fn make_user(slug: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
// Password "s" — matches the login helper below.
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
}
}
fn build_config(data_path: PathBuf) -> Arc<Config> {
let users = vec![make_user("alice")];
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
api_keys,
..Config::test_default()
})
}
/// Log in via POST /web/login and return the resulting `session=…` cookie
/// header value. Mirrors the helper in the other integration tests.
async fn login(app: axum::Router, slug: &str) -> String {
let body = format!("slug={slug}&password=s");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
let s = v.to_str().unwrap();
if let Some(pair) = s.split(';').next()
&& pair.starts_with("session=")
{
return pair.to_string();
}
}
panic!("no session cookie after login");
}
#[tokio::test]
async fn events_without_session_redirects_to_login() {
let config = build_config(unique_data_path());
let app = doctate_server::create_router(config);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/web/events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get(header::LOCATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(location, "/web/login");
}
#[tokio::test]
async fn events_with_session_returns_sse_content_type() {
let config = build_config(unique_data_path());
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "alice").await;
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/web/events")
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.starts_with("text/event-stream"),
"expected text/event-stream, got {ct:?}"
);
}