Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions crates/rest/src/handlers/user_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,22 +326,28 @@ fn parse_if_match(conditional: &ConditionalHeaders) -> RestResult<EntityTagPreco
}

/// Checks a parsed precondition against the currently stored `version`, where
/// version `0` means "no document exists yet".
/// version `0` means "no document has been written yet".
///
/// Version `0` is mapped to "no current representation" so the shared evaluator
/// gives the semantics this endpoint already had: `*` requires an existing
/// document, and a concrete tag never matches a document that does not exist.
/// The GET handler serves that state as a *real representation* — `{}` with
/// `ETag: "0"` — so the validator it hands out must round-trip: a concrete
/// `If-Match: "0"` matches version 0, or a fresh user's read-then-conditional-
/// write could never succeed (#442). `If-Match: *` keeps requiring an actually
/// stored document, per its "unless it does not exist" semantics.
fn check_if_match(precondition: &EntityTagPrecondition, version: i64) -> 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 })
}
Expand Down
39 changes: 39 additions & 0 deletions crates/rest/tests/user_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,45 @@ async fn patch_null_deletes_a_key() {
assert_eq!(get.json::<Value>(), 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();
Expand Down
Loading