mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(relay): admit delayed immutable Desktop profiles
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user