Files
buzz/crates/buzz-test-client/tests/e2e_thread_roots.rs
Tom Brow a61239f0d8 fix(mobile): avoid opening empty threads on message tap (#7756)
Tapping a mobile channel message with no replies currently opens an
empty thread. Only enable message-row thread navigation when the
existing thread summary reports replies, including replies known to the
relay but not loaded locally. Long-press → Reply still starts a new
thread.

Added widget tests for zero replies, loaded replies, relay-only replies,
and starting a thread through Reply. The zero-reply test failed before
the fix and passes afterward.

Validation: all 227 channel-page tests, the full mobile pre-push checks,
and all PR CI checks passed. The full repository gate failed in
unchanged Rust ACP timing tests (idle reset, keepalive, and steering
deadlines); this PR changes only mobile Dart files.

No duplicate open PR or issue found.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-21 08:51:05 -07:00

149 lines
5.2 KiB
Rust

//! Tombstone-aware ownership recovery through the authenticated Nostr bridge.
//! Run against a local development relay with `--ignored`.
use nostr::{Event, EventBuilder, Keys, Kind, Tag};
use serde_json::{json, Value};
fn url() -> String {
std::env::var("RELAY_URL")
.unwrap_or_else(|_| "ws://localhost:3000".into())
.replace("ws://", "http://")
.replace("wss://", "https://")
}
async fn post(keys: &Keys, event: &Event) {
let response = reqwest::Client::new()
.post(format!("{}/events", url()))
.header("X-Pubkey", keys.public_key().to_hex())
.json(event)
.send()
.await
.expect("event request");
assert!(
response.status().is_success(),
"event status {}",
response.status()
);
let body: Value = response.json().await.expect("event response");
assert_eq!(body["accepted"], true, "event rejected: {body}");
}
async fn query(keys: &Keys, filter: Value) -> Vec<Event> {
let response = reqwest::Client::new()
.post(format!("{}/query", url()))
.header("X-Pubkey", keys.public_key().to_hex())
.json(&vec![filter])
.send()
.await
.expect("query request");
assert!(
response.status().is_success(),
"query status {}",
response.status()
);
response.json().await.expect("signed event array")
}
async fn channel(keys: &Keys, private: bool) -> String {
let id = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags([
Tag::parse(["h", &id]).unwrap(),
Tag::parse(["name", &format!("thread-roots-{id}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", if private { "private" } else { "open" }]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
post(keys, &event).await;
id
}
fn request(channel: &str, target: &Event) -> Value {
json!({"kinds": [39005], "ids": [target.id.to_hex()], "#h": [channel], "resolve_thread_roots": true})
}
#[tokio::test]
#[ignore]
async fn deleted_reply_roots_are_recovered_without_content_or_scope_leaks() {
let owner = Keys::generate();
let outsider = Keys::generate();
let other_channel = channel(&owner, false).await;
let bounded_filter = json!({
"kinds": [39005], "#h": [&other_channel], "resolve_thread_roots": true,
"ids": (0..60).map(|id| format!("{id:064x}")).collect::<Vec<_>>()
});
let oversized = reqwest::Client::new()
.post(format!("{}/query", url()))
.header("X-Pubkey", owner.public_key().to_hex())
.json(&vec![bounded_filter.clone(), bounded_filter])
.send()
.await
.expect("bounded batch");
assert_eq!(
oversized.status(),
reqwest::StatusCode::BAD_REQUEST,
"the target cap applies across filters, not just within each filter"
);
for deletion_kind in [5, 9005] {
let channel_id = channel(&owner, true).await;
let root = EventBuilder::new(Kind::Custom(9), "root")
.tags([Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &root).await;
let reply = EventBuilder::new(Kind::Custom(9), "deleted secret payload")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["e", &root.id.to_hex(), "", "reply"]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &reply).await;
let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["e", &reply.id.to_hex()]).unwrap(),
])
.sign_with_keys(&owner)
.unwrap();
post(&owner, &deletion).await;
let ordinary = query(
&owner,
json!({"kinds":[9],"ids":[reply.id.to_hex()],"#h":[channel_id]}),
)
.await;
assert!(
ordinary.is_empty(),
"ordinary history must exclude the deleted payload"
);
let summaries = query(&owner, request(&channel_id, &reply)).await;
assert_eq!(summaries.len(), 1, "metadata must survive deletion");
let summary = &summaries[0];
summary.verify().expect("relay signature");
assert_eq!(summary.kind, Kind::Custom(39005));
assert!(summary
.tags
.iter()
.any(|tag| tag.as_slice() == ["e", &root.id.to_hex()]));
let counts: Value = serde_json::from_str(&summary.content).unwrap();
assert_eq!(counts["reply_count"], 0);
assert_eq!(counts["descendant_count"], 0);
assert!(!summary.content.contains("deleted secret payload"));
assert!(
query(&owner, request(&other_channel, &reply))
.await
.is_empty(),
"wrong channel must not leak ownership"
);
assert!(
query(&outsider, request(&channel_id, &reply))
.await
.is_empty(),
"private channel membership is required"
);
}
}