diff --git a/crates/rest/src/handlers/user_settings.rs b/crates/rest/src/handlers/user_settings.rs index 4eb8392b7..76c74292a 100644 --- a/crates/rest/src/handlers/user_settings.rs +++ b/crates/rest/src/handlers/user_settings.rs @@ -326,22 +326,28 @@ fn parse_if_match(conditional: &ConditionalHeaders) -> RestResult RestResult<()> { - let current = (version > 0).then(|| version.to_string()); - if precondition.if_match_satisfied(current.as_deref()) { + let satisfied = match precondition { + EntityTagPrecondition::Tags(_) => { + precondition.if_match_satisfied(Some(&version.to_string())) + } + _ => precondition.if_match_satisfied((version > 0).then(|| version.to_string()).as_deref()), + }; + if satisfied { return Ok(()); } - let message = match current { - Some(current) => { - format!("If-Match precondition failed: current settings version is {current}") - } - None => "If-Match precondition failed: no settings document exists yet".to_string(), + let message = if version > 0 { + format!("If-Match precondition failed: current settings version is {version}") + } else { + "If-Match precondition failed: no settings document exists yet".to_string() }; Err(RestError::PreconditionFailed { message }) } diff --git a/crates/rest/tests/user_settings.rs b/crates/rest/tests/user_settings.rs index 68d1f90f2..93f7b3604 100644 --- a/crates/rest/tests/user_settings.rs +++ b/crates/rest/tests/user_settings.rs @@ -119,6 +119,45 @@ async fn patch_null_deletes_a_key() { assert_eq!(get.json::(), json!({"theme": "dark"})); } +/// #442: `GET` serves the empty document as a real representation (`{}` with +/// `ETag: "0"`), so the validator it hands out must round-trip — a fresh +/// user's read-then-conditional-write deadlocked on 412 forever otherwise. +#[tokio::test] +async fn if_match_zero_writes_the_first_version() { + let server = create_test_server(); + + let fresh = server.get("/_user/settings").await; + assert_eq!(fresh.header("etag"), HeaderValue::from_static("\"0\"")); + + let created = server + .patch("/_user/settings") + .add_header(IF_MATCH, HeaderValue::from_static("\"0\"")) + .json(&json!({"recentSearches": [{"query": "/Patient?name=x"}]})) + .await; + created.assert_status_ok(); + assert_eq!(created.header("etag"), HeaderValue::from_static("\"1\"")); + + // And once a version exists, "0" is genuinely stale again. + let stale = server + .patch("/_user/settings") + .add_header(IF_MATCH, HeaderValue::from_static("\"0\"")) + .json(&json!({"a": 1})) + .await; + assert_eq!(stale.status_code(), StatusCode::PRECONDITION_FAILED); +} + +/// `If-Match: *` still requires an actually stored document. +#[tokio::test] +async fn if_match_star_still_requires_an_existing_document() { + let server = create_test_server(); + let conflict = server + .patch("/_user/settings") + .add_header(IF_MATCH, HeaderValue::from_static("*")) + .json(&json!({"a": 1})) + .await; + assert_eq!(conflict.status_code(), StatusCode::PRECONDITION_FAILED); +} + #[tokio::test] async fn stale_if_match_is_rejected_with_412() { let server = create_test_server();