fix(relay): admit delayed immutable Desktop profiles

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-04 11:26:03 -04:00
parent 60dacc53a7
commit ff5b04ff10
2 changed files with 137 additions and 3 deletions

View File

@@ -165,3 +165,94 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
}
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn aged_desktop_profile_retries_through_production_ingest_without_resigning() {
let mut state = bridge_handler_test_state()
.await
.expect("test infrastructure");
Arc::make_mut(&mut Arc::get_mut(&mut state).unwrap().config).require_auth_token = true;
let host = format!("desktop-retry-{}.example", uuid::Uuid::new_v4().simple());
state.db.ensure_configured_community(&host).await.unwrap();
let owner = Keys::generate();
let outsider = Keys::generate();
let profile = buzz_core::desktop_profile::DesktopProfile::new(
format!("wss://{host}"),
uuid::Uuid::new_v4().simple().to_string(),
)
.unwrap();
let prepared = profile.sign(&owner).unwrap();
// Model bytes committed during yesterday's offline first launch. Neither
// the first submission nor its duplicate is re-dated or re-signed below.
let now = Timestamp::now().as_secs();
let aged = EventBuilder::new(prepared.kind, &prepared.content)
.tags(prepared.tags.iter().cloned())
.custom_created_at(Timestamp::from(now - 86_400))
.sign_with_keys(&owner)
.unwrap();
let raw = json!(aged);
for _ in 0..2 {
let (status, result) = post(&state, &host, "/events", &owner, raw.clone(), true).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["accepted"], true, "{result}");
let (status, rows) = post(
&state,
&host,
"/query",
&owner,
json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "ids":[aged.id.to_hex()]}]),
true,
)
.await;
assert_eq!(status, StatusCode::OK, "{rows}");
assert_eq!(rows.as_array().unwrap().len(), 1);
for field in [
"id",
"pubkey",
"kind",
"created_at",
"tags",
"content",
"sig",
] {
assert_eq!(rows[0][field], raw[field], "stored {field} changed");
}
let stored: nostr::Event = serde_json::from_value(rows[0].clone()).unwrap();
assert_eq!(
buzz_core::desktop_profile::DesktopProfile::read(
&stored,
&owner,
&format!("wss://{host}")
)
.unwrap(),
profile
);
}
// The age exception grants no signer authority and bypasses no envelope or
// signature checks. These calls use the real HTTP -> shared ingest path.
let (status, result) = post(&state, &host, "/events", &outsider, raw.clone(), true).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{result}");
let mut corrupt = raw.clone();
corrupt["content"] = json!(format!("{}x", aged.content));
let (status, result) = post(&state, &host, "/events", &owner, corrupt, true).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
let invalid = EventBuilder::new(aged.kind, &aged.content)
.tag(Tag::identifier("invalid-coordinate"))
.custom_created_at(aged.created_at)
.sign_with_keys(&owner)
.unwrap();
let future = EventBuilder::new(aged.kind, &aged.content)
.tags(aged.tags.iter().cloned())
.custom_created_at(Timestamp::from(now + 86_400))
.sign_with_keys(&owner)
.unwrap();
let ordinary = EventBuilder::text_note("old ordinary event")
.custom_created_at(aged.created_at)
.sign_with_keys(&owner)
.unwrap();
for rejected in [invalid, future, ordinary] {
let (status, result) = post(&state, &host, "/events", &owner, json!(rejected), true).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{result}");
}
}

View File

@@ -2169,6 +2169,17 @@ pub async fn ingest_event(
result
}
// Profiles are durable display records, not freshness signals. A Desktop may
// first publish its immutable signed record long after an offline startup.
// Only their past-age bound is waived; future drift and all other admission
// checks still apply. Observation/presence kinds must retain their own window.
fn timestamp_within_ingest_window(kind: u32, event_ts: u64, now: u64) -> bool {
const MAX_TIMESTAMP_DRIFT_SECS: u64 = 900;
event_ts <= now.saturating_add(MAX_TIMESTAMP_DRIFT_SECS)
&& (kind == KIND_DESKTOP_PROFILE
|| now.saturating_sub(event_ts) <= MAX_TIMESTAMP_DRIFT_SECS)
}
async fn ingest_event_inner(
state: &Arc<AppState>,
tracer: &Arc<dyn buzz_conformance::Tracer>,
@@ -2233,10 +2244,8 @@ async fn ingest_event_inner(
}
let event = std::sync::Arc::try_unwrap(event).unwrap_or_else(|arc| (*arc).clone());
const MAX_TIMESTAMP_DRIFT_SECS: i64 = 900; // ±15 minutes
let now = chrono::Utc::now().timestamp();
let event_ts = event.created_at.as_secs() as i64;
if (event_ts - now).abs() > MAX_TIMESTAMP_DRIFT_SECS {
if !timestamp_within_ingest_window(kind_u32, event.created_at.as_secs(), now as u64) {
return Err(IngestError::Rejected(
"invalid: event timestamp too far from server time".into(),
));
@@ -3317,6 +3326,40 @@ mod postgres_tests {
));
}
#[test]
fn immutable_profile_age_exception_is_past_only_and_kind_specific() {
let now = 1_800_000_000;
// Include the next observation kind explicitly: freshness is not profile age.
for kind in [
KIND_DESKTOP_PROFILE,
30181,
KIND_PROFILE,
KIND_EVENT_REMINDER,
1,
] {
for (timestamp, ordinary, profile) in [
(0, false, true),
(now - 86_400, false, true),
(now - 901, false, true),
(now - 900, true, true),
(now, true, true),
(now + 900, true, true),
(now + 901, false, false),
(u64::MAX, false, false),
] {
assert_eq!(
timestamp_within_ingest_window(kind, timestamp, now),
if kind == KIND_DESKTOP_PROFILE {
profile
} else {
ordinary
},
"kind={kind} timestamp={timestamp}"
);
}
}
}
#[test]
fn huddle_backing_channel_lookup_outage_is_internal() {
let error = sqlx::Error::Io(std::io::Error::other("database unavailable"));