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:
2026-04-21 16:48:01 +02:00
parent c8186c9f88
commit 3218fc62bd
24 changed files with 167 additions and 132 deletions
+1
View File
@@ -32,6 +32,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -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()
+1
View File
@@ -24,6 +24,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -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,
}
}
+1
View File
@@ -25,6 +25,7 @@ fn make_user() -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+57 -70
View File
@@ -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");
+1
View File
@@ -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,
}
}
+1
View File
@@ -38,6 +38,7 @@ fn make_user(slug: &str) -> User {
role: "doctor".into(),
whisper: Default::default(),
retention: Default::default(),
window_hours: 72,
}
}
+1
View File
@@ -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);
+1
View File
@@ -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()
+1
View File
@@ -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()