mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Goal Add `started_at` to read-state accounts from the day the table is created. This prevents users seeing ALL their channels as unread when they first install a client update that starts using these APIs --------- Signed-off-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz> Signed-off-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Signed-off-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz> Co-authored-by: hMB2 <0400eb0da32afbbf16cdd1dee6ffd243587e8f3d6a2823fe7dfa95d16b002b7d@buzz.block.builderlab.xyz> Co-authored-by: hMurderbot <219c58afff8ac2c76e4e527a329aa61c2ebbb896786b1916d4f9cca5140ecd04@buzz.block.builderlab.xyz> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
25 KiB
Rust
684 lines
25 KiB
Rust
use axum::{
|
|
body::{to_bytes, Body},
|
|
http::{Request, StatusCode},
|
|
};
|
|
use base64::Engine;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::sync::Arc;
|
|
use tower::ServiceExt;
|
|
|
|
fn proof(key: &Keys, host: &str, path: &str, method: &str, body: Option<&[u8]>) -> String {
|
|
let mut tags = vec![
|
|
Tag::parse(["u", &format!("https://{host}{path}")]).unwrap(),
|
|
Tag::parse(["method", method]).unwrap(),
|
|
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
|
|
];
|
|
if let Some(body) = body {
|
|
tags.push(Tag::parse(["payload", &hex::encode(Sha256::digest(body))]).unwrap());
|
|
}
|
|
let event = EventBuilder::new(Kind::Custom(27235), "")
|
|
.tags(tags)
|
|
.sign_with_keys(key)
|
|
.unwrap();
|
|
format!(
|
|
"Nostr {}",
|
|
base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap())
|
|
)
|
|
}
|
|
|
|
async fn request(
|
|
state: Arc<crate::state::AppState>,
|
|
host: &str,
|
|
path: &str,
|
|
method: &str,
|
|
auth: Option<&str>,
|
|
body: &[u8],
|
|
) -> (StatusCode, Value) {
|
|
let mut req = Request::builder()
|
|
.method(method)
|
|
.uri(path)
|
|
.header("host", host);
|
|
if let Some(auth) = auth {
|
|
req = req.header("authorization", auth);
|
|
}
|
|
let response = crate::router::build_router(state)
|
|
.oneshot(req.body(Body::from(body.to_vec())).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
assert_eq!(
|
|
response.headers().get("cache-control").unwrap(),
|
|
"private, no-store"
|
|
);
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
|
(status, serde_json::from_slice(&bytes).unwrap())
|
|
}
|
|
|
|
/// Start a reader's account before any fixture arrival. Nothing counts as
|
|
/// unread until an account starts.
|
|
async fn start_before_everything(
|
|
state: &crate::state::AppState,
|
|
community: buzz_core::CommunityId,
|
|
key: &Keys,
|
|
) {
|
|
sqlx::query(
|
|
"INSERT INTO personal_read_accounts (community_id,actor,started_at)
|
|
VALUES ($1,$2,'1900-01-01T00:00:00Z')",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(key.public_key().to_bytes().as_slice())
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_router_signed_url_body_replay_and_actor_boundary() {
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
let mut state = (*fixture).clone();
|
|
let config = Arc::make_mut(&mut state.config);
|
|
config.require_auth_token = true;
|
|
config.require_relay_membership = true;
|
|
config.buzz_v1_enabled = true;
|
|
state.nip98_replay = Arc::new(buzz_pubsub::RedisNip98ReplayGuard::new(
|
|
state.redis_pool.clone(),
|
|
));
|
|
let state = Arc::new(state);
|
|
let host = format!("bff-{}.local", uuid::Uuid::new_v4());
|
|
let community = state
|
|
.db
|
|
.ensure_configured_community(&host)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let actor = Keys::generate();
|
|
let other = Keys::generate();
|
|
let path = "/buzz/v1/me/sidebar?limit=1";
|
|
let auth = proof(&actor, &host, path, "GET", None);
|
|
assert_eq!(
|
|
request(state.clone(), &host, path, "GET", Some(&auth), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
for key in [&actor, &other] {
|
|
state
|
|
.db
|
|
.add_relay_member(community, &key.public_key().to_hex(), "member", None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
assert_eq!(
|
|
request(state.clone(), &host, path, "GET", None, b"")
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let wrong_query = proof(&actor, &host, "/buzz/v1/me/sidebar?limit=2", "GET", None);
|
|
assert_eq!(
|
|
request(state.clone(), &host, path, "GET", Some(&wrong_query), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let wrong_host = proof(&actor, "other.invalid", path, "GET", None);
|
|
assert_eq!(
|
|
request(state.clone(), &host, path, "GET", Some(&wrong_host), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let auth = proof(&actor, &host, path, "GET", None);
|
|
let accepted = request(state.clone(), &host, path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(accepted.0, StatusCode::OK, "{}", accepted.1);
|
|
assert_eq!(
|
|
request(state.clone(), &host, path, "GET", Some(&auth), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
// Targeted refresh is exclusive with paging and bounded to unique IDs.
|
|
let id = uuid::Uuid::new_v4();
|
|
let many = (0..21)
|
|
.map(|_| uuid::Uuid::new_v4().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
for (query, status) in [
|
|
(format!("channel_ids={id}"), StatusCode::OK),
|
|
(format!("channel_ids={id},{id}"), StatusCode::BAD_REQUEST),
|
|
(format!("channel_ids={id}&limit=1"), StatusCode::BAD_REQUEST),
|
|
(
|
|
format!("channel_ids={id}&cursor={id}"),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
("channel_ids=".into(), StatusCode::BAD_REQUEST),
|
|
("channel_ids=not-a-uuid".into(), StatusCode::BAD_REQUEST),
|
|
(format!("channel_ids={many}"), StatusCode::BAD_REQUEST),
|
|
] {
|
|
let path = format!("/buzz/v1/me/sidebar?{query}");
|
|
let auth = proof(&actor, &host, &path, "GET", None);
|
|
let (got, body) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(got, status, "{query}: {body}");
|
|
if status == StatusCode::OK {
|
|
assert_eq!(body["channels"], json!([]), "unjoined ID is simply absent");
|
|
assert!(body["next_cursor"].is_null());
|
|
}
|
|
}
|
|
let channel = state
|
|
.db
|
|
.create_channel(
|
|
community,
|
|
"boundary",
|
|
buzz_db::channel::ChannelType::Stream,
|
|
buzz_db::channel::ChannelVisibility::Open,
|
|
None,
|
|
&actor.public_key().to_bytes(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let event = EventBuilder::new(Kind::Custom(9), "read by one signer only")
|
|
.sign_with_keys(&Keys::generate())
|
|
.unwrap();
|
|
state
|
|
.db
|
|
.insert_event(community, &event, Some(channel))
|
|
.await
|
|
.unwrap();
|
|
let write_path = "/buzz/v1/me/read-state";
|
|
let body = serde_json::to_vec(&json!({"intents":[{"type":"mark_channel_read",
|
|
"channel_id":channel,"message_id":event.id.to_hex()}]}))
|
|
.unwrap();
|
|
let missing_hash = proof(&actor, &host, write_path, "POST", None);
|
|
assert_eq!(
|
|
request(
|
|
state.clone(),
|
|
&host,
|
|
write_path,
|
|
"POST",
|
|
Some(&missing_hash),
|
|
&body
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let wrong_hash = proof(&actor, &host, write_path, "POST", Some(b"{}"));
|
|
assert_eq!(
|
|
request(
|
|
state.clone(),
|
|
&host,
|
|
write_path,
|
|
"POST",
|
|
Some(&wrong_hash),
|
|
&body
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let auth = proof(&actor, &host, write_path, "POST", Some(&body));
|
|
let applied = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
|
|
assert_eq!(applied.0, StatusCode::OK, "{}", applied.1);
|
|
assert_eq!(applied.1["outcomes"][0]["status"], "applied");
|
|
// The frontier belongs to the signer alone.
|
|
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
|
|
let targets: String = targets
|
|
.to_string()
|
|
.bytes()
|
|
.map(|b| format!("%{b:02X}"))
|
|
.collect();
|
|
let path = format!("/buzz/v1/me/read-state?targets={targets}");
|
|
start_before_everything(&state, community, &other).await;
|
|
for (key, status) in [(&actor, "read"), (&other, "unread")] {
|
|
let auth = proof(key, &host, &path, "GET", None);
|
|
let page = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(page.0, StatusCode::OK, "{}", page.1);
|
|
assert_eq!(page.1["contexts"][0]["messages"][0]["status"], status);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_context_get_signed_query_and_independent_batch_outcomes() {
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
let mut state = (*fixture).clone();
|
|
Arc::make_mut(&mut state.config).buzz_v1_enabled = true;
|
|
let state = Arc::new(state);
|
|
let host = format!("bff-context-{}.local", uuid::Uuid::new_v4());
|
|
let community = state
|
|
.db
|
|
.ensure_configured_community(&host)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let actor = Keys::generate();
|
|
let channel = state
|
|
.db
|
|
.create_channel(
|
|
community,
|
|
"context",
|
|
buzz_db::channel::ChannelType::Stream,
|
|
buzz_db::channel::ChannelVisibility::Open,
|
|
None,
|
|
&actor.public_key().to_bytes(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let event = EventBuilder::new(Kind::Custom(9), "message selector")
|
|
.sign_with_keys(&Keys::generate())
|
|
.unwrap();
|
|
state
|
|
.db
|
|
.insert_event(community, &event, Some(channel))
|
|
.await
|
|
.unwrap();
|
|
let targets = json!([{"target":{"channel_id":channel},"message_ids":[event.id.to_hex()]}]);
|
|
let encode = |value: &str| {
|
|
value
|
|
.bytes()
|
|
.map(|b| format!("%{b:02X}"))
|
|
.collect::<String>()
|
|
};
|
|
let path = format!(
|
|
"/buzz/v1/me/read-state?targets={}",
|
|
encode(&targets.to_string())
|
|
);
|
|
start_before_everything(&state, community, &actor).await;
|
|
let auth = proof(&actor, &host, &path, "GET", None);
|
|
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
|
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "unread");
|
|
let auth = proof(&actor, &host, "/buzz/v1/me/read-state", "GET", None);
|
|
assert_eq!(
|
|
request(state.clone(), &host, &path, "GET", Some(&auth), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let bad_path = "/buzz/v1/me/read-state?targets=invalid";
|
|
let auth = proof(&actor, &host, bad_path, "GET", None);
|
|
assert_eq!(
|
|
request(state.clone(), &host, bad_path, "GET", Some(&auth), b"")
|
|
.await
|
|
.0,
|
|
StatusCode::BAD_REQUEST
|
|
);
|
|
let write_path = "/buzz/v1/me/read-state";
|
|
let body = serde_json::to_vec(&json!({"intents":[
|
|
{"type":"unknown"},
|
|
{"type":"mark_through","target":{"channel_id":uuid::Uuid::new_v4()},"message_id":event.id.to_hex()},
|
|
{"type":"mark_through","target":{"channel_id":channel},"message_id":event.id.to_hex()},
|
|
{"type":"mark_through","target":{"channel_id":channel},"message_id":"not an event id"}
|
|
]})).unwrap();
|
|
let auth = proof(&actor, &host, write_path, "POST", Some(&body));
|
|
let result = request(state.clone(), &host, write_path, "POST", Some(&auth), &body).await;
|
|
assert_eq!(result.0, StatusCode::OK, "{}", result.1);
|
|
assert_eq!(
|
|
result.1["outcomes"],
|
|
json!([{"status":"invalid"},{"status":"blocked"},{"status":"applied"},{"status":"invalid"}])
|
|
);
|
|
let auth = proof(&actor, &host, &path, "GET", None);
|
|
let result = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(result.1["contexts"][0]["messages"][0]["status"], "read");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_write_revocation_is_terminal_before_persistence() {
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
let mut state = (*fixture).clone();
|
|
let config = Arc::make_mut(&mut state.config);
|
|
config.buzz_v1_enabled = true;
|
|
config.require_relay_membership = true;
|
|
let state = Arc::new(state);
|
|
let host = format!("bff-revocation-{}.local", uuid::Uuid::new_v4());
|
|
let community = state
|
|
.db
|
|
.ensure_configured_community(&host)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let actor = Keys::generate();
|
|
state
|
|
.db
|
|
.add_relay_member(community, &actor.public_key().to_hex(), "member", None)
|
|
.await
|
|
.unwrap();
|
|
let channel = state
|
|
.db
|
|
.create_channel(
|
|
community,
|
|
"revocation",
|
|
buzz_db::channel::ChannelType::Stream,
|
|
buzz_db::channel::ChannelVisibility::Open,
|
|
None,
|
|
&actor.public_key().to_bytes(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let event = EventBuilder::new(Kind::Custom(9), "markable before revoke")
|
|
.sign_with_keys(&Keys::generate())
|
|
.unwrap();
|
|
state
|
|
.db
|
|
.insert_event(community, &event, Some(channel))
|
|
.await
|
|
.unwrap();
|
|
let intent = buzz_db::personal_read::ReadIntent::MarkChannelRead {
|
|
channel_id: channel,
|
|
message_id: event.id.to_hex(),
|
|
};
|
|
let path = "/buzz/v1/me/read-state";
|
|
let body = &serde_json::to_vec(&json!({"intents":[intent]})).unwrap()[..];
|
|
let mut headers = axum::http::HeaderMap::new();
|
|
headers.insert("host", host.parse().unwrap());
|
|
headers.insert(
|
|
"authorization",
|
|
proof(&actor, &host, path, "POST", Some(body))
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
let principal =
|
|
super::auth::authorize(&state, &headers, &path.parse().unwrap(), "POST", Some(body))
|
|
.await
|
|
.ok()
|
|
.expect("admitted before revoke");
|
|
state
|
|
.db
|
|
.remove_relay_member(community, &actor.public_key().to_hex())
|
|
.await
|
|
.unwrap();
|
|
// Exercise the same per-item function the batch handler uses after admission.
|
|
let result = super::handlers::write_intent(&state, &headers, &principal, &intent).await;
|
|
assert_eq!(result, json!({"status":"blocked"}));
|
|
let persisted: i64 =
|
|
sqlx::query_scalar("SELECT count(*) FROM personal_read_accounts WHERE community_id=$1")
|
|
.bind(community.as_uuid())
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(persisted, 0, "denied intent must not persist");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_discovery_is_host_bound_and_opt_in() {
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
let host = format!("bff-discovery-{}.local", uuid::Uuid::new_v4());
|
|
fixture.db.ensure_configured_community(&host).await.unwrap();
|
|
for enabled in [false, true] {
|
|
let mut state = (*fixture).clone();
|
|
let config = Arc::make_mut(&mut state.config);
|
|
config.buzz_v1_enabled = enabled;
|
|
config.buzz_v1_retention_seconds = 1234;
|
|
let state = Arc::new(state);
|
|
for known_host in [true, false] {
|
|
let request_host = if known_host {
|
|
host.as_str()
|
|
} else {
|
|
"unknown.invalid"
|
|
};
|
|
for path in ["/", "/info"] {
|
|
let response = crate::router::build_router(state.clone())
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(path)
|
|
.header("host", request_host)
|
|
.header("accept", "application/nostr+json")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
|
let doc: Value = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(
|
|
doc.get("buzz_v1").is_some(),
|
|
enabled && known_host,
|
|
"enabled={enabled} known_host={known_host} path={path}"
|
|
);
|
|
if enabled && known_host {
|
|
let d = &doc["buzz_v1"];
|
|
assert_eq!(d["version"], 1);
|
|
assert_eq!(d["base_path"], "/buzz/v1");
|
|
assert_eq!(d["retention_seconds"], 1234);
|
|
assert_eq!(d["max_channels"], buzz_db::personal_read::MAX_CHANNELS);
|
|
assert_eq!(d["max_intents"], buzz_db::personal_read::MAX_INTENTS);
|
|
assert_eq!(d["max_contexts"], buzz_db::personal_read::MAX_CONTEXTS);
|
|
assert_eq!(
|
|
d["max_context_messages"],
|
|
buzz_db::personal_read::MAX_CONTEXT_MESSAGES
|
|
);
|
|
assert_eq!(d["max_thread_summaries"], 5);
|
|
assert_eq!(d["eligible_kinds"], json!([9, 40002, 45001, 45003]));
|
|
}
|
|
}
|
|
}
|
|
// Disabled means unmounted: indistinguishable from a path that never existed.
|
|
let mut statuses = Vec::new();
|
|
for path in ["/buzz/v1/me/sidebar", "/buzz/v1-never-existed"] {
|
|
let request = Request::builder().uri(path).header("host", host.as_str());
|
|
let response = crate::router::build_router(state.clone())
|
|
.oneshot(request.body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
statuses.push(response.status());
|
|
}
|
|
assert_eq!(statuses[0] == statuses[1], !enabled, "{statuses:?}");
|
|
}
|
|
}
|
|
|
|
// Pins Off parity under NIP-FI Shadow. These error bodies carry a per-request
|
|
// id, so they cannot ride the router's byte-for-byte Shadow rows: Shadow keeps
|
|
// Off's status and code and leaves exactly one verdict.
|
|
// Mutation: binding the tenant without `bind_tenant` leaves the unseeded row
|
|
// with no record; answering a failed proof with the NIP-FI response whenever
|
|
// the mode is not Off changes the seeded row's code.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_shadow_matches_off() {
|
|
use crate::nip_fi_core::tests::ScriptedVerifier;
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
for (seeded, status, code) in [
|
|
(false, StatusCode::NOT_FOUND, "not_found"),
|
|
(true, StatusCode::UNAUTHORIZED, "unauthorized"),
|
|
] {
|
|
let host = format!("bff-shadow-{}.local", uuid::Uuid::new_v4());
|
|
if seeded {
|
|
fixture.db.ensure_configured_community(&host).await.unwrap();
|
|
}
|
|
for (mode, records) in [
|
|
(buzz_auth::NipFiMode::Off, 0),
|
|
(buzz_auth::NipFiMode::Shadow, 1),
|
|
] {
|
|
let mut state = (*fixture).clone();
|
|
let config = Arc::make_mut(&mut state.config);
|
|
config.buzz_v1_enabled = true;
|
|
config.nip_fi.mode = mode;
|
|
config.nip_fi.communities = crate::nip_fi_config::NipFiCommunities::for_test(
|
|
&format!("https://{host}"),
|
|
&["https://issuer.test"],
|
|
);
|
|
state.nip_fi_verifier = Some(Arc::new(ScriptedVerifier::new(Ok(Some(
|
|
Keys::generate().public_key(),
|
|
)))));
|
|
let recorder = metrics_util::debugging::DebuggingRecorder::new();
|
|
let snapshotter = recorder.snapshotter();
|
|
let _guard = metrics::set_default_local_recorder(&recorder);
|
|
// An assertion the guard accepts, and no NIP-98 proof.
|
|
let req = Request::get("/buzz/v1/me/sidebar")
|
|
.header("host", &host)
|
|
.header(buzz_auth::CLIENT_ATTACHED_HEADER, "Bearer a.b.c")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = crate::router::build_router(Arc::new(state))
|
|
.oneshot(req)
|
|
.await
|
|
.unwrap();
|
|
let got = response.status();
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
|
let body: Value = serde_json::from_slice(&bytes).unwrap_or_default();
|
|
let recorded: u64 = snapshotter
|
|
.snapshot()
|
|
.into_vec()
|
|
.iter()
|
|
.filter(|(key, ..)| key.key().name() == "buzz_nip_fi_shadow_total")
|
|
.map(|(.., value)| match value {
|
|
metrics_util::debugging::DebugValue::Counter(n) => *n,
|
|
_ => 0,
|
|
})
|
|
.sum();
|
|
assert_eq!(
|
|
(got, &body["error"]["code"], recorded),
|
|
(status, &json!(code), records),
|
|
"seeded={seeded} {mode:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exercise the real signed HTTP ingest path, including deletion side effects,
|
|
// rather than directly tombstoning an event in the database.
|
|
async fn signed_sidebar_deletion(deletion_kind: u16) {
|
|
let fixture = crate::api::bridge::postgres_tests::bridge_handler_test_state()
|
|
.await
|
|
.unwrap();
|
|
let mut state = (*fixture).clone();
|
|
let config = Arc::make_mut(&mut state.config);
|
|
config.require_auth_token = true;
|
|
config.require_relay_membership = true;
|
|
config.buzz_v1_enabled = true;
|
|
let state = Arc::new(state);
|
|
let host = format!("bff-deletion-{}.local", uuid::Uuid::new_v4());
|
|
let community = state
|
|
.db
|
|
.ensure_configured_community(&host)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
let admin = Keys::generate();
|
|
let author = Keys::generate();
|
|
let reader = Keys::generate();
|
|
for key in [&admin, &author, &reader] {
|
|
state
|
|
.db
|
|
.add_relay_member(community, &key.public_key().to_hex(), "member", None)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let channel = state
|
|
.db
|
|
.create_channel(
|
|
community,
|
|
"deletion",
|
|
buzz_db::channel::ChannelType::Stream,
|
|
buzz_db::channel::ChannelVisibility::Open,
|
|
None,
|
|
&admin.public_key().to_bytes(),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.id;
|
|
for key in [&author, &reader] {
|
|
state
|
|
.db
|
|
.add_member(
|
|
community,
|
|
channel,
|
|
&key.public_key().to_bytes(),
|
|
buzz_db::channel::MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
let message = EventBuilder::new(Kind::Custom(9), "unread message to delete")
|
|
.tags([Tag::parse(["h", &channel.to_string()]).unwrap()])
|
|
.sign_with_keys(&author)
|
|
.unwrap();
|
|
let mut tags = vec![Tag::parse(["e", &message.id.to_hex()]).unwrap()];
|
|
let signer = if deletion_kind == 5 {
|
|
&author
|
|
} else {
|
|
tags.push(Tag::parse(["h", &channel.to_string()]).unwrap());
|
|
&admin
|
|
};
|
|
let deletion = EventBuilder::new(Kind::Custom(deletion_kind), "")
|
|
.tags(tags)
|
|
.sign_with_keys(signer)
|
|
.unwrap();
|
|
let path = format!("/buzz/v1/me/sidebar?channel_ids={channel}");
|
|
start_before_everything(&state, community, &reader).await;
|
|
for (event, key, count) in [(&message, &author, 1), (&deletion, signer, 0)] {
|
|
let body = serde_json::to_vec(event).unwrap();
|
|
let auth = proof(key, &host, "/events", "POST", Some(&body));
|
|
// /events has a different response contract from the accessory helper.
|
|
let response = crate::router::build_router(state.clone())
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri("/events")
|
|
.header("host", &host)
|
|
.header("authorization", auth)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
|
let result: Value = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
assert_eq!(result["accepted"], true, "{result}");
|
|
assert_eq!(result["event_id"], event.id.to_hex(), "{result}");
|
|
|
|
// This distinct member never writes a frontier. Reads alone must not
|
|
// erase unread state; only the accepted signed deletion changes it.
|
|
let auth = proof(&reader, &host, &path, "GET", None);
|
|
let (status, sidebar) = request(state.clone(), &host, &path, "GET", Some(&auth), b"").await;
|
|
assert_eq!(status, StatusCode::OK, "{sidebar}");
|
|
let channels = sidebar["channels"].as_array().unwrap();
|
|
assert_eq!(channels.len(), 1, "{sidebar}");
|
|
assert_eq!(channels[0]["channel_id"], channel.to_string());
|
|
assert_eq!(
|
|
channels[0]["unread"],
|
|
json!({"status":"exact", "value":count}),
|
|
"kind {deletion_kind}, after kind {}: {sidebar}",
|
|
event.kind.as_u16()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_signed_kind5_deletion_clears_another_readers_sidebar_count() {
|
|
signed_sidebar_deletion(5).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessory_signed_kind9005_deletion_clears_another_readers_sidebar_count() {
|
|
signed_sidebar_deletion(9005).await;
|
|
}
|