Files
doctate/server/tests/close_watermark_test.rs
T
Brummel 1182f4817e Enforce admin-only for bulk and purge actions
Move admin checks from individual bulk actions to the main handler for
`bulk.rs` and `case_actions.rs`. This consolidates the authorization
logic for these sensitive operations.

Additionally, refine the HTML template to conditionally render bulk
action UI elements and the purge form only when the user is an admin.
This ensures that non-admin users do not see or have access to these
administrative functions.

Update tests to reflect these changes, ensuring that non-admin users are
correctly rejected for these actions and that the UI is properly hidden.
2026-04-21 19:48:24 +02:00

273 lines
8.8 KiB
Rust

//! Listen-relevante Mutationen (Close, Reopen, Bulk-Close) müssen den
//! pro-User Watermark bumpen. Sonst bleibt der ETag von
//! `/api/oneliners` stehen, jeder Poll liefert 304, und der Client
//! läuft dauerhaft mit einem Marker für einen Fall, den der Server
//! bereits geschlossen hat (ursprünglicher Bugreport: "Polymyalgia
//! rheumatica" blieb im Client sichtbar, obwohl im Web-UI bereits
//! gelöscht).
use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_common::constants::API_KEY_HEADER;
use doctate_common::oneliners::ONELINERS_PATH;
use doctate_server::config::{Config, User};
use tower::util::ServiceExt;
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(),
retention: Default::default(),
window_hours: 72,
preview_lines: 2,
}
}
fn make_admin(slug: &str, password_plain: &str) -> User {
let mut u = make_user(slug, password_plain);
u.role = "admin".into();
u
}
fn test_config_with_users(users: Vec<User>) -> Arc<Config> {
let data_path = std::env::temp_dir().join(format!(
"doctate-close-wm-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
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()
})
}
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
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<Body> {
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()
}
fn oneliners_request(api_key: &str, if_none_match: Option<&str>) -> Request<Body> {
let mut b = Request::builder()
.method("GET")
.uri(ONELINERS_PATH)
.header(API_KEY_HEADER, api_key);
if let Some(tag) = if_none_match {
b = b.header(header::IF_NONE_MATCH, tag);
}
b.body(Body::empty()).unwrap()
}
/// Create `<data_path>/<slug>/<case_id>/<ts>.m4a` so the case is
/// visible to `enumerate_recent_oneliners` (window defaults to 16h;
/// the fake mtime is "now" because write happens right now).
fn seed_case(data_path: &std::path::Path, slug: &str, case_id: &str) {
let case_dir = data_path.join(slug).join(case_id);
std::fs::create_dir_all(&case_dir).unwrap();
std::fs::write(case_dir.join("2026-04-19T10-00-00Z.m4a"), b"fake audio").unwrap();
}
async fn capture_etag(
app: &axum::Router,
api_key: &str,
if_none_match: Option<&str>,
) -> (StatusCode, Option<String>) {
let resp = app
.clone()
.oneshot(oneliners_request(api_key, if_none_match))
.await
.unwrap();
let status = resp.status();
let etag = resp
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
(status, etag)
}
#[tokio::test]
async fn close_bumps_watermark() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_id = "550e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_id);
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).expect("login should set cookie");
// First GET: capture baseline ETag (watermark empty → seeded on 0).
let (s1, etag_before) = capture_etag(&app, "key-dr_a", None).await;
assert_eq!(s1, StatusCode::OK);
let etag_before = etag_before.expect("server must set ETag");
// Close via the web handler.
let del = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/close"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
del.status().as_u16() / 100,
3,
"close should redirect (3xx)"
);
// Same If-None-Match now must NOT match — watermark was bumped.
let (s2, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
assert_eq!(
s2,
StatusCode::OK,
"watermark bump must invalidate cached ETag",
);
let etag_after = etag_after.expect("200 response must set ETag");
assert_ne!(
etag_after, etag_before,
"close handler must change the per-user ETag"
);
}
#[tokio::test]
async fn reopen_bumps_watermark() {
let config = test_config_with_users(vec![make_user("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_id = "660e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_id);
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).expect("login should set cookie");
// Close first so reopen has something to restore; grab the post-close ETag.
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/close"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let (_, etag_after_close) = capture_etag(&app, "key-dr_a", None).await;
let etag_after_close = etag_after_close.expect("ETag must be set");
// Reopen.
let reopen = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/web/cases/{case_id}/reopen"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(reopen.status().as_u16() / 100, 3, "reopen should redirect");
// Reopen must bump again → post-close ETag becomes stale.
let (s, etag_after_reopen) = capture_etag(&app, "key-dr_a", Some(&etag_after_close)).await;
assert_eq!(
s,
StatusCode::OK,
"reopen must change the ETag so clients re-fetch"
);
let etag_after_reopen = etag_after_reopen.expect("200 response must set ETag");
assert_ne!(etag_after_reopen, etag_after_close);
}
#[tokio::test]
async fn bulk_close_bumps_watermark() {
// Bulk actions are admin-only: UI hides them from non-admins and
// the handler rejects non-admin POSTs with 403.
let config = test_config_with_users(vec![make_admin("dr_a", "s")]);
let data_path = config.data_path.clone();
let case_a = "770e8400-e29b-41d4-a716-446655440000";
let case_b = "880e8400-e29b-41d4-a716-446655440000";
seed_case(&data_path, "dr_a", case_a);
seed_case(&data_path, "dr_a", case_b);
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).expect("login should set cookie");
let (_, etag_before) = capture_etag(&app, "key-dr_a", None).await;
let etag_before = etag_before.expect("ETag must be set");
// Form-encoded bulk close: action=close&case_id=a&case_id=b.
let form = format!("action=close&case_id={case_a}&case_id={case_b}");
let bulk = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/web/cases/bulk")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(form))
.unwrap(),
)
.await
.unwrap();
assert_eq!(bulk.status().as_u16() / 100, 3, "bulk should redirect");
let (s, etag_after) = capture_etag(&app, "key-dr_a", Some(&etag_before)).await;
assert_eq!(s, StatusCode::OK, "bulk-close must bump the ETag");
let etag_after = etag_after.expect("200 response must set ETag");
assert_ne!(etag_after, etag_before);
}