feat: server-authoritative case window via users.toml
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.
This commit is contained in:
@@ -22,6 +22,9 @@ pub struct AuthenticatedUser {
|
||||
pub slug: String,
|
||||
pub role: String,
|
||||
pub data_dir: PathBuf,
|
||||
/// Copied from the matching entry in `users.toml` — the single
|
||||
/// source of truth for how many hours of history clients see.
|
||||
pub window_hours: u32,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
@@ -61,6 +64,7 @@ where
|
||||
slug: slug.clone(),
|
||||
role: user.role.clone(),
|
||||
data_dir,
|
||||
window_hours: user.window_hours,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -71,6 +75,9 @@ pub struct AuthenticatedWebUser {
|
||||
pub slug: String,
|
||||
pub role: String,
|
||||
pub data_dir: PathBuf,
|
||||
/// Mirrors [`AuthenticatedUser::window_hours`] so the same policy
|
||||
/// is available to web handlers without a second config lookup.
|
||||
pub window_hours: u32,
|
||||
}
|
||||
|
||||
impl AuthenticatedWebUser {
|
||||
@@ -124,10 +131,21 @@ where
|
||||
.ok_or_else(|| AppError::Redirect("/web/login".into()))?;
|
||||
|
||||
let data_dir = config.data_path.join(&session.slug);
|
||||
// Look up the TOML user record for the policy fields the session
|
||||
// does not carry. A missing record can only happen if users.toml
|
||||
// was edited while a session was still live; fall back to the
|
||||
// serde default so the request stays functional.
|
||||
let window_hours = config
|
||||
.users
|
||||
.iter()
|
||||
.find(|u| u.slug == session.slug)
|
||||
.map(|u| u.window_hours)
|
||||
.unwrap_or(72);
|
||||
Ok(Self {
|
||||
slug: session.slug.clone(),
|
||||
role: session.role.clone(),
|
||||
data_dir,
|
||||
window_hours,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,13 @@ pub struct RetentionUserSettings {
|
||||
pub auto_delete_days: u32,
|
||||
}
|
||||
|
||||
/// Default time window (hours) of visible cases when the user entry
|
||||
/// omits `window_hours`. Chosen so Monday morning still shows Friday's
|
||||
/// unfinished work. Server-authoritative: clients never override this.
|
||||
fn default_window_hours() -> u32 {
|
||||
72
|
||||
}
|
||||
|
||||
/// A single user entry from users.toml.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct User {
|
||||
@@ -37,6 +44,13 @@ pub struct User {
|
||||
pub whisper: WhisperUserSettings,
|
||||
#[serde(default)]
|
||||
pub retention: RetentionUserSettings,
|
||||
/// Time window (hours) that defines which cases are visible to this
|
||||
/// user's clients. Server-authoritative: the `/api/oneliners` scan
|
||||
/// filters recordings older than `now - window_hours` before they
|
||||
/// reach any client. Clients render what the server returns and
|
||||
/// apply no additional time filter.
|
||||
#[serde(default = "default_window_hours")]
|
||||
pub window_hours: u32,
|
||||
}
|
||||
|
||||
/// Wrapper for deserializing the [[user]] array from TOML.
|
||||
@@ -275,6 +289,7 @@ mod tests {
|
||||
role: "doctor".into(),
|
||||
whisper: WhisperUserSettings::default(),
|
||||
retention: RetentionUserSettings::default(),
|
||||
window_hours: default_window_hours(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,4 +417,33 @@ mod tests {
|
||||
let err = validate_and_index_users(&users).unwrap_err();
|
||||
assert!(err.contains("duplicate slug"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_without_window_hours_defaults_to_72() {
|
||||
let toml_str = r#"
|
||||
[[user]]
|
||||
slug = "a"
|
||||
api_key = "k1"
|
||||
web_password = ""
|
||||
role = "doctor"
|
||||
"#;
|
||||
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
||||
// Missing field falls back to the serde default so existing
|
||||
// users.toml files continue to work unchanged.
|
||||
assert_eq!(parsed.user[0].window_hours, 72);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_with_explicit_window_hours() {
|
||||
let toml_str = r#"
|
||||
[[user]]
|
||||
slug = "a"
|
||||
api_key = "k1"
|
||||
web_password = ""
|
||||
role = "doctor"
|
||||
window_hours = 168
|
||||
"#;
|
||||
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(parsed.user[0].window_hours, 168);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
//! GET /api/oneliners — list oneliners for the authenticated user.
|
||||
//!
|
||||
//! Time window is caller-controlled via `?hours=N` (default 16, clamped
|
||||
//! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner
|
||||
//! state (or null if no state file has been written yet), and UTC
|
||||
//! timestamps for when the case started and when its oneliner was last
|
||||
//! written.
|
||||
//! The time window is **server-authoritative**: it is read from the
|
||||
//! user's `users.toml` entry (`window_hours`) and surfaced via the
|
||||
//! [`AuthenticatedUser`] extractor. Clients no longer control it —
|
||||
//! any `?hours=N` on the request URL is ignored.
|
||||
//!
|
||||
//! Each entry carries the case-id, the oneliner state (or null if no
|
||||
//! state file has been written yet), and UTC timestamps for when the
|
||||
//! case started and when its oneliner was last written.
|
||||
//!
|
||||
//! Conditional-GET: the response carries a weak ETag
|
||||
//! `W/"<fingerprint_hex>-<hours>"`. The fingerprint is a deterministic
|
||||
@@ -15,18 +18,17 @@
|
||||
//! shifts at least one tuple and the hash moves. Server restart is a
|
||||
//! no-op: same FS, same hash.
|
||||
//!
|
||||
//! The `hours` component is included so a client swapping window sizes
|
||||
//! does not get a stale 304.
|
||||
//! The `hours` component of the ETag stays so that an admin editing
|
||||
//! `window_hours` in `users.toml` (and restarting the server)
|
||||
//! invalidates existing client caches automatically.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::Query;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
|
||||
use serde::Deserialize;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::warn;
|
||||
@@ -35,20 +37,11 @@ use crate::auth::AuthenticatedUser;
|
||||
use crate::error::AppError;
|
||||
use crate::paths;
|
||||
|
||||
const DEFAULT_HOURS: u32 = 16;
|
||||
const MAX_HOURS: u32 = 168;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OnelinersQuery {
|
||||
hours: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn handle_oneliners(
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<OnelinersQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS);
|
||||
let hours = user.window_hours;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let since = now - Duration::hours(hours as i64);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ fn make_user(slug: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ fn test_config() -> Arc<Config> {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -24,6 +24,7 @@ fn make_user(slug: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ fn make_user(slug: &str, password_plain: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ fn make_user() -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ fn test_config() -> (Arc<Config>, PathBuf) {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}],
|
||||
api_keys: HashMap::from([(TEST_KEY.into(), TEST_SLUG.into())]),
|
||||
..Config::test_default()
|
||||
@@ -36,6 +37,16 @@ fn test_config() -> (Arc<Config>, PathBuf) {
|
||||
(config, data_path)
|
||||
}
|
||||
|
||||
fn test_config_with_window_hours(hours: u32) -> (Arc<Config>, PathBuf) {
|
||||
let (cfg, dp) = test_config();
|
||||
// Safe because `test_config` just created the Arc and handed it
|
||||
// back with refcount == 1.
|
||||
let mut cfg =
|
||||
Arc::try_unwrap(cfg).unwrap_or_else(|_| unreachable!("test_config Arc should be unique"));
|
||||
cfg.users[0].window_hours = hours;
|
||||
(Arc::new(cfg), dp)
|
||||
}
|
||||
|
||||
async fn seed_case(
|
||||
user_root: &Path,
|
||||
case_id: &str,
|
||||
@@ -145,10 +156,10 @@ async fn empty_user_dir_returns_200_empty_list() {
|
||||
|
||||
let etag = header_str(&response, "etag");
|
||||
assert!(etag.starts_with("W/\""), "etag was {etag:?}");
|
||||
assert!(etag.ends_with("-16\""), "etag was {etag:?}");
|
||||
assert!(etag.ends_with("-72\""), "etag was {etag:?}");
|
||||
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["window_hours"], 16);
|
||||
assert_eq!(body["window_hours"], 72);
|
||||
assert!(body["oneliners"].as_array().unwrap().is_empty());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
@@ -207,7 +218,7 @@ async fn case_older_than_default_window_excluded() {
|
||||
seed_case(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000003",
|
||||
Duration::from_secs(20 * 3600), // 20 h old
|
||||
Duration::from_secs(80 * 3600), // 80 h old — outside the 72 h default
|
||||
Some("old case"),
|
||||
)
|
||||
.await;
|
||||
@@ -219,48 +230,41 @@ async fn case_older_than_default_window_excluded() {
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
/// Demonstrates that the visibility decision lives in users.toml:
|
||||
/// the same 40 h-old case is hidden under window_hours = 16 and
|
||||
/// visible under window_hours = 48.
|
||||
#[tokio::test]
|
||||
async fn case_outside_default_but_inside_custom_window_included() {
|
||||
let (config, dp) = test_config();
|
||||
seed_case(
|
||||
&dp,
|
||||
"550e8400-e29b-41d4-a716-000000000004",
|
||||
Duration::from_secs(20 * 3600),
|
||||
Some("yesterday case"),
|
||||
)
|
||||
.await;
|
||||
let app = doctate_server::create_router(config);
|
||||
async fn case_visibility_follows_user_window_hours() {
|
||||
let case_id = "550e8400-e29b-41d4-a716-000000000004";
|
||||
let age = Duration::from_secs(40 * 3600);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await;
|
||||
let (config_small, dp_small) = test_config_with_window_hours(16);
|
||||
seed_case(&dp_small, case_id, age, Some("40h case")).await;
|
||||
let app_small = doctate_server::create_router(config_small);
|
||||
let body = body_json(app_small.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
assert_eq!(body["window_hours"], 16);
|
||||
assert!(body["oneliners"].as_array().unwrap().is_empty());
|
||||
|
||||
let (config_wide, dp_wide) = test_config_with_window_hours(48);
|
||||
seed_case(&dp_wide, case_id, age, Some("40h case")).await;
|
||||
let app_wide = doctate_server::create_router(config_wide);
|
||||
let body = body_json(app_wide.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
assert_eq!(body["window_hours"], 48);
|
||||
assert_eq!(body["oneliners"].as_array().unwrap().len(), 1);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
let _ = std::fs::remove_dir_all(&dp_small);
|
||||
let _ = std::fs::remove_dir_all(&dp_wide);
|
||||
}
|
||||
|
||||
/// Regression guard: a lingering `?hours=N` from an old client is
|
||||
/// silently ignored — the user-config value drives the response.
|
||||
#[tokio::test]
|
||||
async fn hours_clamped_to_max() {
|
||||
async fn hours_query_param_is_ignored() {
|
||||
let (config, dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(
|
||||
app.oneshot(get("/api/oneliners?hours=999999"))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(body["window_hours"], 168);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hours_zero_clamped_to_one() {
|
||||
let (config, dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let body = body_json(app.oneshot(get("/api/oneliners?hours=0")).await.unwrap()).await;
|
||||
assert_eq!(body["window_hours"], 1);
|
||||
let body = body_json(app.oneshot(get("/api/oneliners?hours=999")).await.unwrap()).await;
|
||||
assert_eq!(body["window_hours"], 72);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
@@ -293,7 +297,7 @@ async fn stale_if_none_match_returns_200() {
|
||||
let response = app
|
||||
.oneshot(get_with_if_none_match(
|
||||
"/api/oneliners",
|
||||
"W/\"99999999-16\"",
|
||||
"W/\"99999999-72\"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -302,45 +306,28 @@ async fn stale_if_none_match_returns_200() {
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
|
||||
/// Two users with different `window_hours` must produce different
|
||||
/// ETag suffixes, even on empty data — so an admin's edit to
|
||||
/// users.toml invalidates client caches after the next restart.
|
||||
#[tokio::test]
|
||||
async fn etag_differs_between_different_hours() {
|
||||
let (config, dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
async fn etag_differs_between_users_with_different_window_hours() {
|
||||
let (config_a, dp_a) = test_config_with_window_hours(16);
|
||||
let (config_b, dp_b) = test_config_with_window_hours(48);
|
||||
|
||||
let r1 = app
|
||||
.clone()
|
||||
.oneshot(get("/api/oneliners?hours=1"))
|
||||
.await
|
||||
.unwrap();
|
||||
let r16 = app.oneshot(get("/api/oneliners?hours=16")).await.unwrap();
|
||||
let app_a = doctate_server::create_router(config_a);
|
||||
let app_b = doctate_server::create_router(config_b);
|
||||
|
||||
let etag1 = header_str(&r1, "etag");
|
||||
let etag16 = header_str(&r16, "etag");
|
||||
assert_ne!(etag1, etag16);
|
||||
let r_a = app_a.oneshot(get("/api/oneliners")).await.unwrap();
|
||||
let r_b = app_b.oneshot(get("/api/oneliners")).await.unwrap();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
}
|
||||
let etag_a = header_str(&r_a, "etag");
|
||||
let etag_b = header_str(&r_b, "etag");
|
||||
assert_ne!(etag_a, etag_b);
|
||||
assert!(etag_a.ends_with("-16\""), "etag_a was {etag_a:?}");
|
||||
assert!(etag_b.ends_with("-48\""), "etag_b was {etag_b:?}");
|
||||
|
||||
#[tokio::test]
|
||||
async fn etag_for_one_hour_does_not_match_sixteen_hour_request() {
|
||||
let (config, dp) = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let r1 = app
|
||||
.clone()
|
||||
.oneshot(get("/api/oneliners?hours=1"))
|
||||
.await
|
||||
.unwrap();
|
||||
let etag1 = header_str(&r1, "etag");
|
||||
|
||||
// Send the hours=1 ETag against an hours=16 request — must NOT 304.
|
||||
let r16 = app
|
||||
.oneshot(get_with_if_none_match("/api/oneliners?hours=16", &etag1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r16.status(), StatusCode::OK);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dp);
|
||||
let _ = std::fs::remove_dir_all(&dp_a);
|
||||
let _ = std::fs::remove_dir_all(&dp_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -430,7 +417,7 @@ async fn sorts_by_last_recording_not_created() {
|
||||
.await;
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let body = body_json(app.oneshot(get("/api/oneliners?hours=48")).await.unwrap()).await;
|
||||
let body = body_json(app.oneshot(get("/api/oneliners")).await.unwrap()).await;
|
||||
let arr = body["oneliners"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["case_id"], case_a, "A must sort first");
|
||||
|
||||
@@ -35,6 +35,7 @@ fn user_with_retention(slug: &str, auto_close_days: u32, auto_delete_days: u32)
|
||||
auto_close_days,
|
||||
auto_delete_days,
|
||||
},
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ fn make_user(slug: &str) -> User {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}],
|
||||
whisper_url,
|
||||
whisper_timeout_seconds: 5,
|
||||
|
||||
@@ -75,6 +75,7 @@ async fn transient_whisper_failure_is_reenqueued_and_recovers() {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}];
|
||||
let config = Arc::new(cfg);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ fn test_config() -> Arc<Config> {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
@@ -22,6 +22,7 @@ fn test_config() -> Arc<Config> {
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
|
||||
Reference in New Issue
Block a user